Merge pull request #17 from HarnessLab/feature/readme-update-april-2026

Implemented the next parity slice.
This commit is contained in:
Abdelrahman Abdallah
2026-04-07 02:53:07 +02:00
committed by GitHub
21 changed files with 1886 additions and 24 deletions
+13 -12
View File
@@ -299,10 +299,12 @@ Done:
- [x] `web_search` - [x] `web_search`
- [x] `tool_search` - [x] `tool_search`
- [x] `sleep` - [x] `sleep`
- [x] `ask_user_question`
- [x] `account_status` - [x] `account_status`
- [x] `account_list_profiles` - [x] `account_list_profiles`
- [x] `account_login` - [x] `account_login`
- [x] `account_logout` - [x] `account_logout`
- [x] `notebook_edit`
- [x] `mcp_list_resources` - [x] `mcp_list_resources`
- [x] `mcp_read_resource` - [x] `mcp_read_resource`
- [x] `mcp_list_tools` - [x] `mcp_list_tools`
@@ -328,20 +330,22 @@ Done:
- [x] `task_block` - [x] `task_block`
- [x] `task_cancel` - [x] `task_cancel`
- [x] `todo_write` - [x] `todo_write`
- [x] `team_list`
- [x] `team_get`
- [x] `team_create`
- [x] `team_delete`
- [x] `send_message`
- [x] `team_messages`
Missing: Missing:
- [ ] Agent spawning tool parity beyond the current `delegate_agent` runtime tool - [ ] Agent spawning tool parity beyond the current `delegate_agent` runtime tool
- [ ] Skill tool - [ ] Skill tool
- [ ] Notebook edit tool
- [ ] Web fetch parity beyond the current local text-fetch implementation - [ ] Web fetch parity beyond the current local text-fetch implementation
- [ ] Web search parity beyond the current provider-backed implementation - [ ] Web search parity beyond the current provider-backed implementation
- [ ] Ask-user-question tool
- [ ] LSP tool - [ ] LSP tool
- [ ] Tool search parity beyond the current local registry search - [ ] Tool search parity beyond the current local registry search
- [ ] Config tool - [ ] Config tool
- [ ] Team create/delete tools
- [ ] Send-message tool
- [ ] Terminal capture tool - [ ] Terminal capture tool
- [ ] Browser tool - [ ] Browser tool
- [ ] Workflow 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 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 remote profile/runtime flow with persisted connect/disconnect state
- [x] Local background task management for agent worker sessions - [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: Missing:
- [ ] Real implementation of the larger upstream command tree - [ ] Real implementation of the larger upstream command tree
- [ ] Task orchestration system beyond the current local dependency-aware task runtime - [ ] 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 - [ ] 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 - [ ] Command-specific session behaviors
- [ ] Full `src/commands/*` parity - [ ] Full `src/commands/*` parity
- [ ] Full `src/tasks/*` parity - [ ] Full `src/tasks/*` parity
@@ -532,12 +538,8 @@ Mirrored inventory / scaffold areas that still need real implementation work:
- [ ] `src/tools.py` - [ ] `src/tools.py`
- [ ] `src/query_engine.py` - [ ] `src/query_engine.py`
- [ ] `src/runtime.py` - [ ] `src/runtime.py`
- [ ] `src/services/*` - [ ] Remaining mirrored inventory surfaces still represented mainly by snapshot data under `src/reference_data/*`
- [ ] `src/plugins/*` - [ ] Command/task/plugin/skill/service/editor subsystems that exist upstream but do not yet have real Python modules after the tree cleanup
- [ ] `src/remote/*`
- [ ] `src/voice/*`
- [ ] `src/vim/*`
- [ ] Large parts of the rest of the mirrored package tree
## 15. High-Priority Next Steps ## 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 MCP parity beyond the current stdio resource/tool transport support
- [ ] Expand hooks and policy parity beyond the current manifest/runtime implementation - [ ] Expand hooks and policy parity beyond the current manifest/runtime implementation
- [ ] Build a real interactive REPL / TUI - [ ] Build a real interactive REPL / TUI
- [ ] Add tokenizer-accurate context accounting
- [ ] Expand background session parity beyond the current local worker/log/attach model - [ ] 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 - [ ] Add real remote session transport and shared remote state beyond the current local remote-profile runtime
- [ ] Port more of the command/task system - [ ] Port more of the command/task system
+15 -7
View File
@@ -46,6 +46,9 @@
| 🆕 | **MCP Transport** | Real stdio MCP transport for `initialize`, resource listing/reading, and tool listing/calling | | 🆕 | **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 | | 🆕 | **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 | | 🆕 | **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 | | 🆕 | **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 | | 🆕 | **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 | | 🆕 | **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 | | 🛰️ **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 | | 🔎 **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 | | ⚙️ **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 | | 🪝 **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 | | 🧠 **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 | | 🔢 **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 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 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 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 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 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] 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] Tokenizer-aware context accounting with cached tokenizer backends and heuristic fallback
- [x] Plugin runtime: manifest discovery, hooks, aliases, virtual tools, tool blocking - [x] Plugin runtime: manifest discovery, hooks, aliases, virtual tools, tool blocking
- [x] Plugin lifecycle hooks: resume, persist, delegate phases - [x] Plugin lifecycle hooks: resume, persist, delegate phases
@@ -175,7 +184,7 @@ claw-code/
├── .gitignore ├── .gitignore
├── images/ ├── images/
│ └── logo.png │ └── logo.png
├── src/ # Python implementation (75+ modules) ├── src/ # Python implementation
│ ├── main.py # CLI entry point & argument parsing │ ├── main.py # CLI entry point & argument parsing
│ ├── agent_runtime.py # Core agent loop (LocalCodingAgent) │ ├── agent_runtime.py # Core agent loop (LocalCodingAgent)
│ ├── agent_tools.py # Tool definitions & execution engine │ ├── 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 │ ├── remote_runtime.py # Local remote profiles, connect/disconnect state, remote CLI support
│ ├── background_runtime.py # Local background sessions and daemon support │ ├── background_runtime.py # Local background sessions and daemon support
│ ├── account_runtime.py # Local account profiles, login/logout state, account CLI 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 │ ├── config_runtime.py # Local workspace config/settings discovery and mutation
│ ├── plan_runtime.py # Persistent plan runtime and plan sync │ ├── plan_runtime.py # Persistent plan runtime and plan sync
│ ├── task_runtime.py # Persistent task runtime and task execution │ ├── task_runtime.py # Persistent task runtime and task execution
│ ├── task.py # Task state model and task dataclasses │ ├── 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 │ ├── hook_policy.py # Hook/policy manifests, trust, and safe env handling
│ ├── tokenizer_runtime.py # Tokenizer-aware context accounting backends │ ├── tokenizer_runtime.py # Tokenizer-aware context accounting backends
│ ├── permissions.py # Tool permission filtering │ ├── permissions.py # Tool permission filtering
│ ├── cost_tracker.py # Cost & budget enforcement │ ├── cost_tracker.py # Cost & budget enforcement
│ ├── tools.py # Mirrored tool inventory
│ ├── commands.py # Mirrored command inventory │ ├── commands.py # Mirrored command inventory
│ ├── plugins/ # Plugin subsystem │ ├── tools.py # Mirrored tool inventory
│ ├── hooks/ # Hook system (WIP) │ ├── runtime.py # Mirrored runtime facade
── remote/ # Remote runtime modes (WIP) ── reference_data/ # Mirrored inventory snapshots
│ ├── voice/ # Voice mode (WIP)
│ └── vim/ # VIM mode (WIP)
└── tests/ # Unit tests └── tests/ # Unit tests
├── test_agent_runtime.py ├── test_agent_runtime.py
├── test_agent_context.py ├── test_agent_context.py
+148 -1
View File
@@ -71,6 +71,8 @@ export OPENAI_API_KEY=anything
export OPENAI_MODEL=ollama/qwen3 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 ### 1.5 Run the full unit test suite
```bash ```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_remote_runtime -v
python3 -m unittest tests.test_config_runtime -v python3 -m unittest tests.test_config_runtime -v
python3 -m unittest tests.test_account_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_tokenizer_runtime -v
python3 -m unittest tests.test_extended_tools -v python3 -m unittest tests.test_extended_tools -v
python3 -m unittest tests.test_porting_workspace -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_plugins/plugins/demo
mkdir -p ./test_cases_mcp mkdir -p ./test_cases_mcp
mkdir -p ./test_cases_tasks mkdir -p ./test_cases_tasks
mkdir -p ./test_cases_notebooks
``` ```
### 4.1 Config fixtures ### 4.1 Config fixtures
@@ -243,6 +248,67 @@ cat > ./test_cases/.claw-account.json <<'EOF'
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 ### 4.3 Remote fixtures
```bash ```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" --cwd ./test_cases
python3 -m src.main agent "/account profiles" --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 "/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 "/login local" --cwd ./test_cases
python3 -m src.main agent "/logout" --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 ```bash
python3 -m src.main agent "/remote" --cwd ./test_cases 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 provider local-search" --cwd ./test_cases
python3 -m src.main agent "/search use 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 "/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 "/plan" --cwd ./test_cases_tasks
python3 -m src.main agent "/planner" --cwd ./test_cases_tasks python3 -m src.main agent "/planner" --cwd ./test_cases_tasks
python3 -m src.main agent "/tasks" --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 --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. Search Runtime And Real Web Search
### 15.1 Provider status and activation ### 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 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 ### 18.2 Create and inspect tasks
```bash ```bash
+32 -3
View File
@@ -271,6 +271,7 @@ def build_verifier_exec_command(
task_dir: Path, task_dir: Path,
verifier_logs_dir: Path, verifier_logs_dir: Path,
env: dict[str, str], env: dict[str, str],
fakeroot: bool = False,
) -> str: ) -> str:
binds = [ binds = [
f"{workspace_dir}:{task.workdir}:rw", f"{workspace_dir}:{task.workdir}:rw",
@@ -278,18 +279,35 @@ def build_verifier_exec_command(
f"{verifier_logs_dir}:/logs/verifier:rw", f"{verifier_logs_dir}:/logs/verifier:rw",
] ]
bind_flags = " ".join(f"--bind {shlex.quote(spec)}" for spec in binds) 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( env_flags = " ".join(
f"--env {shlex.quote(key)}={shlex.quote(value)}" f"--env {shlex.quote(key)}={shlex.quote(value)}"
for key, value in sorted(env.items()) for key, value in sorted(env.items())
) )
inner = ( inner = (
"set -euo pipefail; " "set -euo pipefail; "
"chmod +x /tests/test.sh; " "chmod +x /tests/test.sh 2>/dev/null || true; "
f"cd {shlex.quote(task.workdir)}; " 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 ( 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"--cwd {shlex.quote(task.workdir)} {shlex.quote(str(image_path))} "
f"bash -lc {shlex.quote(inner)}" f"bash -lc {shlex.quote(inner)}"
) )
@@ -304,6 +322,7 @@ def run_trial(
force_pull: bool, force_pull: bool,
keep_images: bool, keep_images: bool,
timeout_multiplier: float, timeout_multiplier: float,
fakeroot: bool = False,
) -> LocalTrialResult: ) -> LocalTrialResult:
timestamp = time.strftime("%Y%m%d-%H%M%S") timestamp = time.strftime("%Y%m%d-%H%M%S")
trial_dir = jobs_dir / f"{timestamp}_{safe_name(task.short_name)}" trial_dir = jobs_dir / f"{timestamp}_{safe_name(task.short_name)}"
@@ -363,6 +382,7 @@ def run_trial(
task_dir=task.task_dir, task_dir=task.task_dir,
verifier_logs_dir=verifier_logs_dir, verifier_logs_dir=verifier_logs_dir,
env=verifier_env, env=verifier_env,
fakeroot=fakeroot,
) )
agent_timeout = (task.agent_timeout_sec or 1800.0) * timeout_multiplier 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("--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("--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("--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 return parser
@@ -481,6 +507,8 @@ def main() -> None:
print("=" * 80) print("=" * 80)
print(f" Tasks: {len(tasks)}") print(f" Tasks: {len(tasks)}")
print(f" Jobs dir: {args.jobs_dir}") print(f" Jobs dir: {args.jobs_dir}")
if args.fakeroot:
print(" Fakeroot: enabled (no-sudo HPC mode)")
print("=" * 80) print("=" * 80)
print() print()
@@ -494,6 +522,7 @@ def main() -> None:
force_pull=args.force_pull, force_pull=args.force_pull,
keep_images=args.keep_images, keep_images=args.keep_images,
timeout_multiplier=args.timeout_multiplier, timeout_multiplier=args.timeout_multiplier,
fakeroot=args.fakeroot,
) )
results.append(result) results.append(result)
icon = "PASS ✅" if result.passed else ("SKIP ⚪" if result.status == "skipped" else "FAIL ❌") icon = "PASS ✅" if result.passed else ("SKIP ⚪" if result.status == "skipped" else "FAIL ❌")
+8
View File
@@ -1,6 +1,7 @@
"""Python porting workspace for the Claude Code rewrite effort.""" """Python porting workspace for the Claude Code rewrite effort."""
from .account_runtime import AccountRuntime, AccountProfile, AccountSessionState, AccountStatusReport from .account_runtime import AccountRuntime, AccountProfile, AccountSessionState, AccountStatusReport
from .ask_user_runtime import AskUserRuntime, AskUserResponse, QueuedUserAnswer
from .agent_context import ( from .agent_context import (
AgentContextSnapshot, AgentContextSnapshot,
build_context_snapshot, 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 .system_init import build_system_init_message
from .task import PortingTask from .task import PortingTask
from .task_runtime import TaskRuntime 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 .tokenizer_runtime import TokenCounterInfo, clear_token_counter_cache, count_tokens, describe_token_counter
from .tools import PORTED_TOOLS, build_tool_backlog from .tools import PORTED_TOOLS, build_tool_backlog
@@ -42,6 +44,8 @@ __all__ = [
'AccountRuntime', 'AccountRuntime',
'AccountSessionState', 'AccountSessionState',
'AccountStatusReport', 'AccountStatusReport',
'AskUserResponse',
'AskUserRuntime',
'AgentMessage', 'AgentMessage',
'AgentSessionState', 'AgentSessionState',
'BackgroundSessionRuntime', 'BackgroundSessionRuntime',
@@ -60,6 +64,7 @@ __all__ = [
'PortRuntime', 'PortRuntime',
'PluginRuntime', 'PluginRuntime',
'PortingTask', 'PortingTask',
'QueuedUserAnswer',
'QueryEnginePort', 'QueryEnginePort',
'RuntimeSession', 'RuntimeSession',
'SearchProviderProfile', 'SearchProviderProfile',
@@ -68,6 +73,9 @@ __all__ = [
'SearchStatusReport', 'SearchStatusReport',
'StoredSession', 'StoredSession',
'TaskRuntime', 'TaskRuntime',
'TeamDefinition',
'TeamMessage',
'TeamRuntime',
'TokenCounterInfo', 'TokenCounterInfo',
'TurnResult', 'TurnResult',
'PORTED_COMMANDS', 'PORTED_COMMANDS',
+8
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from .agent_plugin_cache import load_plugin_cache_summary from .agent_plugin_cache import load_plugin_cache_summary
from .account_runtime import AccountRuntime from .account_runtime import AccountRuntime
from .ask_user_runtime import AskUserRuntime
from .config_runtime import ConfigRuntime from .config_runtime import ConfigRuntime
from .hook_policy import HookPolicyRuntime from .hook_policy import HookPolicyRuntime
from .mcp_runtime import MCPRuntime from .mcp_runtime import MCPRuntime
@@ -18,6 +19,7 @@ from .plugin_runtime import PluginRuntime
from .remote_runtime import RemoteRuntime from .remote_runtime import RemoteRuntime
from .search_runtime import SearchRuntime from .search_runtime import SearchRuntime
from .task_runtime import TaskRuntime from .task_runtime import TaskRuntime
from .team_runtime import TeamRuntime
from .agent_types import AgentRuntimeConfig from .agent_types import AgentRuntimeConfig
MAX_STATUS_CHARS = 2000 MAX_STATUS_CHARS = 2000
@@ -237,6 +239,9 @@ def _get_user_context_cached(
account_runtime = AccountRuntime.from_workspace(Path(cwd), additional_working_directories) account_runtime = AccountRuntime.from_workspace(Path(cwd), additional_working_directories)
if account_runtime.has_account_state(): if account_runtime.has_account_state():
context['accountRuntime'] = account_runtime.render_summary() 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)) config_runtime = ConfigRuntime.from_workspace(Path(cwd))
if config_runtime.has_config(): if config_runtime.has_config():
context['configRuntime'] = config_runtime.render_summary() context['configRuntime'] = config_runtime.render_summary()
@@ -246,6 +251,9 @@ def _get_user_context_cached(
task_runtime = TaskRuntime.from_workspace(Path(cwd)) task_runtime = TaskRuntime.from_workspace(Path(cwd))
if task_runtime.tasks: if task_runtime.tasks:
context['taskRuntime'] = task_runtime.render_summary() 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 return context
+26
View File
@@ -95,9 +95,11 @@ def build_system_prompt_parts(
get_remote_guidance_section(prompt_context), get_remote_guidance_section(prompt_context),
get_search_guidance_section(prompt_context), get_search_guidance_section(prompt_context),
get_account_guidance_section(prompt_context), get_account_guidance_section(prompt_context),
get_ask_user_guidance_section(prompt_context),
get_config_guidance_section(prompt_context), get_config_guidance_section(prompt_context),
get_plan_guidance_section(prompt_context), get_plan_guidance_section(prompt_context),
get_task_guidance_section(prompt_context), get_task_guidance_section(prompt_context),
get_team_guidance_section(prompt_context),
get_hook_policy_guidance_section(prompt_context), get_hook_policy_guidance_section(prompt_context),
get_tone_and_style_section(), get_tone_and_style_section(),
get_output_efficiency_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)]) 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: def get_config_guidance_section(prompt_context: PromptContext) -> str:
config_runtime = prompt_context.user_context.get('configRuntime') config_runtime = prompt_context.user_context.get('configRuntime')
if not config_runtime: if not config_runtime:
@@ -302,6 +316,18 @@ def get_task_guidance_section(prompt_context: PromptContext) -> str:
return '\n'.join(['# Tasks', *prepend_bullets(items)]) 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: def get_plan_guidance_section(prompt_context: PromptContext) -> str:
plan_runtime = prompt_context.user_context.get('planRuntime') plan_runtime = prompt_context.user_context.get('planRuntime')
if not plan_runtime: if not plan_runtime:
+69
View File
@@ -12,6 +12,7 @@ from .agent_manager import AgentManager
from .agent_context import clear_context_caches from .agent_context import clear_context_caches
from .agent_context import render_context_report as render_agent_context_report 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 .agent_context_usage import collect_context_usage, estimate_tokens, format_context_usage
from .ask_user_runtime import AskUserRuntime
from .config_runtime import ConfigRuntime from .config_runtime import ConfigRuntime
from .hook_policy import HookPolicyRuntime from .hook_policy import HookPolicyRuntime
from .mcp_runtime import MCPRuntime from .mcp_runtime import MCPRuntime
@@ -48,6 +49,7 @@ from .plugin_runtime import PluginRuntime
from .remote_runtime import RemoteRuntime from .remote_runtime import RemoteRuntime
from .search_runtime import SearchRuntime from .search_runtime import SearchRuntime
from .task_runtime import TaskRuntime from .task_runtime import TaskRuntime
from .team_runtime import TeamRuntime
from .tokenizer_runtime import describe_token_counter from .tokenizer_runtime import describe_token_counter
from .session_store import ( from .session_store import (
StoredAgentSession, StoredAgentSession,
@@ -84,9 +86,11 @@ class LocalCodingAgent:
remote_runtime: RemoteRuntime | None = None remote_runtime: RemoteRuntime | None = None
search_runtime: SearchRuntime | None = None search_runtime: SearchRuntime | None = None
account_runtime: AccountRuntime | None = None account_runtime: AccountRuntime | None = None
ask_user_runtime: AskUserRuntime | None = None
config_runtime: ConfigRuntime | None = None config_runtime: ConfigRuntime | None = None
plan_runtime: PlanRuntime | None = None plan_runtime: PlanRuntime | None = None
task_runtime: TaskRuntime | None = None task_runtime: TaskRuntime | None = None
team_runtime: TeamRuntime | None = None
last_session: AgentSessionState | None = field(default=None, init=False, repr=False) last_session: AgentSessionState | None = field(default=None, init=False, repr=False)
last_run_result: AgentRunResult | 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) active_session_id: str | None = field(default=None, init=False, repr=False)
@@ -129,12 +133,22 @@ class LocalCodingAgent:
self.runtime_config.cwd, self.runtime_config.cwd,
tuple(str(path) for path in self.runtime_config.additional_working_directories), 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: if self.config_runtime is None:
self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd) self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd)
if self.plan_runtime is None: if self.plan_runtime is None:
self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd) self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd)
if self.task_runtime is None: if self.task_runtime is None:
self.task_runtime = TaskRuntime.from_workspace(self.runtime_config.cwd) 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) self.runtime_config = self._apply_hook_policy_budget_overrides(self.runtime_config)
registry = dict(self.tool_registry) registry = dict(self.tool_registry)
plugin_tools = self.plugin_runtime.register_tool_aliases(registry) plugin_tools = self.plugin_runtime.register_tool_aliases(registry)
@@ -155,11 +169,13 @@ class LocalCodingAgent:
), ),
search_runtime=self.search_runtime, search_runtime=self.search_runtime,
account_runtime=self.account_runtime, account_runtime=self.account_runtime,
ask_user_runtime=self.ask_user_runtime,
config_runtime=self.config_runtime, config_runtime=self.config_runtime,
mcp_runtime=self.mcp_runtime, mcp_runtime=self.mcp_runtime,
remote_runtime=self.remote_runtime, remote_runtime=self.remote_runtime,
plan_runtime=self.plan_runtime, plan_runtime=self.plan_runtime,
task_runtime=self.task_runtime, task_runtime=self.task_runtime,
team_runtime=self.team_runtime,
) )
def set_model(self, model: str) -> None: def set_model(self, model: str) -> None:
@@ -3063,6 +3079,37 @@ class LocalCodingAgent:
return '# Task\n\nNo local task runtime is available.' return '# Task\n\nNo local task runtime is available.'
return self.task_runtime.render_task(task_id) 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: def render_hook_policy_report(self) -> str:
if self.hook_policy_runtime is None: if self.hook_policy_runtime is None:
return '# Hook Policy\n\nNo local hook or policy manifests discovered.' return '# Hook Policy\n\nNo local hook or policy manifests discovered.'
@@ -3131,6 +3178,9 @@ class LocalCodingAgent:
lines.append( lines.append(
f'- Active account: {session.provider} -> {session.identity}' 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(): 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(f'- Config sources: {len(self.config_runtime.sources)}')
lines.append( lines.append(
@@ -3140,6 +3190,9 @@ class LocalCodingAgent:
lines.append(f'- Local plan steps: {len(self.plan_runtime.steps)}') lines.append(f'- Local plan steps: {len(self.plan_runtime.steps)}')
if self.task_runtime is not None and self.task_runtime.tasks: if self.task_runtime is not None and self.task_runtime.tasks:
lines.append(f'- Local tasks: {len(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: if self.last_session_path is not None:
lines.append(f'- Session path: {self.last_session_path}') lines.append(f'- Session path: {self.last_session_path}')
if self.last_run_result is not None: if self.last_run_result is not None:
@@ -3198,6 +3251,10 @@ class LocalCodingAgent:
'account_login', 'account_login',
'account_logout', 'account_logout',
'config_set', 'config_set',
'ask_user_question',
'team_create',
'team_delete',
'send_message',
} }
if tool_name not in refresh_tool_names: if tool_name not in refresh_tool_names:
return return
@@ -3220,21 +3277,33 @@ class LocalCodingAgent:
self.runtime_config.cwd, self.runtime_config.cwd,
additional_working_directories=additional_dirs, 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': if tool_name == 'config_set':
self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd) self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd)
if tool_name.startswith('task_') or tool_name == 'todo_write': if tool_name.startswith('task_') or tool_name == 'todo_write':
self.task_runtime = TaskRuntime.from_workspace(self.runtime_config.cwd) self.task_runtime = TaskRuntime.from_workspace(self.runtime_config.cwd)
if tool_name.startswith('plan_') or tool_name == 'update_plan': if tool_name.startswith('plan_') or tool_name == 'update_plan':
self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd) 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 = replace(
self.tool_context, self.tool_context,
tool_registry=self.tool_registry, tool_registry=self.tool_registry,
search_runtime=self.search_runtime, search_runtime=self.search_runtime,
account_runtime=self.account_runtime, account_runtime=self.account_runtime,
ask_user_runtime=self.ask_user_runtime,
config_runtime=self.config_runtime, config_runtime=self.config_runtime,
remote_runtime=self.remote_runtime, remote_runtime=self.remote_runtime,
plan_runtime=self.plan_runtime, plan_runtime=self.plan_runtime,
task_runtime=self.task_runtime, task_runtime=self.task_runtime,
team_runtime=self.team_runtime,
) )
def _apply_plugin_before_prompt_hooks(self, prompt: str) -> str: def _apply_plugin_before_prompt_hooks(self, prompt: str) -> str:
+46
View File
@@ -129,6 +129,11 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]:
description='Show local account runtime status or configured account profiles.', description='Show local account runtime status or configured account profiles.',
handler=_handle_account, handler=_handle_account,
), ),
SlashCommandSpec(
names=('ask',),
description='Show local ask-user runtime status or ask-user history.',
handler=_handle_ask,
),
SlashCommandSpec( SlashCommandSpec(
names=('login',), names=('login',),
description='Activate a local account profile or ephemeral identity.', 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.', description='Show the local runtime task list, optionally filtered by status.',
handler=_handle_tasks, 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( SlashCommandSpec(
names=('task-next', 'next-task'), names=('task-next', 'next-task'),
description='Show the next actionable tasks from the local runtime task list.', 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 <name>]') return _local_result(input_text, 'Usage: /account [profiles|profile <name>]')
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: def _handle_login(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
target = args.strip() target = args.strip()
if not target: 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)) 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 <team-name>')
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: def _handle_task_next(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
return _local_result(input_text, agent.render_next_tasks_report()) return _local_result(input_text, agent.render_next_tasks_report())
+395
View File
@@ -18,12 +18,14 @@ from .agent_types import AgentPermissions, AgentRuntimeConfig, ToolExecutionResu
if TYPE_CHECKING: if TYPE_CHECKING:
from .account_runtime import AccountRuntime from .account_runtime import AccountRuntime
from .ask_user_runtime import AskUserRuntime
from .config_runtime import ConfigRuntime from .config_runtime import ConfigRuntime
from .mcp_runtime import MCPRuntime from .mcp_runtime import MCPRuntime
from .plan_runtime import PlanRuntime from .plan_runtime import PlanRuntime
from .remote_runtime import RemoteRuntime from .remote_runtime import RemoteRuntime
from .search_runtime import SearchRuntime from .search_runtime import SearchRuntime
from .task_runtime import TaskRuntime from .task_runtime import TaskRuntime
from .team_runtime import TeamRuntime
class ToolPermissionError(RuntimeError): class ToolPermissionError(RuntimeError):
@@ -44,11 +46,13 @@ class ToolExecutionContext:
tool_registry: dict[str, 'AgentTool'] | None = None tool_registry: dict[str, 'AgentTool'] | None = None
search_runtime: 'SearchRuntime | None' = None search_runtime: 'SearchRuntime | None' = None
account_runtime: 'AccountRuntime | None' = None account_runtime: 'AccountRuntime | None' = None
ask_user_runtime: 'AskUserRuntime | None' = None
config_runtime: 'ConfigRuntime | None' = None config_runtime: 'ConfigRuntime | None' = None
mcp_runtime: 'MCPRuntime | None' = None mcp_runtime: 'MCPRuntime | None' = None
remote_runtime: 'RemoteRuntime | None' = None remote_runtime: 'RemoteRuntime | None' = None
plan_runtime: 'PlanRuntime | None' = None plan_runtime: 'PlanRuntime | None' = None
task_runtime: 'TaskRuntime | None' = None task_runtime: 'TaskRuntime | None' = None
team_runtime: 'TeamRuntime | None' = None
ToolHandler = Callable[ ToolHandler = Callable[
@@ -114,11 +118,13 @@ def build_tool_context(
tool_registry: dict[str, AgentTool] | None = None, tool_registry: dict[str, AgentTool] | None = None,
search_runtime: 'SearchRuntime | None' = None, search_runtime: 'SearchRuntime | None' = None,
account_runtime: 'AccountRuntime | None' = None, account_runtime: 'AccountRuntime | None' = None,
ask_user_runtime: 'AskUserRuntime | None' = None,
config_runtime: 'ConfigRuntime | None' = None, config_runtime: 'ConfigRuntime | None' = None,
mcp_runtime: 'MCPRuntime | None' = None, mcp_runtime: 'MCPRuntime | None' = None,
remote_runtime: 'RemoteRuntime | None' = None, remote_runtime: 'RemoteRuntime | None' = None,
plan_runtime: 'PlanRuntime | None' = None, plan_runtime: 'PlanRuntime | None' = None,
task_runtime: 'TaskRuntime | None' = None, task_runtime: 'TaskRuntime | None' = None,
team_runtime: 'TeamRuntime | None' = None,
) -> ToolExecutionContext: ) -> ToolExecutionContext:
return ToolExecutionContext( return ToolExecutionContext(
root=config.cwd.resolve(), root=config.cwd.resolve(),
@@ -129,11 +135,13 @@ def build_tool_context(
tool_registry=tool_registry, tool_registry=tool_registry,
search_runtime=search_runtime, search_runtime=search_runtime,
account_runtime=account_runtime, account_runtime=account_runtime,
ask_user_runtime=ask_user_runtime,
config_runtime=config_runtime, config_runtime=config_runtime,
mcp_runtime=mcp_runtime, mcp_runtime=mcp_runtime,
remote_runtime=remote_runtime, remote_runtime=remote_runtime,
plan_runtime=plan_runtime, plan_runtime=plan_runtime,
task_runtime=task_runtime, task_runtime=task_runtime,
team_runtime=team_runtime,
) )
@@ -238,6 +246,22 @@ def default_tool_registry() -> dict[str, AgentTool]:
}, },
handler=_edit_file, 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( AgentTool(
name='glob_search', name='glob_search',
description='Find files matching a glob pattern inside the workspace.', description='Find files matching a glob pattern inside the workspace.',
@@ -368,6 +392,25 @@ def default_tool_registry() -> dict[str, AgentTool]:
}, },
handler=_sleep, 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( AgentTool(
name='account_status', name='account_status',
description='Show local account runtime summary or a specific configured account profile.', 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, 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( AgentTool(
name='task_list', name='task_list',
description='List locally stored runtime tasks.', 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: def _glob_search(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
pattern = _require_string(arguments, 'pattern') pattern = _require_string(arguments, 'pattern')
matches = sorted(context.root.glob(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: def _account_status(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
runtime = _require_account_runtime(context) runtime = _require_account_runtime(context)
profile = arguments.get('profile') 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: def _task_list(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
runtime = _require_task_runtime(context) runtime = _require_task_runtime(context)
status = arguments.get('status') status = arguments.get('status')
@@ -2048,6 +2431,12 @@ def _require_account_runtime(context: ToolExecutionContext):
return context.account_runtime 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): def _require_search_runtime(context: ToolExecutionContext):
if context.search_runtime is None or not context.search_runtime.has_search_runtime(): if context.search_runtime is None or not context.search_runtime.has_search_runtime():
raise ToolExecutionError( raise ToolExecutionError(
@@ -2095,6 +2484,12 @@ def _require_task_runtime(context: ToolExecutionContext):
return context.task_runtime 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( def _task_mutation_metadata(
*, *,
action: str, action: str,
+320
View File
@@ -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 {}
+83
View File
@@ -10,6 +10,7 @@ from typing import Callable
from .background_runtime import BackgroundSessionRuntime, build_background_worker_command from .background_runtime import BackgroundSessionRuntime, build_background_worker_command
from .account_runtime import AccountRuntime from .account_runtime import AccountRuntime
from .ask_user_runtime import AskUserRuntime
from .agent_runtime import LocalCodingAgent from .agent_runtime import LocalCodingAgent
from .agent_types import ( from .agent_types import (
AgentPermissions, AgentPermissions,
@@ -37,6 +38,7 @@ from .remote_runtime import (
run_teleport_mode, run_teleport_mode,
) )
from .search_runtime import SearchRuntime from .search_runtime import SearchRuntime
from .team_runtime import TeamRuntime
from .runtime import PortRuntime from .runtime import PortRuntime
from .session_store import ( from .session_store import (
StoredAgentSession, StoredAgentSession,
@@ -613,6 +615,10 @@ def build_parser() -> argparse.ArgumentParser:
account_login_parser.add_argument('--cwd', default='.') 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 = subparsers.add_parser('account-logout', help='clear the active local account session')
account_logout_parser.add_argument('--cwd', default='.') 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 = subparsers.add_parser('search-status', help='show local search runtime status')
search_status_parser.add_argument('--cwd', default='.') search_status_parser.add_argument('--cwd', default='.')
search_status_parser.add_argument('--provider') 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('value_json')
config_set_parser.add_argument('--source', default='local') config_set_parser.add_argument('--source', default='local')
config_set_parser.add_argument('--cwd', default='.') 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 = subparsers.add_parser('show-command', help='show one mirrored command entry by exact name')
show_command.add_argument('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()) runtime = AccountRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.logout().as_text()) print(runtime.logout().as_text())
return 0 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': if args.command == 'search-status':
runtime = SearchRuntime.from_workspace(Path(args.cwd).resolve()) runtime = SearchRuntime.from_workspace(Path(args.cwd).resolve())
if args.provider: 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(f'effective_key_count={mutation.effective_key_count}')
print(runtime.render_value(args.key_path)) print(runtime.render_value(args.key_path))
return 0 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': if args.command == 'show-command':
module = get_command(args.name) module = get_command(args.name)
if module is None: if module is None:
+386
View File
@@ -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 {}
+28
View File
@@ -12,9 +12,11 @@ from src.agent_context import (
clear_context_caches, clear_context_caches,
set_system_prompt_injection, set_system_prompt_injection,
) )
from src.ask_user_runtime import AskUserRuntime
from src.plan_runtime import PlanRuntime from src.plan_runtime import PlanRuntime
from src.agent_types import AgentRuntimeConfig from src.agent_types import AgentRuntimeConfig
from src.task_runtime import TaskRuntime from src.task_runtime import TaskRuntime
from src.team_runtime import TeamRuntime
class AgentContextTests(unittest.TestCase): class AgentContextTests(unittest.TestCase):
@@ -151,6 +153,20 @@ class AgentContextTests(unittest.TestCase):
self.assertIn('Configured account profiles: 1', snapshot.user_context['accountRuntime']) self.assertIn('Configured account profiles: 1', snapshot.user_context['accountRuntime'])
self.assertIn('dev@example.com', 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: def test_user_context_loads_config_runtime_summary(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir: with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir) / 'repo' workspace = Path(tmp_dir) / 'repo'
@@ -195,6 +211,18 @@ class AgentContextTests(unittest.TestCase):
self.assertIn('planRuntime', snapshot.user_context) self.assertIn('planRuntime', snapshot.user_context)
self.assertIn('Total plan steps: 1', snapshot.user_context['planRuntime']) 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') @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: def test_git_status_snapshot_contains_branch_and_status(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir: with tempfile.TemporaryDirectory() as tmp_dir:
+38
View File
@@ -186,6 +186,25 @@ class AgentPromptingTests(unittest.TestCase):
prompt = render_system_prompt(parts) prompt = render_system_prompt(parts)
self.assertIn('# Account', prompt) 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: def test_prompt_builder_mentions_config_when_runtime_is_loaded(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir: with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir) workspace = Path(tmp_dir)
@@ -224,6 +243,25 @@ class AgentPromptingTests(unittest.TestCase):
prompt = render_system_prompt(parts) prompt = render_system_prompt(parts)
self.assertIn('# Tasks', prompt) 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: def test_prompt_builder_mentions_planning_when_runtime_is_loaded(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir: with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir) workspace = Path(tmp_dir)
+37
View File
@@ -246,6 +246,43 @@ class AgentSlashCommandTests(unittest.TestCase):
self.assertIn('profile=local', login_result.final_output) self.assertIn('profile=local', login_result.final_output)
self.assertIn('logged_in=False', logout_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: def test_config_commands_render_local_reports(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir: with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir) workspace = Path(tmp_dir)
+55
View File
@@ -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')
+37 -1
View File
@@ -5,7 +5,7 @@ import unittest
from pathlib import Path from pathlib import Path
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool 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): class ExtendedToolTests(unittest.TestCase):
@@ -65,3 +65,39 @@ class ExtendedToolTests(unittest.TestCase):
self.assertTrue(result.ok) self.assertTrue(result.ok)
self.assertIn('slept for', result.content) self.assertIn('slept for', result.content)
self.assertEqual(result.metadata.get('action'), 'sleep') 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')
+14
View File
@@ -140,6 +140,12 @@ class MainCliTests(unittest.TestCase):
self.assertEqual(args.command, 'account-profiles') self.assertEqual(args.command, 'account-profiles')
self.assertEqual(args.cwd, '.') 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: def test_parser_accepts_search_runtime_commands(self) -> None:
parser = build_parser() parser = build_parser()
args = parser.parse_args(['search', 'repo query', '--cwd', '.', '--provider', 'local-search']) 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.command, 'config-get')
self.assertEqual(args.key_path, 'review.mode') self.assertEqual(args.key_path, 'review.mode')
self.assertEqual(args.cwd, '.') 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, '.')
+76
View File
@@ -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)
+52
View File
@@ -7,6 +7,7 @@ from pathlib import Path
from benchmarks.run_terminal_bench_local import ( from benchmarks.run_terminal_bench_local import (
TerminalBenchTask, TerminalBenchTask,
build_host_agent_command, build_host_agent_command,
build_verifier_exec_command,
discover_tasks, discover_tasks,
filter_tasks, filter_tasks,
parse_dockerfile_workdir, parse_dockerfile_workdir,
@@ -129,6 +130,57 @@ docker_image = "example/other:latest"
self.assertIn("instruction=$(cat", cmd) self.assertIn("instruction=$(cat", cmd)
self.assertNotIn("claw-code-agent agent", 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__": if __name__ == "__main__":
unittest.main() unittest.main()