From a5629295ac956faf776cbb3d9529b21164ed3639 Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Tue, 7 Apr 2026 02:51:30 +0200 Subject: [PATCH] Implemented the next parity slice. New runtime/code: - src/ask_user_runtime.py - src/team_runtime.py New real tools in src/agent_tools.py: - ask_user_question - team_create - team_delete - team_list - team_get - send_message - team_messages - notebook_edit --- PARITY_CHECKLIST.md | 25 +- README.md | 22 +- TESTING_GUIDE.md | 149 +++++++++- benchmarks/run_terminal_bench_local.py | 35 ++- src/__init__.py | 8 + src/agent_context.py | 8 + src/agent_prompting.py | 26 ++ src/agent_runtime.py | 69 +++++ src/agent_slash_commands.py | 46 +++ src/agent_tools.py | 395 +++++++++++++++++++++++++ src/ask_user_runtime.py | 320 ++++++++++++++++++++ src/main.py | 83 ++++++ src/team_runtime.py | 386 ++++++++++++++++++++++++ tests/test_agent_context.py | 28 ++ tests/test_agent_prompting.py | 38 +++ tests/test_agent_slash_commands.py | 37 +++ tests/test_ask_user_runtime.py | 55 ++++ tests/test_extended_tools.py | 38 ++- tests/test_main.py | 14 + tests/test_team_runtime.py | 76 +++++ tests/test_terminal_bench_local.py | 52 ++++ 21 files changed, 1886 insertions(+), 24 deletions(-) create mode 100644 src/ask_user_runtime.py create mode 100644 src/team_runtime.py create mode 100644 tests/test_ask_user_runtime.py create mode 100644 tests/test_team_runtime.py diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index c7a5bf0..71737fc 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -299,10 +299,12 @@ Done: - [x] `web_search` - [x] `tool_search` - [x] `sleep` +- [x] `ask_user_question` - [x] `account_status` - [x] `account_list_profiles` - [x] `account_login` - [x] `account_logout` +- [x] `notebook_edit` - [x] `mcp_list_resources` - [x] `mcp_read_resource` - [x] `mcp_list_tools` @@ -328,20 +330,22 @@ Done: - [x] `task_block` - [x] `task_cancel` - [x] `todo_write` +- [x] `team_list` +- [x] `team_get` +- [x] `team_create` +- [x] `team_delete` +- [x] `send_message` +- [x] `team_messages` Missing: - [ ] Agent spawning tool parity beyond the current `delegate_agent` runtime tool - [ ] Skill tool -- [ ] Notebook edit tool - [ ] Web fetch parity beyond the current local text-fetch implementation - [ ] Web search parity beyond the current provider-backed implementation -- [ ] Ask-user-question tool - [ ] LSP tool - [ ] Tool search parity beyond the current local registry search - [ ] Config tool -- [ ] Team create/delete tools -- [ ] Send-message tool - [ ] Terminal capture tool - [ ] Browser tool - [ ] Workflow tool @@ -364,13 +368,15 @@ Done: - [x] Local dependency-aware task execution flow with next-task selection and blocked/unblocked state - [x] Local remote profile/runtime flow with persisted connect/disconnect state - [x] Local background task management for agent worker sessions +- [x] Local ask-user runtime with queued answers, history, and slash/CLI inspection flows +- [x] Local team runtime with persisted teams, messages, and slash/CLI inspection flows Missing: - [ ] Real implementation of the larger upstream command tree - [ ] Task orchestration system beyond the current local dependency-aware task runtime - [ ] Planner / task execution parity beyond the current local plan persistence, sync, and next-task flow -- [ ] Team / collaboration command flows +- [ ] Team / collaboration command flows beyond the current local team runtime and message recording flows - [ ] Command-specific session behaviors - [ ] Full `src/commands/*` parity - [ ] Full `src/tasks/*` parity @@ -532,12 +538,8 @@ Mirrored inventory / scaffold areas that still need real implementation work: - [ ] `src/tools.py` - [ ] `src/query_engine.py` - [ ] `src/runtime.py` -- [ ] `src/services/*` -- [ ] `src/plugins/*` -- [ ] `src/remote/*` -- [ ] `src/voice/*` -- [ ] `src/vim/*` -- [ ] Large parts of the rest of the mirrored package tree +- [ ] Remaining mirrored inventory surfaces still represented mainly by snapshot data under `src/reference_data/*` +- [ ] Command/task/plugin/skill/service/editor subsystems that exist upstream but do not yet have real Python modules after the tree cleanup ## 15. High-Priority Next Steps @@ -546,7 +548,6 @@ Mirrored inventory / scaffold areas that still need real implementation work: - [ ] Expand MCP parity beyond the current stdio resource/tool transport support - [ ] Expand hooks and policy parity beyond the current manifest/runtime implementation - [ ] Build a real interactive REPL / TUI -- [ ] Add tokenizer-accurate context accounting - [ ] Expand background session parity beyond the current local worker/log/attach model - [ ] Add real remote session transport and shared remote state beyond the current local remote-profile runtime - [ ] Port more of the command/task system diff --git a/README.md b/README.md index 41d31a3..ff004a3 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,9 @@ | 🆕 | **MCP Transport** | Real stdio MCP transport for `initialize`, resource listing/reading, and tool listing/calling | | 🆕 | **Search Runtime** | Provider-backed `web_search` with local manifests, activation state, and `/search` flows | | 🆕 | **Config & Account Runtime** | Local config/settings mutation plus manifest-backed account profiles and login/logout state | +| 🆕 | **Ask-User Runtime** | Queued or interactive local ask-user flow with history, slash commands, and agent tool support | +| 🆕 | **Team Runtime** | Persisted local teams and message history with team/message tools and slash/CLI inspection | +| 🆕 | **Notebook Edit Tool** | Native `.ipynb` cell editing through the real agent tool registry | | 🆕 | **Tokenizer-Aware Context** | Cached tokenizer backends with heuristic fallback for `/context`, `/status`, and compaction | | 🆕 | **Daemon Commands** | Local `daemon start/ps/logs/attach/kill` wrapper over background agent sessions | | 🆕 | **Background Sessions** | Local `agent-bg`, `agent-ps`, `agent-logs`, `agent-attach`, and `agent-kill` flows | @@ -86,6 +89,9 @@ Built on the public porting workspace from [instructkr/claw-code](https://github | 🛰️ **MCP Runtime** | Local MCP manifests plus real stdio MCP transport for resources and tools | | 🔎 **Search Runtime** | Provider-backed `web_search` plus provider activation and status reporting | | ⚙️ **Config & Account Runtime** | Local config mutation, settings inspection, account profiles, and login/logout state | +| 🙋 **Ask-User Runtime** | Queued answer or interactive user-question flow with history tracking | +| 👥 **Team Runtime** | Persisted local teams plus message history, handoff notes, and collaboration metadata | +| 📓 **Notebook Editing** | Native Jupyter notebook cell editing through `notebook_edit` | | 🪝 **Hook & Policy Runtime** | Trust reporting, safe env, managed settings, tool blocking, and budget overrides | | 🧠 **Context Engine** | Automatic context building with CLAUDE.md discovery, compaction, and snipping | | 🔢 **Tokenizer-Aware Accounting** | Model-aware token counting with cached tokenizer backends and fallback heuristics | @@ -137,9 +143,12 @@ Built on the public porting workspace from [instructkr/claw-code](https://github - [x] Local hook and policy runtime with trust reporting, safe env, tool blocking, and budget overrides - [x] Local config runtime: config discovery, effective settings, source inspection, and config mutation - [x] Local account runtime: profile discovery, login/logout state, and account CLI/slash flows +- [x] Local ask-user runtime: queued answers, history, and ask-user CLI/slash flows +- [x] Local team runtime: persisted teams, team messages, and team CLI/slash flows - [x] Local search runtime with provider discovery, activation, and provider-backed `web_search` - [x] Local MCP runtime: manifest resources, stdio transport, MCP resources, and MCP tool calls - [x] Local task and plan runtimes with plan sync and dependency-aware task execution +- [x] Notebook edit tool in the real Python tool registry - [x] Tokenizer-aware context accounting with cached tokenizer backends and heuristic fallback - [x] Plugin runtime: manifest discovery, hooks, aliases, virtual tools, tool blocking - [x] Plugin lifecycle hooks: resume, persist, delegate phases @@ -175,7 +184,7 @@ claw-code/ ├── .gitignore ├── images/ │ └── logo.png -├── src/ # Python implementation (75+ modules) +├── src/ # Python implementation │ ├── main.py # CLI entry point & argument parsing │ ├── agent_runtime.py # Core agent loop (LocalCodingAgent) │ ├── agent_tools.py # Tool definitions & execution engine @@ -197,21 +206,20 @@ claw-code/ │ ├── remote_runtime.py # Local remote profiles, connect/disconnect state, remote CLI support │ ├── background_runtime.py # Local background sessions and daemon support │ ├── account_runtime.py # Local account profiles, login/logout state, account CLI support +│ ├── ask_user_runtime.py # Local ask-user queued answers and interaction history │ ├── config_runtime.py # Local workspace config/settings discovery and mutation │ ├── plan_runtime.py # Persistent plan runtime and plan sync │ ├── task_runtime.py # Persistent task runtime and task execution │ ├── task.py # Task state model and task dataclasses +│ ├── team_runtime.py # Local teams, messages, and collaboration metadata │ ├── hook_policy.py # Hook/policy manifests, trust, and safe env handling │ ├── tokenizer_runtime.py # Tokenizer-aware context accounting backends │ ├── permissions.py # Tool permission filtering │ ├── cost_tracker.py # Cost & budget enforcement -│ ├── tools.py # Mirrored tool inventory │ ├── commands.py # Mirrored command inventory -│ ├── plugins/ # Plugin subsystem -│ ├── hooks/ # Hook system (WIP) -│ ├── remote/ # Remote runtime modes (WIP) -│ ├── voice/ # Voice mode (WIP) -│ └── vim/ # VIM mode (WIP) +│ ├── tools.py # Mirrored tool inventory +│ ├── runtime.py # Mirrored runtime facade +│ └── reference_data/ # Mirrored inventory snapshots └── tests/ # Unit tests ├── test_agent_runtime.py ├── test_agent_context.py diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 9ba76ad..bf30427 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -71,6 +71,8 @@ export OPENAI_API_KEY=anything export OPENAI_MODEL=ollama/qwen3 ``` +If your cluster wraps `python3`, use an explicit interpreter path such as `/usr/bin/python3.9 -m ...` for the commands below. + ### 1.5 Run the full unit test suite ```bash @@ -90,6 +92,8 @@ python3 -m unittest tests.test_background_runtime -v python3 -m unittest tests.test_remote_runtime -v python3 -m unittest tests.test_config_runtime -v python3 -m unittest tests.test_account_runtime -v +python3 -m unittest tests.test_ask_user_runtime -v +python3 -m unittest tests.test_team_runtime -v python3 -m unittest tests.test_tokenizer_runtime -v python3 -m unittest tests.test_extended_tools -v python3 -m unittest tests.test_porting_workspace -v @@ -191,6 +195,7 @@ mkdir -p ./test_cases_budget mkdir -p ./test_cases_plugins/plugins/demo mkdir -p ./test_cases_mcp mkdir -p ./test_cases_tasks +mkdir -p ./test_cases_notebooks ``` ### 4.1 Config fixtures @@ -243,6 +248,67 @@ cat > ./test_cases/.claw-account.json <<'EOF' EOF ``` +### 4.2b Ask-user fixtures + +```bash +cat > ./test_cases/.claw-ask-user.json <<'EOF' +{ + "answers": [ + { + "question": "Approve deploy?", + "answer": "yes" + }, + { + "question": "Choose rollout mode", + "answer": "safe" + } + ] +} +EOF +``` + +### 4.2c Team fixtures + +```bash +cat > ./test_cases/.claw-teams.json <<'EOF' +{ + "teams": [ + { + "name": "reviewers", + "description": "Code review group", + "members": ["alice", "bob"] + }, + { + "name": "release", + "description": "Release coordination group", + "members": ["ops", "qa"] + } + ] +} +EOF +``` + +### 4.2d Notebook fixture + +```bash +cat > ./test_cases_notebooks/demo.ipynb <<'EOF' +{ + "cells": [ + { + "cell_type": "code", + "metadata": {}, + "source": ["print(1)\n"], + "outputs": [], + "execution_count": null + } + ], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 5 +} +EOF +``` + ### 4.3 Remote fixtures ```bash @@ -528,11 +594,13 @@ python3 -m src.main agent "/settings" --cwd ./test_cases python3 -m src.main agent "/account" --cwd ./test_cases python3 -m src.main agent "/account profiles" --cwd ./test_cases python3 -m src.main agent "/account profile local" --cwd ./test_cases +python3 -m src.main agent "/ask" --cwd ./test_cases +python3 -m src.main agent "/ask history" --cwd ./test_cases python3 -m src.main agent "/login local" --cwd ./test_cases python3 -m src.main agent "/logout" --cwd ./test_cases ``` -### 5.3 Remote, search, task, and plan slash commands +### 5.3 Remote, search, team, task, and plan slash commands ```bash python3 -m src.main agent "/remote" --cwd ./test_cases @@ -549,6 +617,10 @@ python3 -m src.main agent "/search providers" --cwd ./test_cases python3 -m src.main agent "/search provider local-search" --cwd ./test_cases python3 -m src.main agent "/search use local-search" --cwd ./test_cases python3 -m src.main agent "/search python argparse tutorial" --cwd ./test_cases +python3 -m src.main agent "/teams" --cwd ./test_cases +python3 -m src.main agent "/team reviewers" --cwd ./test_cases +python3 -m src.main agent "/messages" --cwd ./test_cases +python3 -m src.main agent "/messages reviewers" --cwd ./test_cases python3 -m src.main agent "/plan" --cwd ./test_cases_tasks python3 -m src.main agent "/planner" --cwd ./test_cases_tasks python3 -m src.main agent "/tasks" --cwd ./test_cases_tasks @@ -1156,6 +1228,31 @@ python3 -m src.main agent \ --show-transcript ``` +## 14A. Ask-user Runtime + +### 14A.1 CLI status and history + +```bash +python3 -m src.main ask-status --cwd ./test_cases +python3 -m src.main ask-history --cwd ./test_cases +``` + +### 14A.2 Slash commands + +```bash +python3 -m src.main agent "/ask" --cwd ./test_cases +python3 -m src.main agent "/ask history" --cwd ./test_cases +``` + +### 14A.3 Real tool loop + +```bash +python3 -m src.main agent \ + "Use ask_user_question to answer 'Approve deploy?' and then summarize the decision." \ + --cwd ./test_cases \ + --show-transcript +``` + ## 15. Search Runtime And Real Web Search ### 15.1 Provider status and activation @@ -1303,6 +1400,56 @@ python3 -m src.main agent-context-raw --cwd ./test_cases_tasks python3 -m src.main agent-prompt --cwd ./test_cases_tasks ``` +## 18A. Team Runtime + +### 18A.1 CLI status and inspection + +```bash +python3 -m src.main team-status --cwd ./test_cases +python3 -m src.main team-list --cwd ./test_cases +python3 -m src.main team-get reviewers --cwd ./test_cases +python3 -m src.main team-messages --cwd ./test_cases +``` + +### 18A.2 Create and delete teams + +```bash +python3 -m src.main team-create docs --member alice --member bob --cwd ./test_cases +python3 -m src.main team-list --cwd ./test_cases +python3 -m src.main team-delete docs --cwd ./test_cases +``` + +### 18A.3 Slash commands + +```bash +python3 -m src.main agent "/teams" --cwd ./test_cases +python3 -m src.main agent "/team reviewers" --cwd ./test_cases +python3 -m src.main agent "/messages" --cwd ./test_cases +python3 -m src.main agent "/messages reviewers" --cwd ./test_cases +``` + +### 18A.4 Real tool loop + +```bash +python3 -m src.main agent \ + "Create a local team called docs with members alice and bob, send a message to that team asking for notebook review, then show the team messages." \ + --cwd ./test_cases \ + --allow-write \ + --show-transcript +``` + +## 18B. Notebook Edit Tool + +### 18B.1 Direct notebook edit through the agent loop + +```bash +python3 -m src.main agent \ + "Use notebook_edit to update demo.ipynb cell 0 so it prints 2, then read back the notebook file and summarize the change." \ + --cwd ./test_cases_notebooks \ + --allow-write \ + --show-transcript +``` + ### 18.2 Create and inspect tasks ```bash diff --git a/benchmarks/run_terminal_bench_local.py b/benchmarks/run_terminal_bench_local.py index fa3dd89..beea990 100644 --- a/benchmarks/run_terminal_bench_local.py +++ b/benchmarks/run_terminal_bench_local.py @@ -271,6 +271,7 @@ def build_verifier_exec_command( task_dir: Path, verifier_logs_dir: Path, env: dict[str, str], + fakeroot: bool = False, ) -> str: binds = [ f"{workspace_dir}:{task.workdir}:rw", @@ -278,18 +279,35 @@ def build_verifier_exec_command( f"{verifier_logs_dir}:/logs/verifier:rw", ] bind_flags = " ".join(f"--bind {shlex.quote(spec)}" for spec in binds) + + # When using fakeroot (no-sudo HPC), inject env vars needed for apt-get + # and SSL inside the container. + if fakeroot: + fakeroot_env = { + "TMPDIR": "/tmp", + "DEBIAN_FRONTEND": "noninteractive", + "CURL_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt", + "SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt", + "REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt", + } + env = {**fakeroot_env, **env} + env_flags = " ".join( f"--env {shlex.quote(key)}={shlex.quote(value)}" for key, value in sorted(env.items()) ) inner = ( "set -euo pipefail; " - "chmod +x /tests/test.sh; " + "chmod +x /tests/test.sh 2>/dev/null || true; " f"cd {shlex.quote(task.workdir)}; " - "/tests/test.sh > /logs/verifier/test-stdout.txt 2> /logs/verifier/test-stderr.txt" + "bash /tests/test.sh > /logs/verifier/test-stdout.txt 2> /logs/verifier/test-stderr.txt" ) + # --fakeroot: simulate root inside the container (for apt-get etc.) + # --writable-tmpfs: in-memory overlay so packages can be installed + # --contain: prevent host dirs from leaking into container + fakeroot_flags = "--fakeroot --writable-tmpfs --contain " if fakeroot else "" return ( - f"apptainer exec --cleanenv {bind_flags} {env_flags} " + f"apptainer exec --cleanenv {fakeroot_flags}{bind_flags} {env_flags} " f"--cwd {shlex.quote(task.workdir)} {shlex.quote(str(image_path))} " f"bash -lc {shlex.quote(inner)}" ) @@ -304,6 +322,7 @@ def run_trial( force_pull: bool, keep_images: bool, timeout_multiplier: float, + fakeroot: bool = False, ) -> LocalTrialResult: timestamp = time.strftime("%Y%m%d-%H%M%S") trial_dir = jobs_dir / f"{timestamp}_{safe_name(task.short_name)}" @@ -363,6 +382,7 @@ def run_trial( task_dir=task.task_dir, verifier_logs_dir=verifier_logs_dir, env=verifier_env, + fakeroot=fakeroot, ) agent_timeout = (task.agent_timeout_sec or 1800.0) * timeout_multiplier @@ -447,6 +467,12 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--timeout-multiplier", type=float, default=1.0, help="Multiplier applied to agent and verifier timeouts.") parser.add_argument("--output", type=Path, help="Optional JSON file for the run summary.") parser.add_argument("--list", action="store_true", help="List discovered tasks and exit.") + parser.add_argument( + "--fakeroot", + action="store_true", + help="Use Apptainer --fakeroot + --writable-tmpfs for the verifier container. " + "Required on HPC systems without sudo where test.sh scripts need apt-get.", + ) return parser @@ -481,6 +507,8 @@ def main() -> None: print("=" * 80) print(f" Tasks: {len(tasks)}") print(f" Jobs dir: {args.jobs_dir}") + if args.fakeroot: + print(" Fakeroot: enabled (no-sudo HPC mode)") print("=" * 80) print() @@ -494,6 +522,7 @@ def main() -> None: force_pull=args.force_pull, keep_images=args.keep_images, timeout_multiplier=args.timeout_multiplier, + fakeroot=args.fakeroot, ) results.append(result) icon = "PASS ✅" if result.passed else ("SKIP ⚪" if result.status == "skipped" else "FAIL ❌") diff --git a/src/__init__.py b/src/__init__.py index 5c49660..278c962 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,6 +1,7 @@ """Python porting workspace for the Claude Code rewrite effort.""" from .account_runtime import AccountRuntime, AccountProfile, AccountSessionState, AccountStatusReport +from .ask_user_runtime import AskUserRuntime, AskUserResponse, QueuedUserAnswer from .agent_context import ( AgentContextSnapshot, build_context_snapshot, @@ -29,6 +30,7 @@ from .session_store import StoredSession, load_session, save_session from .system_init import build_system_init_message from .task import PortingTask from .task_runtime import TaskRuntime +from .team_runtime import TeamDefinition, TeamMessage, TeamRuntime from .tokenizer_runtime import TokenCounterInfo, clear_token_counter_cache, count_tokens, describe_token_counter from .tools import PORTED_TOOLS, build_tool_backlog @@ -42,6 +44,8 @@ __all__ = [ 'AccountRuntime', 'AccountSessionState', 'AccountStatusReport', + 'AskUserResponse', + 'AskUserRuntime', 'AgentMessage', 'AgentSessionState', 'BackgroundSessionRuntime', @@ -60,6 +64,7 @@ __all__ = [ 'PortRuntime', 'PluginRuntime', 'PortingTask', + 'QueuedUserAnswer', 'QueryEnginePort', 'RuntimeSession', 'SearchProviderProfile', @@ -68,6 +73,9 @@ __all__ = [ 'SearchStatusReport', 'StoredSession', 'TaskRuntime', + 'TeamDefinition', + 'TeamMessage', + 'TeamRuntime', 'TokenCounterInfo', 'TurnResult', 'PORTED_COMMANDS', diff --git a/src/agent_context.py b/src/agent_context.py index 1e11e58..35bc69b 100644 --- a/src/agent_context.py +++ b/src/agent_context.py @@ -10,6 +10,7 @@ from pathlib import Path from .agent_plugin_cache import load_plugin_cache_summary from .account_runtime import AccountRuntime +from .ask_user_runtime import AskUserRuntime from .config_runtime import ConfigRuntime from .hook_policy import HookPolicyRuntime from .mcp_runtime import MCPRuntime @@ -18,6 +19,7 @@ from .plugin_runtime import PluginRuntime from .remote_runtime import RemoteRuntime from .search_runtime import SearchRuntime from .task_runtime import TaskRuntime +from .team_runtime import TeamRuntime from .agent_types import AgentRuntimeConfig MAX_STATUS_CHARS = 2000 @@ -237,6 +239,9 @@ def _get_user_context_cached( account_runtime = AccountRuntime.from_workspace(Path(cwd), additional_working_directories) if account_runtime.has_account_state(): context['accountRuntime'] = account_runtime.render_summary() + ask_user_runtime = AskUserRuntime.from_workspace(Path(cwd), additional_working_directories) + if ask_user_runtime.has_state(): + context['askUserRuntime'] = ask_user_runtime.render_summary() config_runtime = ConfigRuntime.from_workspace(Path(cwd)) if config_runtime.has_config(): context['configRuntime'] = config_runtime.render_summary() @@ -246,6 +251,9 @@ def _get_user_context_cached( task_runtime = TaskRuntime.from_workspace(Path(cwd)) if task_runtime.tasks: context['taskRuntime'] = task_runtime.render_summary() + team_runtime = TeamRuntime.from_workspace(Path(cwd), additional_working_directories) + if team_runtime.has_team_state(): + context['teamRuntime'] = team_runtime.render_summary() return context diff --git a/src/agent_prompting.py b/src/agent_prompting.py index 4c98c5c..8ee0831 100644 --- a/src/agent_prompting.py +++ b/src/agent_prompting.py @@ -95,9 +95,11 @@ def build_system_prompt_parts( get_remote_guidance_section(prompt_context), get_search_guidance_section(prompt_context), get_account_guidance_section(prompt_context), + get_ask_user_guidance_section(prompt_context), get_config_guidance_section(prompt_context), get_plan_guidance_section(prompt_context), get_task_guidance_section(prompt_context), + get_team_guidance_section(prompt_context), get_hook_policy_guidance_section(prompt_context), get_tone_and_style_section(), get_output_efficiency_section(), @@ -277,6 +279,18 @@ def get_account_guidance_section(prompt_context: PromptContext) -> str: return '\n'.join(['# Account', *prepend_bullets(items)]) +def get_ask_user_guidance_section(prompt_context: PromptContext) -> str: + ask_user_runtime = prompt_context.user_context.get('askUserRuntime') + if not ask_user_runtime: + return '' + items = [ + 'A local ask-user runtime may be available with queued answers or optional interactive prompting.', + 'Use ask_user_question when you genuinely need a user decision or clarification that should not be guessed.', + 'If ask_user_question reports that no queued answer is available, explain the limitation or ask the human user directly outside the tool loop.', + ] + return '\n'.join(['# Ask User', *prepend_bullets(items)]) + + def get_config_guidance_section(prompt_context: PromptContext) -> str: config_runtime = prompt_context.user_context.get('configRuntime') if not config_runtime: @@ -302,6 +316,18 @@ def get_task_guidance_section(prompt_context: PromptContext) -> str: return '\n'.join(['# Tasks', *prepend_bullets(items)]) +def get_team_guidance_section(prompt_context: PromptContext) -> str: + team_runtime = prompt_context.user_context.get('teamRuntime') + if not team_runtime: + return '' + items = [ + 'A local collaboration team runtime may be available with persisted teams and message history.', + 'Use the team tools when the task needs local team state, simple collaboration metadata, or persisted teammate messages.', + 'Use send_message to record a concrete handoff or note to a team instead of burying it in free-form assistant text.', + ] + return '\n'.join(['# Teams', *prepend_bullets(items)]) + + def get_plan_guidance_section(prompt_context: PromptContext) -> str: plan_runtime = prompt_context.user_context.get('planRuntime') if not plan_runtime: diff --git a/src/agent_runtime.py b/src/agent_runtime.py index 013f118..38a9372 100644 --- a/src/agent_runtime.py +++ b/src/agent_runtime.py @@ -12,6 +12,7 @@ from .agent_manager import AgentManager from .agent_context import clear_context_caches from .agent_context import render_context_report as render_agent_context_report from .agent_context_usage import collect_context_usage, estimate_tokens, format_context_usage +from .ask_user_runtime import AskUserRuntime from .config_runtime import ConfigRuntime from .hook_policy import HookPolicyRuntime from .mcp_runtime import MCPRuntime @@ -48,6 +49,7 @@ from .plugin_runtime import PluginRuntime from .remote_runtime import RemoteRuntime from .search_runtime import SearchRuntime from .task_runtime import TaskRuntime +from .team_runtime import TeamRuntime from .tokenizer_runtime import describe_token_counter from .session_store import ( StoredAgentSession, @@ -84,9 +86,11 @@ class LocalCodingAgent: remote_runtime: RemoteRuntime | None = None search_runtime: SearchRuntime | None = None account_runtime: AccountRuntime | None = None + ask_user_runtime: AskUserRuntime | None = None config_runtime: ConfigRuntime | None = None plan_runtime: PlanRuntime | None = None task_runtime: TaskRuntime | None = None + team_runtime: TeamRuntime | None = None last_session: AgentSessionState | None = field(default=None, init=False, repr=False) last_run_result: AgentRunResult | None = field(default=None, init=False, repr=False) active_session_id: str | None = field(default=None, init=False, repr=False) @@ -129,12 +133,22 @@ class LocalCodingAgent: self.runtime_config.cwd, tuple(str(path) for path in self.runtime_config.additional_working_directories), ) + if self.ask_user_runtime is None: + self.ask_user_runtime = AskUserRuntime.from_workspace( + self.runtime_config.cwd, + tuple(str(path) for path in self.runtime_config.additional_working_directories), + ) if self.config_runtime is None: self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd) if self.plan_runtime is None: self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd) if self.task_runtime is None: self.task_runtime = TaskRuntime.from_workspace(self.runtime_config.cwd) + if self.team_runtime is None: + self.team_runtime = TeamRuntime.from_workspace( + self.runtime_config.cwd, + tuple(str(path) for path in self.runtime_config.additional_working_directories), + ) self.runtime_config = self._apply_hook_policy_budget_overrides(self.runtime_config) registry = dict(self.tool_registry) plugin_tools = self.plugin_runtime.register_tool_aliases(registry) @@ -155,11 +169,13 @@ class LocalCodingAgent: ), search_runtime=self.search_runtime, account_runtime=self.account_runtime, + ask_user_runtime=self.ask_user_runtime, config_runtime=self.config_runtime, mcp_runtime=self.mcp_runtime, remote_runtime=self.remote_runtime, plan_runtime=self.plan_runtime, task_runtime=self.task_runtime, + team_runtime=self.team_runtime, ) def set_model(self, model: str) -> None: @@ -3063,6 +3079,37 @@ class LocalCodingAgent: return '# Task\n\nNo local task runtime is available.' return self.task_runtime.render_task(task_id) + def render_ask_user_report(self) -> str: + if self.ask_user_runtime is None: + return '# Ask User\n\nNo local ask-user runtime is available.' + return '\n'.join(['# Ask User', '', self.ask_user_runtime.render_summary()]) + + def render_ask_user_history_report(self) -> str: + if self.ask_user_runtime is None: + return '# Ask User History\n\nNo local ask-user runtime is available.' + return self.ask_user_runtime.render_history() + + def render_teams_report(self, query: str | None = None) -> str: + if self.team_runtime is None: + return '# Teams\n\nNo local team runtime is available.' + return self.team_runtime.render_teams_index(query=query) + + def render_team_report(self, team_name: str) -> str: + if self.team_runtime is None: + return '# Team\n\nNo local team runtime is available.' + try: + return self.team_runtime.render_team(team_name) + except KeyError: + return f'# Team\n\nUnknown team: {team_name}' + + def render_team_messages_report(self, team_name: str | None = None) -> str: + if self.team_runtime is None: + return '# Team Messages\n\nNo local team runtime is available.' + try: + return self.team_runtime.render_messages(team_name=team_name) + except KeyError: + return f'# Team Messages\n\nUnknown team: {team_name}' + def render_hook_policy_report(self) -> str: if self.hook_policy_runtime is None: return '# Hook Policy\n\nNo local hook or policy manifests discovered.' @@ -3131,6 +3178,9 @@ class LocalCodingAgent: lines.append( f'- Active account: {session.provider} -> {session.identity}' ) + if self.ask_user_runtime is not None and self.ask_user_runtime.has_state(): + lines.append(f'- Ask-user queued answers: {len(self.ask_user_runtime.queued_answers)}') + lines.append(f'- Ask-user history: {len(self.ask_user_runtime.history)}') if self.config_runtime is not None and self.config_runtime.has_config(): lines.append(f'- Config sources: {len(self.config_runtime.sources)}') lines.append( @@ -3140,6 +3190,9 @@ class LocalCodingAgent: lines.append(f'- Local plan steps: {len(self.plan_runtime.steps)}') if self.task_runtime is not None and self.task_runtime.tasks: lines.append(f'- Local tasks: {len(self.task_runtime.tasks)}') + if self.team_runtime is not None and self.team_runtime.has_team_state(): + lines.append(f'- Local teams: {len(self.team_runtime.teams)}') + lines.append(f'- Team messages: {len(self.team_runtime.messages)}') if self.last_session_path is not None: lines.append(f'- Session path: {self.last_session_path}') if self.last_run_result is not None: @@ -3198,6 +3251,10 @@ class LocalCodingAgent: 'account_login', 'account_logout', 'config_set', + 'ask_user_question', + 'team_create', + 'team_delete', + 'send_message', } if tool_name not in refresh_tool_names: return @@ -3220,21 +3277,33 @@ class LocalCodingAgent: self.runtime_config.cwd, additional_working_directories=additional_dirs, ) + if tool_name == 'ask_user_question': + self.ask_user_runtime = AskUserRuntime.from_workspace( + self.runtime_config.cwd, + additional_working_directories=additional_dirs, + ) if tool_name == 'config_set': self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd) if tool_name.startswith('task_') or tool_name == 'todo_write': self.task_runtime = TaskRuntime.from_workspace(self.runtime_config.cwd) if tool_name.startswith('plan_') or tool_name == 'update_plan': self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd) + if tool_name.startswith('team_') or tool_name == 'send_message': + self.team_runtime = TeamRuntime.from_workspace( + self.runtime_config.cwd, + additional_working_directories=additional_dirs, + ) self.tool_context = replace( self.tool_context, tool_registry=self.tool_registry, search_runtime=self.search_runtime, account_runtime=self.account_runtime, + ask_user_runtime=self.ask_user_runtime, config_runtime=self.config_runtime, remote_runtime=self.remote_runtime, plan_runtime=self.plan_runtime, task_runtime=self.task_runtime, + team_runtime=self.team_runtime, ) def _apply_plugin_before_prompt_hooks(self, prompt: str) -> str: diff --git a/src/agent_slash_commands.py b/src/agent_slash_commands.py index 9be00f7..161b66e 100644 --- a/src/agent_slash_commands.py +++ b/src/agent_slash_commands.py @@ -129,6 +129,11 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]: description='Show local account runtime status or configured account profiles.', handler=_handle_account, ), + SlashCommandSpec( + names=('ask',), + description='Show local ask-user runtime status or ask-user history.', + handler=_handle_ask, + ), SlashCommandSpec( names=('login',), description='Activate a local account profile or ephemeral identity.', @@ -189,6 +194,21 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]: description='Show the local runtime task list, optionally filtered by status.', handler=_handle_tasks, ), + SlashCommandSpec( + names=('teams',), + description='List the locally configured collaboration teams.', + handler=_handle_teams, + ), + SlashCommandSpec( + names=('team',), + description='Show one local collaboration team by name.', + handler=_handle_team, + ), + SlashCommandSpec( + names=('messages',), + description='Show recorded collaboration messages for all teams or one team.', + handler=_handle_messages, + ), SlashCommandSpec( names=('task-next', 'next-task'), description='Show the next actionable tasks from the local runtime task list.', @@ -343,6 +363,15 @@ def _handle_account(agent: 'LocalCodingAgent', args: str, input_text: str) -> Sl return _local_result(input_text, 'Usage: /account [profiles|profile ]') +def _handle_ask(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + command = args.strip() + if not command: + return _local_result(input_text, agent.render_ask_user_report()) + if command == 'history': + return _local_result(input_text, agent.render_ask_user_history_report()) + return _local_result(input_text, 'Usage: /ask [history]') + + def _handle_login(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: target = args.strip() if not target: @@ -429,6 +458,23 @@ def _handle_tasks(agent: 'LocalCodingAgent', args: str, input_text: str) -> Slas return _local_result(input_text, agent.render_tasks_report(status)) +def _handle_teams(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + query = args or None + return _local_result(input_text, agent.render_teams_report(query)) + + +def _handle_team(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + team_name = args.strip() + if not team_name: + return _local_result(input_text, 'Usage: /team ') + return _local_result(input_text, agent.render_team_report(team_name)) + + +def _handle_messages(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + team_name = args.strip() or None + return _local_result(input_text, agent.render_team_messages_report(team_name)) + + def _handle_task_next(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult: return _local_result(input_text, agent.render_next_tasks_report()) diff --git a/src/agent_tools.py b/src/agent_tools.py index 2b49957..8178bfc 100644 --- a/src/agent_tools.py +++ b/src/agent_tools.py @@ -18,12 +18,14 @@ from .agent_types import AgentPermissions, AgentRuntimeConfig, ToolExecutionResu if TYPE_CHECKING: from .account_runtime import AccountRuntime + from .ask_user_runtime import AskUserRuntime from .config_runtime import ConfigRuntime from .mcp_runtime import MCPRuntime from .plan_runtime import PlanRuntime from .remote_runtime import RemoteRuntime from .search_runtime import SearchRuntime from .task_runtime import TaskRuntime + from .team_runtime import TeamRuntime class ToolPermissionError(RuntimeError): @@ -44,11 +46,13 @@ class ToolExecutionContext: tool_registry: dict[str, 'AgentTool'] | None = None search_runtime: 'SearchRuntime | None' = None account_runtime: 'AccountRuntime | None' = None + ask_user_runtime: 'AskUserRuntime | None' = None config_runtime: 'ConfigRuntime | None' = None mcp_runtime: 'MCPRuntime | None' = None remote_runtime: 'RemoteRuntime | None' = None plan_runtime: 'PlanRuntime | None' = None task_runtime: 'TaskRuntime | None' = None + team_runtime: 'TeamRuntime | None' = None ToolHandler = Callable[ @@ -114,11 +118,13 @@ def build_tool_context( tool_registry: dict[str, AgentTool] | None = None, search_runtime: 'SearchRuntime | None' = None, account_runtime: 'AccountRuntime | None' = None, + ask_user_runtime: 'AskUserRuntime | None' = None, config_runtime: 'ConfigRuntime | None' = None, mcp_runtime: 'MCPRuntime | None' = None, remote_runtime: 'RemoteRuntime | None' = None, plan_runtime: 'PlanRuntime | None' = None, task_runtime: 'TaskRuntime | None' = None, + team_runtime: 'TeamRuntime | None' = None, ) -> ToolExecutionContext: return ToolExecutionContext( root=config.cwd.resolve(), @@ -129,11 +135,13 @@ def build_tool_context( tool_registry=tool_registry, search_runtime=search_runtime, account_runtime=account_runtime, + ask_user_runtime=ask_user_runtime, config_runtime=config_runtime, mcp_runtime=mcp_runtime, remote_runtime=remote_runtime, plan_runtime=plan_runtime, task_runtime=task_runtime, + team_runtime=team_runtime, ) @@ -238,6 +246,22 @@ def default_tool_registry() -> dict[str, AgentTool]: }, handler=_edit_file, ), + AgentTool( + name='notebook_edit', + description='Edit a Jupyter notebook cell by replacing or appending source in a .ipynb file.', + parameters={ + 'type': 'object', + 'properties': { + 'path': {'type': 'string'}, + 'cell_index': {'type': 'integer', 'minimum': 0}, + 'source': {'type': 'string'}, + 'cell_type': {'type': 'string'}, + 'create_cell': {'type': 'boolean'}, + }, + 'required': ['path', 'cell_index', 'source'], + }, + handler=_notebook_edit, + ), AgentTool( name='glob_search', description='Find files matching a glob pattern inside the workspace.', @@ -368,6 +392,25 @@ def default_tool_registry() -> dict[str, AgentTool]: }, handler=_sleep, ), + AgentTool( + name='ask_user_question', + description='Request an answer from the local ask-user runtime using queued or interactive answers.', + parameters={ + 'type': 'object', + 'properties': { + 'question': {'type': 'string'}, + 'header': {'type': 'string'}, + 'question_id': {'type': 'string'}, + 'choices': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + 'allow_free_text': {'type': 'boolean'}, + }, + 'required': ['question'], + }, + handler=_ask_user_question, + ), AgentTool( name='account_status', description='Show local account runtime summary or a specific configured account profile.', @@ -631,6 +674,85 @@ def default_tool_registry() -> dict[str, AgentTool]: }, handler=_task_next, ), + AgentTool( + name='team_list', + description='List locally configured collaboration teams.', + parameters={ + 'type': 'object', + 'properties': { + 'query': {'type': 'string'}, + 'max_teams': {'type': 'integer', 'minimum': 1, 'maximum': 200}, + }, + }, + handler=_team_list, + ), + AgentTool( + name='team_get', + description='Show a locally configured collaboration team by name.', + parameters={ + 'type': 'object', + 'properties': { + 'team_name': {'type': 'string'}, + }, + 'required': ['team_name'], + }, + handler=_team_get, + ), + AgentTool( + name='team_create', + description='Create a locally stored collaboration team.', + parameters={ + 'type': 'object', + 'properties': { + 'team_name': {'type': 'string'}, + 'description': {'type': 'string'}, + 'members': {'type': 'array', 'items': {'type': 'string'}}, + 'metadata': {'type': 'object'}, + }, + 'required': ['team_name'], + }, + handler=_team_create, + ), + AgentTool( + name='team_delete', + description='Delete a locally stored collaboration team and its recorded messages.', + parameters={ + 'type': 'object', + 'properties': { + 'team_name': {'type': 'string'}, + }, + 'required': ['team_name'], + }, + handler=_team_delete, + ), + AgentTool( + name='send_message', + description='Send a local collaboration message to a team or teammate and persist it in the team runtime.', + parameters={ + 'type': 'object', + 'properties': { + 'team_name': {'type': 'string'}, + 'message': {'type': 'string'}, + 'sender': {'type': 'string'}, + 'recipient': {'type': 'string'}, + 'metadata': {'type': 'object'}, + }, + 'required': ['team_name', 'message'], + }, + handler=_send_message, + ), + AgentTool( + name='team_messages', + description='Show locally recorded collaboration messages for all teams or one team.', + parameters={ + 'type': 'object', + 'properties': { + 'team_name': {'type': 'string'}, + 'limit': {'type': 'integer', 'minimum': 1, 'maximum': 200}, + }, + }, + handler=_team_messages, + ), AgentTool( name='task_list', description='List locally stored runtime tasks.', @@ -1047,6 +1169,85 @@ def _edit_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str: ) +def _notebook_edit(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + _ensure_write_allowed(context) + target = _resolve_path(_require_string(arguments, 'path'), context, allow_missing=False) + if target.suffix != '.ipynb': + raise ToolExecutionError('notebook_edit requires a .ipynb target') + if not target.is_file(): + raise ToolExecutionError(f'Path is not a file: {target}') + cell_index = arguments.get('cell_index') + if isinstance(cell_index, bool) or not isinstance(cell_index, int) or cell_index < 0: + raise ToolExecutionError('cell_index must be an integer >= 0') + source = arguments.get('source') + if not isinstance(source, str): + raise ToolExecutionError('source must be a string') + cell_type = arguments.get('cell_type', 'code') + if cell_type is not None and not isinstance(cell_type, str): + raise ToolExecutionError('cell_type must be a string') + create_cell = arguments.get('create_cell', False) + if not isinstance(create_cell, bool): + raise ToolExecutionError('create_cell must be a boolean') + + raw = target.read_text(encoding='utf-8', errors='replace') + before_sha256 = hashlib.sha256(raw.encode('utf-8')).hexdigest() + try: + notebook = json.loads(raw) + except json.JSONDecodeError as exc: + raise ToolExecutionError(f'Notebook is not valid JSON: {target}') from exc + if not isinstance(notebook, dict): + raise ToolExecutionError('Notebook payload must be a JSON object') + cells = notebook.get('cells') + if not isinstance(cells, list): + raise ToolExecutionError('Notebook does not contain a cells array') + + while len(cells) <= cell_index: + if not create_cell: + raise ToolExecutionError( + f'Notebook cell {cell_index} does not exist; pass create_cell=true to append missing cells' + ) + cells.append( + { + 'cell_type': cell_type or 'code', + 'metadata': {}, + 'source': [], + 'outputs': [], + 'execution_count': None, + } + ) + cell = cells[cell_index] + if not isinstance(cell, dict): + raise ToolExecutionError(f'Notebook cell {cell_index} is not a JSON object') + existing_type = cell.get('cell_type') + if not isinstance(existing_type, str): + existing_type = 'code' + source_lines = source.splitlines(keepends=True) + if source and not source.endswith('\n'): + source_lines = [*source_lines[:-1], source_lines[-1]] + cell['cell_type'] = cell_type or existing_type + cell['source'] = source_lines + if cell['cell_type'] == 'code': + cell.setdefault('outputs', []) + cell.setdefault('execution_count', None) + updated = json.dumps(notebook, ensure_ascii=True, indent=1) + '\n' + target.write_text(updated, encoding='utf-8') + after_sha256 = hashlib.sha256(updated.encode('utf-8')).hexdigest() + rel = target.relative_to(context.root) + return ( + f'updated notebook cell {cell_index} in {rel}', + { + 'action': 'notebook_edit', + 'path': str(rel), + 'cell_index': cell_index, + 'cell_type': cell['cell_type'], + 'before_sha256': before_sha256, + 'after_sha256': after_sha256, + 'before_preview': _snapshot_text(raw), + 'after_preview': _snapshot_text(updated), + }, + ) + + def _glob_search(arguments: dict[str, Any], context: ToolExecutionContext) -> str: pattern = _require_string(arguments, 'pattern') matches = sorted(context.root.glob(pattern)) @@ -1321,6 +1522,69 @@ def _sleep(arguments: dict[str, Any], context: ToolExecutionContext) -> str: ) +def _ask_user_question(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + runtime = _require_ask_user_runtime(context) + question = _require_string(arguments, 'question') + header = arguments.get('header') + question_id = arguments.get('question_id') + if header is not None and not isinstance(header, str): + raise ToolExecutionError('header must be a string') + if question_id is not None and not isinstance(question_id, str): + raise ToolExecutionError('question_id must be a string') + raw_choices = arguments.get('choices', ()) + if raw_choices is None: + raw_choices = () + if not isinstance(raw_choices, (list, tuple)): + raise ToolExecutionError('choices must be an array of strings') + choices = tuple( + item.strip() + for item in raw_choices + if isinstance(item, str) and item.strip() + ) + if len(choices) != len(raw_choices): + raise ToolExecutionError('choices must contain only non-empty strings') + allow_free_text = arguments.get('allow_free_text', True) + if not isinstance(allow_free_text, bool): + raise ToolExecutionError('allow_free_text must be a boolean') + try: + response = runtime.answer( + question=question, + choices=choices, + question_id=question_id, + header=header, + allow_free_text=allow_free_text, + ) + except LookupError as exc: + raise ToolExecutionError(str(exc)) from exc + lines = ['# Ask User', ''] + if header: + lines.append(f'- Header: {header}') + if question_id: + lines.append(f'- Question ID: {question_id}') + lines.append(f'- Question: {question}') + if choices: + lines.append('- Choices: ' + ', '.join(choices)) + lines.extend( + [ + f'- Source: {response.source}', + '', + response.answer, + ] + ) + return ( + '\n'.join(lines), + { + 'action': 'ask_user_question', + 'question': question, + 'question_id': question_id, + 'header': header, + 'source': response.source, + 'answer_preview': _snapshot_text(response.answer), + 'choices': list(choices), + }, + ) + + def _account_status(arguments: dict[str, Any], context: ToolExecutionContext) -> str: runtime = _require_account_runtime(context) profile = arguments.get('profile') @@ -1579,6 +1843,125 @@ def _remote_disconnect( ) +def _team_list(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + runtime = _require_team_runtime(context) + query = arguments.get('query') + if query is not None and not isinstance(query, str): + raise ToolExecutionError('query must be a string') + max_teams = _coerce_int(arguments, 'max_teams', 50) + return runtime.render_teams_index(query=query, limit=max_teams) + + +def _team_get(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + runtime = _require_team_runtime(context) + team_name = _require_string(arguments, 'team_name') + try: + return runtime.render_team(team_name) + except KeyError as exc: + raise ToolExecutionError(f'Unknown team: {team_name}') from exc + + +def _team_create(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + _ensure_write_allowed(context) + runtime = _require_team_runtime(context) + team_name = _require_string(arguments, 'team_name') + description = arguments.get('description') + members = arguments.get('members', ()) + metadata = arguments.get('metadata') + if description is not None and not isinstance(description, str): + raise ToolExecutionError('description must be a string') + if not isinstance(members, (list, tuple)): + raise ToolExecutionError('members must be an array of strings') + if metadata is not None and not isinstance(metadata, dict): + raise ToolExecutionError('metadata must be an object') + try: + team = runtime.create_team( + team_name, + description=description, + members=members, + metadata=metadata, + ) + except KeyError as exc: + raise ToolExecutionError(f'Team already exists: {team_name}') from exc + return ( + f'created team {team.name}', + { + 'action': 'team_create', + 'team_name': team.name, + 'member_count': len(team.members), + 'path': str(runtime.state_path), + }, + ) + + +def _team_delete(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + _ensure_write_allowed(context) + runtime = _require_team_runtime(context) + team_name = _require_string(arguments, 'team_name') + try: + team = runtime.delete_team(team_name) + except KeyError as exc: + raise ToolExecutionError(f'Unknown team: {team_name}') from exc + return ( + f'deleted team {team.name}', + { + 'action': 'team_delete', + 'team_name': team.name, + 'path': str(runtime.state_path), + }, + ) + + +def _send_message(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + _ensure_write_allowed(context) + runtime = _require_team_runtime(context) + team_name = _require_string(arguments, 'team_name') + message = _require_string(arguments, 'message') + sender = arguments.get('sender', 'agent') + recipient = arguments.get('recipient') + metadata = arguments.get('metadata') + if sender is not None and not isinstance(sender, str): + raise ToolExecutionError('sender must be a string') + if recipient is not None and not isinstance(recipient, str): + raise ToolExecutionError('recipient must be a string') + if metadata is not None and not isinstance(metadata, dict): + raise ToolExecutionError('metadata must be an object') + try: + stored = runtime.send_message( + team_name=team_name, + text=message, + sender=sender or 'agent', + recipient=recipient, + metadata=metadata, + ) + except KeyError as exc: + raise ToolExecutionError(f'Unknown team: {team_name}') from exc + return ( + f'sent message to team {stored.team_name}', + { + 'action': 'send_message', + 'team_name': stored.team_name, + 'sender': stored.sender, + 'recipient': stored.recipient, + 'message_id': stored.message_id, + 'message_preview': _snapshot_text(stored.text), + 'path': str(runtime.state_path), + }, + ) + + +def _team_messages(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + runtime = _require_team_runtime(context) + team_name = arguments.get('team_name') + if team_name is not None and not isinstance(team_name, str): + raise ToolExecutionError('team_name must be a string') + limit = _coerce_int(arguments, 'limit', 20) + try: + return runtime.render_messages(team_name=team_name, limit=limit) + except KeyError as exc: + raise ToolExecutionError(f'Unknown team: {team_name}') from exc + + def _task_list(arguments: dict[str, Any], context: ToolExecutionContext) -> str: runtime = _require_task_runtime(context) status = arguments.get('status') @@ -2048,6 +2431,12 @@ def _require_account_runtime(context: ToolExecutionContext): return context.account_runtime +def _require_ask_user_runtime(context: ToolExecutionContext): + if context.ask_user_runtime is None: + raise ToolExecutionError('No local ask-user runtime is available.') + return context.ask_user_runtime + + def _require_search_runtime(context: ToolExecutionContext): if context.search_runtime is None or not context.search_runtime.has_search_runtime(): raise ToolExecutionError( @@ -2095,6 +2484,12 @@ def _require_task_runtime(context: ToolExecutionContext): return context.task_runtime +def _require_team_runtime(context: ToolExecutionContext): + if context.team_runtime is None: + raise ToolExecutionError('Local team runtime is not available.') + return context.team_runtime + + def _task_mutation_metadata( *, action: str, diff --git a/src/ask_user_runtime.py b/src/ask_user_runtime.py new file mode 100644 index 0000000..da8a865 --- /dev/null +++ b/src/ask_user_runtime.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + + +DEFAULT_ASK_USER_STATE_FILE = Path('.port_sessions') / 'ask_user_runtime.json' + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +@dataclass(frozen=True) +class QueuedUserAnswer: + answer: str + question: str | None = None + question_id: str | None = None + header: str | None = None + match: str = 'exact' + consume: bool = True + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> 'QueuedUserAnswer | None': + answer = payload.get('answer') + if not isinstance(answer, str) or not answer.strip(): + return None + question = payload.get('question') + if question is not None and not isinstance(question, str): + question = None + question_id = payload.get('question_id') + if question_id is not None and not isinstance(question_id, str): + question_id = None + header = payload.get('header') + if header is not None and not isinstance(header, str): + header = None + match = payload.get('match') + if not isinstance(match, str) or match not in {'exact', 'contains'}: + match = 'exact' + consume = payload.get('consume', True) + if not isinstance(consume, bool): + consume = True + return cls( + answer=answer, + question=question.strip() if question else None, + question_id=question_id.strip() if question_id else None, + header=header.strip() if header else None, + match=match, + consume=consume, + ) + + def matches( + self, + *, + question: str, + question_id: str | None, + header: str | None, + ) -> bool: + if question_id and self.question_id and self.question_id == question_id: + return True + if header and self.header and self.header.lower() == header.lower(): + return True + if self.question is None: + return False + if self.match == 'contains': + return self.question.lower() in question.lower() + return self.question.strip().lower() == question.strip().lower() + + def to_dict(self) -> dict[str, Any]: + return { + 'answer': self.answer, + 'question': self.question, + 'question_id': self.question_id, + 'header': self.header, + 'match': self.match, + 'consume': self.consume, + } + + +@dataclass(frozen=True) +class AskUserResponse: + answer: str + source: str + matched_question: str | None = None + question_id: str | None = None + header: str | None = None + + +@dataclass +class AskUserRuntime: + cwd: Path + queued_answers: tuple[QueuedUserAnswer, ...] = field(default_factory=tuple) + history: tuple[dict[str, Any], ...] = field(default_factory=tuple) + manifests: tuple[str, ...] = field(default_factory=tuple) + state_path: Path = field(default_factory=lambda: DEFAULT_ASK_USER_STATE_FILE.resolve()) + interactive: bool = False + input_func: Callable[[str], str] | None = None + + @classmethod + def from_workspace( + cls, + cwd: Path, + additional_working_directories: tuple[str, ...] = (), + *, + interactive: bool | None = None, + input_func: Callable[[str], str] | None = None, + ) -> 'AskUserRuntime': + manifest_paths = _discover_manifest_paths(cwd, additional_working_directories) + manifest_answers: list[QueuedUserAnswer] = [] + for manifest_path in manifest_paths: + manifest_answers.extend(_load_answers_from_manifest(manifest_path)) + state_path = cwd.resolve() / DEFAULT_ASK_USER_STATE_FILE + payload = _load_state_payload(state_path) + queued_payload = payload.get('queued_answers') + if isinstance(queued_payload, list): + queued_answers = tuple( + answer + for answer in ( + QueuedUserAnswer.from_dict(item) + for item in queued_payload + if isinstance(item, dict) + ) + if answer is not None + ) + else: + queued_answers = tuple(manifest_answers) + history_payload = payload.get('history') + history = tuple(item for item in history_payload if isinstance(item, dict)) if isinstance(history_payload, list) else () + if interactive is None: + env_value = os.environ.get('CLAW_ASK_USER_INTERACTIVE', '') + interactive = env_value.strip().lower() in {'1', 'true', 'yes', 'on'} + return cls( + cwd=cwd.resolve(), + queued_answers=queued_answers, + history=history, + manifests=tuple(str(path) for path in manifest_paths), + state_path=state_path, + interactive=bool(interactive), + input_func=input_func, + ) + + def has_state(self) -> bool: + return bool(self.queued_answers or self.history or self.manifests) + + def answer( + self, + *, + question: str, + choices: tuple[str, ...] = (), + question_id: str | None = None, + header: str | None = None, + allow_free_text: bool = True, + ) -> AskUserResponse: + for index, entry in enumerate(self.queued_answers): + if not entry.matches(question=question, question_id=question_id, header=header): + continue + if entry.consume: + queued_answers = list(self.queued_answers) + queued_answers.pop(index) + self.queued_answers = tuple(queued_answers) + response = AskUserResponse( + answer=entry.answer, + source='queued', + matched_question=entry.question, + question_id=question_id or entry.question_id, + header=header or entry.header, + ) + self._record_history(question, response, choices=choices) + self._persist_state() + return response + + if self.interactive and self.input_func is not None: + prompt_lines = ['# Ask User'] + if header: + prompt_lines.append(f'header={header}') + if question_id: + prompt_lines.append(f'question_id={question_id}') + prompt_lines.append(question) + if choices: + prompt_lines.append('choices=' + ', '.join(choices)) + raw_answer = self.input_func('\n'.join(prompt_lines) + '\nanswer> ') + answer = raw_answer.strip() + if not answer: + raise LookupError('Interactive ask-user prompt returned an empty answer.') + if choices and not allow_free_text and answer not in choices: + raise LookupError( + 'Interactive answer did not match the allowed choices: ' + + ', '.join(choices) + ) + response = AskUserResponse( + answer=answer, + source='interactive', + question_id=question_id, + header=header, + ) + self._record_history(question, response, choices=choices) + self._persist_state() + return response + + raise LookupError( + 'No queued ask-user answer is available. ' + 'Add .claw-ask-user.json or enable CLAW_ASK_USER_INTERACTIVE=1 for interactive prompting.' + ) + + def render_summary(self) -> str: + lines = [ + f'Ask-user manifests: {len(self.manifests)}', + f'Queued answers: {len(self.queued_answers)}', + f'History entries: {len(self.history)}', + f'Interactive mode: {self.interactive}', + ] + if self.queued_answers: + lines.append('- Pending queued answers:') + for entry in self.queued_answers[:10]: + label = entry.question_id or entry.header or entry.question or '(wildcard answer)' + lines.append(f' - {label}') + if len(self.queued_answers) > 10: + lines.append(f' - ... plus {len(self.queued_answers) - 10} more') + return '\n'.join(lines) + + def render_history(self, *, limit: int = 20) -> str: + lines = ['# Ask User History', ''] + entries = list(self.history[-limit:]) + if not entries: + lines.append('No ask-user interactions recorded.') + return '\n'.join(lines) + for entry in entries: + lines.append(f"- {entry.get('created_at', '(unknown time)')} :: {entry.get('question', '(unknown question)')}") + lines.append(f" - answer={entry.get('answer', '')}") + lines.append(f" - source={entry.get('source', 'unknown')}") + if entry.get('choices'): + lines.append(' - choices=' + ', '.join(entry['choices'])) + return '\n'.join(lines) + + def _record_history( + self, + question: str, + response: AskUserResponse, + *, + choices: tuple[str, ...], + ) -> None: + entry = { + 'question': question, + 'answer': response.answer, + 'source': response.source, + 'question_id': response.question_id, + 'header': response.header, + 'choices': list(choices), + 'created_at': _utc_now(), + } + self.history = (*self.history, entry) + + def _persist_state(self) -> None: + payload = { + 'queued_answers': [answer.to_dict() for answer in self.queued_answers], + 'history': list(self.history), + } + self.state_path.parent.mkdir(parents=True, exist_ok=True) + self.state_path.write_text( + json.dumps(payload, ensure_ascii=True, indent=2), + encoding='utf-8', + ) + + +def _discover_manifest_paths(cwd: Path, additional_working_directories: tuple[str, ...]) -> tuple[Path, ...]: + candidates = [ + cwd.resolve() / '.claw-ask-user.json', + cwd.resolve() / '.claude' / 'ask-user.json', + ] + for raw_path in additional_working_directories: + root = Path(raw_path).resolve() + candidates.extend( + [ + root / '.claw-ask-user.json', + root / '.claude' / 'ask-user.json', + ] + ) + discovered: list[Path] = [] + seen: set[Path] = set() + for candidate in candidates: + if not candidate.is_file(): + continue + resolved = candidate.resolve() + if resolved in seen: + continue + seen.add(resolved) + discovered.append(resolved) + return tuple(discovered) + + +def _load_answers_from_manifest(path: Path) -> list[QueuedUserAnswer]: + try: + payload = json.loads(path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError): + return [] + answers_payload = payload.get('answers') + if not isinstance(answers_payload, list): + return [] + answers: list[QueuedUserAnswer] = [] + for item in answers_payload: + if not isinstance(item, dict): + continue + answer = QueuedUserAnswer.from_dict(item) + if answer is not None: + answers.append(answer) + return answers + + +def _load_state_payload(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + try: + payload = json.loads(path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError): + return {} + return payload if isinstance(payload, dict) else {} diff --git a/src/main.py b/src/main.py index 96791a0..a8d2331 100644 --- a/src/main.py +++ b/src/main.py @@ -10,6 +10,7 @@ from typing import Callable from .background_runtime import BackgroundSessionRuntime, build_background_worker_command from .account_runtime import AccountRuntime +from .ask_user_runtime import AskUserRuntime from .agent_runtime import LocalCodingAgent from .agent_types import ( AgentPermissions, @@ -37,6 +38,7 @@ from .remote_runtime import ( run_teleport_mode, ) from .search_runtime import SearchRuntime +from .team_runtime import TeamRuntime from .runtime import PortRuntime from .session_store import ( StoredAgentSession, @@ -613,6 +615,10 @@ def build_parser() -> argparse.ArgumentParser: account_login_parser.add_argument('--cwd', default='.') account_logout_parser = subparsers.add_parser('account-logout', help='clear the active local account session') account_logout_parser.add_argument('--cwd', default='.') + ask_status_parser = subparsers.add_parser('ask-status', help='show local ask-user runtime status') + ask_status_parser.add_argument('--cwd', default='.') + ask_history_parser = subparsers.add_parser('ask-history', help='show local ask-user interaction history') + ask_history_parser.add_argument('--cwd', default='.') search_status_parser = subparsers.add_parser('search-status', help='show local search runtime status') search_status_parser.add_argument('--cwd', default='.') search_status_parser.add_argument('--provider') @@ -661,6 +667,25 @@ def build_parser() -> argparse.ArgumentParser: config_set_parser.add_argument('value_json') config_set_parser.add_argument('--source', default='local') config_set_parser.add_argument('--cwd', default='.') + teams_status_parser = subparsers.add_parser('team-status', help='show local collaboration team runtime summary') + teams_status_parser.add_argument('--cwd', default='.') + teams_list_parser = subparsers.add_parser('team-list', help='list local collaboration teams') + teams_list_parser.add_argument('--cwd', default='.') + teams_list_parser.add_argument('--query') + team_get_parser = subparsers.add_parser('team-get', help='show one local collaboration team') + team_get_parser.add_argument('team_name') + team_get_parser.add_argument('--cwd', default='.') + team_create_parser = subparsers.add_parser('team-create', help='create a local collaboration team') + team_create_parser.add_argument('team_name') + team_create_parser.add_argument('--description') + team_create_parser.add_argument('--member', action='append', default=[]) + team_create_parser.add_argument('--cwd', default='.') + team_delete_parser = subparsers.add_parser('team-delete', help='delete a local collaboration team') + team_delete_parser.add_argument('team_name') + team_delete_parser.add_argument('--cwd', default='.') + team_messages_parser = subparsers.add_parser('team-messages', help='show local team messages') + team_messages_parser.add_argument('--team-name') + team_messages_parser.add_argument('--cwd', default='.') show_command = subparsers.add_parser('show-command', help='show one mirrored command entry by exact name') show_command.add_argument('name') @@ -905,6 +930,16 @@ def main(argv: list[str] | None = None) -> int: runtime = AccountRuntime.from_workspace(Path(args.cwd).resolve()) print(runtime.logout().as_text()) return 0 + if args.command == 'ask-status': + runtime = AskUserRuntime.from_workspace(Path(args.cwd).resolve()) + print('# Ask User') + print() + print(runtime.render_summary()) + return 0 + if args.command == 'ask-history': + runtime = AskUserRuntime.from_workspace(Path(args.cwd).resolve()) + print(runtime.render_history()) + return 0 if args.command == 'search-status': runtime = SearchRuntime.from_workspace(Path(args.cwd).resolve()) if args.provider: @@ -1003,6 +1038,54 @@ def main(argv: list[str] | None = None) -> int: print(f'effective_key_count={mutation.effective_key_count}') print(runtime.render_value(args.key_path)) return 0 + if args.command == 'team-status': + runtime = TeamRuntime.from_workspace(Path(args.cwd).resolve()) + print('# Teams') + print() + print(runtime.render_summary()) + return 0 + if args.command == 'team-list': + runtime = TeamRuntime.from_workspace(Path(args.cwd).resolve()) + print(runtime.render_teams_index(query=args.query)) + return 0 + if args.command == 'team-get': + runtime = TeamRuntime.from_workspace(Path(args.cwd).resolve()) + try: + print(runtime.render_team(args.team_name)) + except KeyError: + print(f'Unknown team: {args.team_name}') + return 1 + return 0 + if args.command == 'team-create': + runtime = TeamRuntime.from_workspace(Path(args.cwd).resolve()) + try: + team = runtime.create_team( + args.team_name, + description=args.description, + members=args.member, + ) + except KeyError: + print(f'Team already exists: {args.team_name}') + return 1 + print(f'created team {team.name}') + return 0 + if args.command == 'team-delete': + runtime = TeamRuntime.from_workspace(Path(args.cwd).resolve()) + try: + team = runtime.delete_team(args.team_name) + except KeyError: + print(f'Unknown team: {args.team_name}') + return 1 + print(f'deleted team {team.name}') + return 0 + if args.command == 'team-messages': + runtime = TeamRuntime.from_workspace(Path(args.cwd).resolve()) + try: + print(runtime.render_messages(team_name=args.team_name)) + except KeyError: + print(f'Unknown team: {args.team_name}') + return 1 + return 0 if args.command == 'show-command': module = get_command(args.name) if module is None: diff --git a/src/team_runtime.py b/src/team_runtime.py new file mode 100644 index 0000000..055df1e --- /dev/null +++ b/src/team_runtime.py @@ -0,0 +1,386 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from uuid import uuid4 + + +DEFAULT_TEAM_STATE_FILE = Path('.port_sessions') / 'team_runtime.json' + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +@dataclass(frozen=True) +class TeamDefinition: + name: str + description: str | None = None + members: tuple[str, ...] = () + metadata: dict[str, Any] = field(default_factory=dict) + created_at: str = field(default_factory=_utc_now) + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> 'TeamDefinition | None': + name = payload.get('name') + if not isinstance(name, str) or not name.strip(): + return None + description = payload.get('description') + if description is not None and not isinstance(description, str): + description = None + raw_members = payload.get('members', ()) + members = tuple( + item.strip() + for item in raw_members + if isinstance(item, str) and item.strip() + ) if isinstance(raw_members, (list, tuple)) else () + metadata = payload.get('metadata', {}) + if not isinstance(metadata, dict): + metadata = {} + created_at = payload.get('created_at') + if not isinstance(created_at, str) or not created_at.strip(): + created_at = _utc_now() + return cls( + name=name.strip(), + description=description.strip() if description else None, + members=members, + metadata=dict(metadata), + created_at=created_at, + ) + + def to_dict(self) -> dict[str, Any]: + return { + 'name': self.name, + 'description': self.description, + 'members': list(self.members), + 'metadata': dict(self.metadata), + 'created_at': self.created_at, + } + + +@dataclass(frozen=True) +class TeamMessage: + message_id: str + team_name: str + sender: str + text: str + recipient: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + created_at: str = field(default_factory=_utc_now) + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> 'TeamMessage | None': + message_id = payload.get('message_id') + team_name = payload.get('team_name') + sender = payload.get('sender') + text = payload.get('text') + if not all(isinstance(value, str) and value.strip() for value in (message_id, team_name, sender, text)): + return None + recipient = payload.get('recipient') + if recipient is not None and not isinstance(recipient, str): + recipient = None + metadata = payload.get('metadata', {}) + if not isinstance(metadata, dict): + metadata = {} + created_at = payload.get('created_at') + if not isinstance(created_at, str) or not created_at.strip(): + created_at = _utc_now() + return cls( + message_id=message_id.strip(), + team_name=team_name.strip(), + sender=sender.strip(), + text=text, + recipient=recipient.strip() if isinstance(recipient, str) and recipient.strip() else None, + metadata=dict(metadata), + created_at=created_at, + ) + + def to_dict(self) -> dict[str, Any]: + return { + 'message_id': self.message_id, + 'team_name': self.team_name, + 'sender': self.sender, + 'text': self.text, + 'recipient': self.recipient, + 'metadata': dict(self.metadata), + 'created_at': self.created_at, + } + + +@dataclass +class TeamRuntime: + cwd: Path + teams: tuple[TeamDefinition, ...] = field(default_factory=tuple) + messages: tuple[TeamMessage, ...] = field(default_factory=tuple) + manifests: tuple[str, ...] = field(default_factory=tuple) + state_path: Path = field(default_factory=lambda: DEFAULT_TEAM_STATE_FILE.resolve()) + + @classmethod + def from_workspace( + cls, + cwd: Path, + additional_working_directories: tuple[str, ...] = (), + ) -> 'TeamRuntime': + manifest_paths = _discover_manifest_paths(cwd, additional_working_directories) + manifest_teams: list[TeamDefinition] = [] + for manifest_path in manifest_paths: + manifest_teams.extend(_load_teams_from_manifest(manifest_path)) + state_path = cwd.resolve() / DEFAULT_TEAM_STATE_FILE + payload = _load_state_payload(state_path) + raw_teams = payload.get('teams') + raw_messages = payload.get('messages') + if isinstance(raw_teams, list): + teams = tuple( + team + for team in ( + TeamDefinition.from_dict(item) + for item in raw_teams + if isinstance(item, dict) + ) + if team is not None + ) + else: + teams = tuple(manifest_teams) + messages = tuple( + message + for message in ( + TeamMessage.from_dict(item) + for item in raw_messages + if isinstance(item, dict) + ) + if message is not None + ) if isinstance(raw_messages, list) else () + return cls( + cwd=cwd.resolve(), + teams=teams, + messages=messages, + manifests=tuple(str(path) for path in manifest_paths), + state_path=state_path, + ) + + def has_team_state(self) -> bool: + return bool(self.teams or self.messages or self.manifests) + + def list_teams( + self, + *, + query: str | None = None, + limit: int | None = None, + ) -> tuple[TeamDefinition, ...]: + teams = self.teams + if query: + needle = query.lower() + teams = tuple( + team + for team in teams + if needle in team.name.lower() + or needle in (team.description or '').lower() + or any(needle in member.lower() for member in team.members) + ) + teams = tuple(sorted(teams, key=lambda team: team.name.lower())) + if limit is not None and limit >= 0: + teams = teams[:limit] + return teams + + def get_team(self, name: str) -> TeamDefinition | None: + needle = name.strip().lower() + for team in self.teams: + if team.name.lower() == needle: + return team + return None + + def create_team( + self, + name: str, + *, + description: str | None = None, + members: tuple[str, ...] | list[str] = (), + metadata: dict[str, Any] | None = None, + ) -> TeamDefinition: + normalized = name.strip() + if not normalized: + raise KeyError('team_name') + if self.get_team(normalized) is not None: + raise KeyError(normalized) + team = TeamDefinition( + name=normalized, + description=description.strip() if isinstance(description, str) and description.strip() else None, + members=tuple( + member.strip() + for member in members + if isinstance(member, str) and member.strip() + ), + metadata=dict(metadata or {}), + ) + self.teams = tuple(sorted((*self.teams, team), key=lambda item: item.name.lower())) + self._persist_state() + return team + + def delete_team(self, name: str) -> TeamDefinition: + team = self.get_team(name) + if team is None: + raise KeyError(name) + self.teams = tuple(existing for existing in self.teams if existing.name != team.name) + self.messages = tuple(message for message in self.messages if message.team_name != team.name) + self._persist_state() + return team + + def send_message( + self, + *, + team_name: str, + text: str, + sender: str, + recipient: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> TeamMessage: + team = self.get_team(team_name) + if team is None: + raise KeyError(team_name) + message = TeamMessage( + message_id=f'msg_{uuid4().hex[:10]}', + team_name=team.name, + sender=sender.strip(), + text=text, + recipient=recipient.strip() if isinstance(recipient, str) and recipient.strip() else None, + metadata=dict(metadata or {}), + ) + self.messages = (*self.messages, message) + self._persist_state() + return message + + def render_summary(self) -> str: + lines = [ + f'Configured teams: {len(self.teams)}', + f'Message history entries: {len(self.messages)}', + f'Team manifests: {len(self.manifests)}', + ] + if self.teams: + lines.append('- Teams:') + for team in self.list_teams(limit=10): + lines.append(f' - {team.name} ({len(team.members)} members)') + if len(self.teams) > 10: + lines.append(f' - ... plus {len(self.teams) - 10} more') + return '\n'.join(lines) + + def render_team(self, name: str) -> str: + team = self.get_team(name) + if team is None: + raise KeyError(name) + lines = ['# Team', '', f'- Name: {team.name}'] + if team.description: + lines.append(f'- Description: {team.description}') + lines.append(f'- Members: {len(team.members)}') + if team.members: + lines.extend(f' - {member}' for member in team.members) + recent_messages = [message for message in self.messages if message.team_name == team.name][-5:] + if recent_messages: + lines.extend(['', '## Recent Messages']) + for message in recent_messages: + target = f' -> {message.recipient}' if message.recipient else '' + lines.append(f'- {message.sender}{target}: {message.text}') + return '\n'.join(lines) + + def render_teams_index(self, *, query: str | None = None, limit: int = 50) -> str: + lines = ['# Teams', ''] + teams = self.list_teams(query=query, limit=limit) + if not teams: + lines.append('No local teams are configured.') + return '\n'.join(lines) + for team in teams: + details = [team.name] + if team.description: + details.append(team.description) + if team.members: + details.append(f'members={",".join(team.members)}') + lines.append('- ' + ' ; '.join(details)) + return '\n'.join(lines) + + def render_messages(self, *, team_name: str | None = None, limit: int = 20) -> str: + lines = ['# Team Messages', ''] + messages = list(self.messages) + if team_name: + team = self.get_team(team_name) + if team is None: + raise KeyError(team_name) + messages = [message for message in messages if message.team_name == team.name] + messages = messages[-limit:] + if not messages: + lines.append('No team messages recorded.') + return '\n'.join(lines) + for message in messages: + target = f' -> {message.recipient}' if message.recipient else '' + lines.append( + f'- [{message.team_name}] {message.sender}{target}: {message.text}' + ) + return '\n'.join(lines) + + def _persist_state(self) -> None: + payload = { + 'teams': [team.to_dict() for team in self.teams], + 'messages': [message.to_dict() for message in self.messages], + } + self.state_path.parent.mkdir(parents=True, exist_ok=True) + self.state_path.write_text( + json.dumps(payload, ensure_ascii=True, indent=2), + encoding='utf-8', + ) + + +def _discover_manifest_paths(cwd: Path, additional_working_directories: tuple[str, ...]) -> tuple[Path, ...]: + candidates = [ + cwd.resolve() / '.claw-teams.json', + cwd.resolve() / '.claw-team.json', + cwd.resolve() / '.claude' / 'teams.json', + ] + for raw_path in additional_working_directories: + root = Path(raw_path).resolve() + candidates.extend( + [ + root / '.claw-teams.json', + root / '.claw-team.json', + root / '.claude' / 'teams.json', + ] + ) + discovered: list[Path] = [] + seen: set[Path] = set() + for candidate in candidates: + if not candidate.is_file(): + continue + resolved = candidate.resolve() + if resolved in seen: + continue + seen.add(resolved) + discovered.append(resolved) + return tuple(discovered) + + +def _load_teams_from_manifest(path: Path) -> list[TeamDefinition]: + try: + payload = json.loads(path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError): + return [] + teams_payload = payload.get('teams') + if not isinstance(teams_payload, list): + return [] + teams: list[TeamDefinition] = [] + for item in teams_payload: + if not isinstance(item, dict): + continue + team = TeamDefinition.from_dict(item) + if team is not None: + teams.append(team) + return teams + + +def _load_state_payload(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + try: + payload = json.loads(path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError): + return {} + return payload if isinstance(payload, dict) else {} diff --git a/tests/test_agent_context.py b/tests/test_agent_context.py index b537c3d..4b0c3bc 100644 --- a/tests/test_agent_context.py +++ b/tests/test_agent_context.py @@ -12,9 +12,11 @@ from src.agent_context import ( clear_context_caches, set_system_prompt_injection, ) +from src.ask_user_runtime import AskUserRuntime from src.plan_runtime import PlanRuntime from src.agent_types import AgentRuntimeConfig from src.task_runtime import TaskRuntime +from src.team_runtime import TeamRuntime class AgentContextTests(unittest.TestCase): @@ -151,6 +153,20 @@ class AgentContextTests(unittest.TestCase): self.assertIn('Configured account profiles: 1', snapshot.user_context['accountRuntime']) self.assertIn('dev@example.com', snapshot.user_context['accountRuntime']) + def test_user_context_loads_ask_user_runtime_summary(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) / 'repo' + workspace.mkdir(parents=True) + (workspace / '.claw-ask-user.json').write_text( + '{"answers":[{"question":"Approve deploy?","answer":"yes"}]}', + encoding='utf-8', + ) + + snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace)) + + self.assertIn('askUserRuntime', snapshot.user_context) + self.assertIn('Queued answers: 1', snapshot.user_context['askUserRuntime']) + def test_user_context_loads_config_runtime_summary(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: workspace = Path(tmp_dir) / 'repo' @@ -195,6 +211,18 @@ class AgentContextTests(unittest.TestCase): self.assertIn('planRuntime', snapshot.user_context) self.assertIn('Total plan steps: 1', snapshot.user_context['planRuntime']) + def test_user_context_loads_team_runtime_summary(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) / 'repo' + workspace.mkdir(parents=True) + runtime = TeamRuntime.from_workspace(workspace) + runtime.create_team('reviewers', members=['alice', 'bob']) + + snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace)) + + self.assertIn('teamRuntime', snapshot.user_context) + self.assertIn('Configured teams: 1', snapshot.user_context['teamRuntime']) + @unittest.skipIf(shutil.which('git') is None, 'git is required for git context tests') def test_git_status_snapshot_contains_branch_and_status(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: diff --git a/tests/test_agent_prompting.py b/tests/test_agent_prompting.py index fda3079..4ed2a71 100644 --- a/tests/test_agent_prompting.py +++ b/tests/test_agent_prompting.py @@ -186,6 +186,25 @@ class AgentPromptingTests(unittest.TestCase): prompt = render_system_prompt(parts) self.assertIn('# Account', prompt) + def test_prompt_builder_mentions_ask_user_when_runtime_is_loaded(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + (workspace / '.claw-ask-user.json').write_text( + '{"answers":[{"question":"Approve deploy?","answer":"yes"}]}', + encoding='utf-8', + ) + runtime_config = AgentRuntimeConfig(cwd=workspace) + model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct') + prompt_context = build_prompt_context(runtime_config, model_config) + parts = build_system_prompt_parts( + prompt_context=prompt_context, + runtime_config=runtime_config, + tools=default_tool_registry(), + ) + + prompt = render_system_prompt(parts) + self.assertIn('# Ask User', prompt) + def test_prompt_builder_mentions_config_when_runtime_is_loaded(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: workspace = Path(tmp_dir) @@ -224,6 +243,25 @@ class AgentPromptingTests(unittest.TestCase): prompt = render_system_prompt(parts) self.assertIn('# Tasks', prompt) + def test_prompt_builder_mentions_teams_when_runtime_is_loaded(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + (workspace / '.claw-teams.json').write_text( + '{"teams":[{"name":"reviewers","members":["alice","bob"]}]}', + encoding='utf-8', + ) + runtime_config = AgentRuntimeConfig(cwd=workspace) + model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct') + prompt_context = build_prompt_context(runtime_config, model_config) + parts = build_system_prompt_parts( + prompt_context=prompt_context, + runtime_config=runtime_config, + tools=default_tool_registry(), + ) + + prompt = render_system_prompt(parts) + self.assertIn('# Teams', prompt) + def test_prompt_builder_mentions_planning_when_runtime_is_loaded(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: workspace = Path(tmp_dir) diff --git a/tests/test_agent_slash_commands.py b/tests/test_agent_slash_commands.py index c710a16..74becf6 100644 --- a/tests/test_agent_slash_commands.py +++ b/tests/test_agent_slash_commands.py @@ -246,6 +246,43 @@ class AgentSlashCommandTests(unittest.TestCase): self.assertIn('profile=local', login_result.final_output) self.assertIn('logged_in=False', logout_result.final_output) + def test_ask_commands_render_local_ask_user_runtime(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + (workspace / '.claw-ask-user.json').write_text( + '{"answers":[{"question":"Approve deploy?","answer":"yes"}]}', + encoding='utf-8', + ) + agent = LocalCodingAgent( + model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'), + runtime_config=AgentRuntimeConfig(cwd=workspace), + ) + status_result = agent.run('/ask') + history_result = agent.run('/ask history') + self.assertIn('# Ask User', status_result.final_output) + self.assertIn('Queued answers: 1', status_result.final_output) + self.assertIn('# Ask User History', history_result.final_output) + + def test_team_commands_render_local_team_runtime(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + (workspace / '.claw-teams.json').write_text( + '{"teams":[{"name":"reviewers","members":["alice","bob"]}]}', + encoding='utf-8', + ) + agent = LocalCodingAgent( + model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'), + runtime_config=AgentRuntimeConfig(cwd=workspace), + ) + teams_result = agent.run('/teams') + team_result = agent.run('/team reviewers') + messages_result = agent.run('/messages') + self.assertIn('# Teams', teams_result.final_output) + self.assertIn('reviewers', teams_result.final_output) + self.assertIn('# Team', team_result.final_output) + self.assertIn('alice', team_result.final_output) + self.assertIn('# Team Messages', messages_result.final_output) + def test_config_commands_render_local_reports(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: workspace = Path(tmp_dir) diff --git a/tests/test_ask_user_runtime.py b/tests/test_ask_user_runtime.py new file mode 100644 index 0000000..585ed00 --- /dev/null +++ b/tests/test_ask_user_runtime.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from src.ask_user_runtime import AskUserRuntime +from src.agent_tools import build_tool_context, default_tool_registry, execute_tool +from src.agent_types import AgentRuntimeConfig + + +class AskUserRuntimeTests(unittest.TestCase): + def test_ask_user_runtime_consumes_queued_answers(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + (workspace / '.claw-ask-user.json').write_text( + '{"answers":[{"question":"Approve deploy?","answer":"yes"}]}', + encoding='utf-8', + ) + runtime = AskUserRuntime.from_workspace(workspace) + response = runtime.answer(question='Approve deploy?') + restored = AskUserRuntime.from_workspace(workspace) + + self.assertEqual(response.answer, 'yes') + self.assertEqual(response.source, 'queued') + self.assertEqual(len(restored.queued_answers), 0) + self.assertEqual(len(restored.history), 1) + + def test_ask_user_tool_executes_against_runtime(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + (workspace / '.claw-ask-user.json').write_text( + '{"answers":[{"question":"Choose mode","answer":"safe"}]}', + encoding='utf-8', + ) + runtime = AskUserRuntime.from_workspace(workspace) + context = build_tool_context( + AgentRuntimeConfig(cwd=workspace), + ask_user_runtime=runtime, + ) + result = execute_tool( + default_tool_registry(), + 'ask_user_question', + { + 'question': 'Choose mode', + 'choices': ['safe', 'fast'], + 'allow_free_text': False, + }, + context, + ) + + self.assertTrue(result.ok) + self.assertIn('# Ask User', result.content) + self.assertIn('safe', result.content) + self.assertEqual(result.metadata.get('action'), 'ask_user_question') diff --git a/tests/test_extended_tools.py b/tests/test_extended_tools.py index 9057e1e..11493f7 100644 --- a/tests/test_extended_tools.py +++ b/tests/test_extended_tools.py @@ -5,7 +5,7 @@ import unittest from pathlib import Path from src.agent_tools import build_tool_context, default_tool_registry, execute_tool -from src.agent_types import AgentRuntimeConfig +from src.agent_types import AgentPermissions, AgentRuntimeConfig class ExtendedToolTests(unittest.TestCase): @@ -65,3 +65,39 @@ class ExtendedToolTests(unittest.TestCase): self.assertTrue(result.ok) self.assertIn('slept for', result.content) self.assertEqual(result.metadata.get('action'), 'sleep') + + def test_notebook_edit_updates_ipynb_cell(self) -> None: + registry = default_tool_registry() + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + notebook = workspace / 'demo.ipynb' + notebook.write_text( + '{\n' + ' "cells": [\n' + ' {"cell_type": "code", "metadata": {}, "source": ["print(1)\\n"], "outputs": [], "execution_count": null}\n' + ' ],\n' + ' "metadata": {},\n' + ' "nbformat": 4,\n' + ' "nbformat_minor": 5\n' + '}\n', + encoding='utf-8', + ) + context = build_tool_context( + AgentRuntimeConfig( + cwd=workspace, + permissions=AgentPermissions(allow_file_write=True), + ), + tool_registry=registry, + ) + result = execute_tool( + registry, + 'notebook_edit', + {'path': 'demo.ipynb', 'cell_index': 0, 'source': 'print(2)\n'}, + context, + ) + updated = notebook.read_text(encoding='utf-8') + + self.assertTrue(result.ok) + self.assertIn('updated notebook cell 0', result.content) + self.assertIn('print(2)', updated) + self.assertEqual(result.metadata.get('action'), 'notebook_edit') diff --git a/tests/test_main.py b/tests/test_main.py index de848c0..0a6c883 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -140,6 +140,12 @@ class MainCliTests(unittest.TestCase): self.assertEqual(args.command, 'account-profiles') self.assertEqual(args.cwd, '.') + def test_parser_accepts_ask_user_runtime_commands(self) -> None: + parser = build_parser() + args = parser.parse_args(['ask-history', '--cwd', '.']) + self.assertEqual(args.command, 'ask-history') + self.assertEqual(args.cwd, '.') + def test_parser_accepts_search_runtime_commands(self) -> None: parser = build_parser() args = parser.parse_args(['search', 'repo query', '--cwd', '.', '--provider', 'local-search']) @@ -167,3 +173,11 @@ class MainCliTests(unittest.TestCase): self.assertEqual(args.command, 'config-get') self.assertEqual(args.key_path, 'review.mode') self.assertEqual(args.cwd, '.') + + def test_parser_accepts_team_runtime_commands(self) -> None: + parser = build_parser() + args = parser.parse_args(['team-create', 'reviewers', '--member', 'alice', '--cwd', '.']) + self.assertEqual(args.command, 'team-create') + self.assertEqual(args.team_name, 'reviewers') + self.assertEqual(args.member, ['alice']) + self.assertEqual(args.cwd, '.') diff --git a/tests/test_team_runtime.py b/tests/test_team_runtime.py new file mode 100644 index 0000000..b979f73 --- /dev/null +++ b/tests/test_team_runtime.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from src.agent_tools import build_tool_context, default_tool_registry, execute_tool +from src.agent_types import AgentPermissions, AgentRuntimeConfig +from src.team_runtime import TeamRuntime + + +class TeamRuntimeTests(unittest.TestCase): + def test_team_runtime_persists_team_and_messages(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + runtime = TeamRuntime.from_workspace(workspace) + runtime.create_team('reviewers', members=['alice', 'bob']) + runtime.send_message( + team_name='reviewers', + text='Please review the patch.', + sender='agent', + recipient='alice', + ) + restored = TeamRuntime.from_workspace(workspace) + + self.assertEqual(len(restored.teams), 1) + self.assertEqual(restored.teams[0].name, 'reviewers') + self.assertEqual(len(restored.messages), 1) + self.assertIn('Please review the patch.', restored.render_messages()) + + def test_team_tools_execute_against_runtime(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + runtime = TeamRuntime.from_workspace(workspace) + context = build_tool_context( + AgentRuntimeConfig( + cwd=workspace, + permissions=AgentPermissions(allow_file_write=True), + ), + team_runtime=runtime, + ) + create_result = execute_tool( + default_tool_registry(), + 'team_create', + {'team_name': 'reviewers', 'members': ['alice', 'bob']}, + context, + ) + send_result = execute_tool( + default_tool_registry(), + 'send_message', + { + 'team_name': 'reviewers', + 'message': 'Check notebook changes', + 'sender': 'agent', + }, + context, + ) + list_result = execute_tool( + default_tool_registry(), + 'team_list', + {}, + context, + ) + message_result = execute_tool( + default_tool_registry(), + 'team_messages', + {'team_name': 'reviewers'}, + context, + ) + + self.assertTrue(create_result.ok) + self.assertEqual(create_result.metadata.get('action'), 'team_create') + self.assertTrue(send_result.ok) + self.assertEqual(send_result.metadata.get('action'), 'send_message') + self.assertIn('reviewers', list_result.content) + self.assertIn('Check notebook changes', message_result.content) diff --git a/tests/test_terminal_bench_local.py b/tests/test_terminal_bench_local.py index b688ce3..9b425da 100644 --- a/tests/test_terminal_bench_local.py +++ b/tests/test_terminal_bench_local.py @@ -7,6 +7,7 @@ from pathlib import Path from benchmarks.run_terminal_bench_local import ( TerminalBenchTask, build_host_agent_command, + build_verifier_exec_command, discover_tasks, filter_tasks, parse_dockerfile_workdir, @@ -129,6 +130,57 @@ docker_image = "example/other:latest" self.assertIn("instruction=$(cat", cmd) self.assertNotIn("claw-code-agent agent", cmd) + def test_build_verifier_command_fakeroot_adds_flags(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + workspace_dir = root / "workspace" + verifier_logs_dir = root / "verifier" + task_dir = root / "task" + tests_dir = task_dir / "tests" + workspace_dir.mkdir() + verifier_logs_dir.mkdir() + tests_dir.mkdir(parents=True) + image_path = root / "image.sif" + image_path.write_text("fake", encoding="utf-8") + task = TerminalBenchTask( + task_dir=task_dir, + name="terminal-bench/demo", + short_name="demo", + instruction="solve it", + docker_image="example/demo:latest", + agent_timeout_sec=30.0, + verifier_timeout_sec=30.0, + workdir="/workspace", + has_docker_compose=False, + ) + + cmd_no_fakeroot = build_verifier_exec_command( + task=task, + image_path=image_path, + workspace_dir=workspace_dir, + task_dir=task_dir, + verifier_logs_dir=verifier_logs_dir, + env={}, + fakeroot=False, + ) + self.assertNotIn("--fakeroot", cmd_no_fakeroot) + self.assertNotIn("--writable-tmpfs", cmd_no_fakeroot) + + cmd_fakeroot = build_verifier_exec_command( + task=task, + image_path=image_path, + workspace_dir=workspace_dir, + task_dir=task_dir, + verifier_logs_dir=verifier_logs_dir, + env={}, + fakeroot=True, + ) + self.assertIn("--fakeroot", cmd_fakeroot) + self.assertIn("--writable-tmpfs", cmd_fakeroot) + self.assertIn("--contain", cmd_fakeroot) + self.assertIn("TMPDIR", cmd_fakeroot) + self.assertIn("CURL_CA_BUNDLE", cmd_fakeroot) + if __name__ == "__main__": unittest.main()