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

add mcp and online search
This commit is contained in:
Abdelrahman Abdallah
2026-04-05 02:36:19 +02:00
committed by GitHub
38 changed files with 8114 additions and 261 deletions
+96 -17
View File
@@ -72,12 +72,22 @@ Done:
- [x] Local manifest-backed MCP resource discovery
- [x] Local MCP resource listing and reading
- [x] MCP-backed runtime tools for local resource access
- [x] Real stdio MCP client transport for `initialize`, `resources/list`, `resources/read`, `tools/list`, and `tools/call`
- [x] Transport-backed MCP tool listing and execution
- [x] Local manifest-backed remote runtime discovery
- [x] Local remote profile listing and summary reporting
- [x] Local remote connect/disconnect state persistence
- [x] Local manifest/env-backed search runtime discovery
- [x] Local search-provider activation persistence
- [x] Provider-backed web search execution against configured search backends
- [x] Local persistent task runtime discovery
- [x] Local task create/get/list/update runtime flows
- [x] Local todo-list replacement runtime flow
- [x] Local persistent plan runtime discovery
- [x] Local plan get/update/clear runtime flows
- [x] Local plan-to-task sync flow
- [x] Dependency-aware local task state with blocking and actionable-task selection
- [x] Local task start/complete/block/cancel execution flows
- [x] Compaction metadata with compacted message ids
- [x] Compaction metadata with preserved-tail ids and compaction depth
- [x] Compaction metadata with compacted/preserved lineage ids and revision summaries
@@ -125,11 +135,18 @@ Done:
- [x] Local background session logs (`agent-logs`)
- [x] Local background attach snapshot (`agent-attach`)
- [x] Local background kill flow (`agent-kill`)
- [x] Local daemon-style background command family (`daemon start/ps/logs/attach/kill`)
- [x] Local daemon worker command path (`daemon worker`)
- [x] Local remote runtime CLI modes (`remote-mode`, `ssh-mode`, `teleport-mode`, `direct-connect-mode`, `deep-link-mode`)
- [x] Local remote runtime inspection commands (`remote-status`, `remote-profiles`, `remote-disconnect`)
- [x] Local account runtime inspection commands (`account-status`, `account-profiles`, `account-login`, `account-logout`)
- [x] Local search runtime inspection commands (`search-status`, `search-providers`, `search-activate`, `search`)
- [x] Local MCP runtime inspection commands (`mcp-status`, `mcp-resources`, `mcp-resource`, `mcp-tools`, `mcp-call-tool`)
- [x] Inventory/helper commands such as `summary`, `manifest`, `commands`, and `tools`
Missing:
- [ ] Daemon worker mode
- [ ] Full daemon supervisor parity beyond the current local daemon wrapper and worker flow
- [ ] Remote-control / bridge runtime mode
- [ ] Browser/native-host runtime mode
- [ ] Computer-use MCP mode
@@ -152,6 +169,10 @@ Done:
- [x] Custom system prompt override and append support
- [x] Local hook/policy guidance section in the Python system prompt
- [x] Local MCP guidance section in the Python system prompt
- [x] MCP transport/tool guidance section in the Python system prompt
- [x] Local remote-runtime guidance section in the Python system prompt
- [x] Local search-runtime guidance section in the Python system prompt
- [x] Local account-runtime guidance section in the Python system prompt
- [x] Local planning guidance section in the Python system prompt
- [x] Local task guidance section in the Python system prompt
@@ -178,18 +199,23 @@ Done:
- [x] `CLAUDE.md` discovery
- [x] Extra directory injection through `--add-dir`
- [x] Session context usage report
- [x] Tokenizer-aware context accounting with cached model-specific backends and heuristic fallback
- [x] Raw context inspection command
- [x] Plugin cache snapshot injection
- [x] Manifest-based plugin runtime summary injection
- [x] Manifest-based hook/policy summary injection
- [x] Trust-mode, managed-settings, and safe-env context injection
- [x] Manifest-based MCP runtime summary injection
- [x] Manifest-based MCP transport server summary injection
- [x] Manifest-based remote runtime summary injection
- [x] Manifest/env-based search runtime summary injection
- [x] Manifest-based account runtime summary injection
- [x] Manifest-based plan runtime summary injection
- [x] Manifest-based task runtime summary injection
Missing:
- [ ] Tokenizer-accurate context accounting
- [ ] Full tokenizer/chat-message framing parity beyond the current model-aware text token counters
- [ ] Full parity with `utils/queryContext.ts`
- [ ] Rich memory prompt loading
- [ ] Internal permission-aware memory handling
@@ -209,6 +235,19 @@ Done:
- [x] `/context-raw`
- [x] `/env`
- [x] `/mcp`
- [x] `/mcp tools`
- [x] `/mcp tool <name>`
- [x] `/search`
- [x] `/remote`
- [x] `/remotes`
- [x] `/ssh`
- [x] `/teleport`
- [x] `/direct-connect`
- [x] `/deep-link`
- [x] `/disconnect`
- [x] `/account`
- [x] `/login`
- [x] `/logout`
- [x] `/resources`
- [x] `/resource`
- [x] `/plan`
@@ -216,6 +255,7 @@ Done:
- [x] `/tasks`
- [x] `/todo`
- [x] `/task`
- [x] `/task-next`
- [x] `/prompt`
- [x] `/system-prompt`
- [x] `/permissions`
@@ -228,16 +268,18 @@ Done:
- [x] `/status`
- [x] `/session`
- [x] `/clear`
- [x] `/config`
- [x] `/settings`
Missing:
- [ ] Full npm slash-command surface
- [ ] Slash commands backed by MCP integration
- [x] Slash commands backed by MCP integration
- [ ] Slash commands tied to task/plan systems beyond the current local `/plan`, `/tasks`, and `/task` flows
- [ ] Slash commands tied to remote/background sessions
- [ ] Slash commands tied to remote/background sessions beyond the current local remote connect/disconnect and background inspection flows
- [ ] Slash commands with richer interactive behavior
- [ ] Slash commands tied to plugins and bundled skills
- [ ] Slash commands tied to settings, config, and account state
- [ ] Slash commands tied to account, settings, and auth flows beyond the current local `/account`, `/login`, `/logout`, `/config`, and `/settings` flows
## 6. Built-in Tools
@@ -250,16 +292,41 @@ Done:
- [x] `glob_search`
- [x] `grep_search`
- [x] `bash`
- [x] `web_fetch`
- [x] `search_status`
- [x] `search_list_providers`
- [x] `search_activate_provider`
- [x] `web_search`
- [x] `tool_search`
- [x] `sleep`
- [x] `account_status`
- [x] `account_list_profiles`
- [x] `account_login`
- [x] `account_logout`
- [x] `mcp_list_resources`
- [x] `mcp_read_resource`
- [x] `mcp_list_tools`
- [x] `mcp_call_tool`
- [x] `remote_status`
- [x] `remote_list_profiles`
- [x] `remote_connect`
- [x] `remote_disconnect`
- [x] `config_list`
- [x] `config_get`
- [x] `config_set`
- [x] `plan_get`
- [x] `update_plan`
- [x] `plan_clear`
- [x] `delegate_agent`
- [x] `task_next`
- [x] `task_list`
- [x] `task_get`
- [x] `task_create`
- [x] `task_update`
- [x] `task_start`
- [x] `task_complete`
- [x] `task_block`
- [x] `task_cancel`
- [x] `todo_write`
Missing:
@@ -267,11 +334,11 @@ Missing:
- [ ] Agent spawning tool parity beyond the current `delegate_agent` runtime tool
- [ ] Skill tool
- [ ] Notebook edit tool
- [ ] Web fetch tool
- [ ] Web search tool
- [ ] Web fetch parity beyond the current local text-fetch implementation
- [ ] Web search parity beyond the current provider-backed implementation
- [ ] Ask-user-question tool
- [ ] LSP tool
- [ ] Tool search tool
- [ ] Tool search parity beyond the current local registry search
- [ ] Config tool
- [ ] Team create/delete tools
- [ ] Send-message tool
@@ -279,7 +346,7 @@ Missing:
- [ ] Browser tool
- [ ] Workflow tool
- [ ] Remote trigger tool
- [ ] Sleep / cron tools
- [ ] Sleep / cron tools beyond the current local `sleep` tool
- [ ] PowerShell tool parity
- [ ] Worktree enter/exit tools
- [ ] Full `tools.ts` parity
@@ -294,13 +361,15 @@ Done:
- [x] Local todo-list replacement flow
- [x] Local persistent plan runtime with get/update/clear flows
- [x] Local plan-to-task sync flow
- [x] Local dependency-aware task execution flow with next-task selection and blocked/unblocked state
- [x] Local remote profile/runtime flow with persisted connect/disconnect state
- [x] Local background task management for agent worker sessions
Missing:
- [ ] Real implementation of the larger upstream command tree
- [ ] Task orchestration system beyond the current local plan/task sync runtime
- [ ] Planner / task execution parity beyond the current local plan persistence and sync flow
- [ ] Task orchestration system beyond the current local dependency-aware task runtime
- [ ] Planner / task execution parity beyond the current local plan persistence, sync, and next-task flow
- [ ] Team / collaboration command flows
- [ ] Command-specific session behaviors
- [ ] Full `src/commands/*` parity
@@ -338,12 +407,13 @@ Done:
- [x] Local manifest-backed MCP discovery
- [x] Local MCP resource listing and reading
- [x] MCP-backed runtime tools for local resource access
- [x] Real MCP client support over local stdio transport
- [x] MCP server integration for stdio child-process servers
- [x] MCP-backed tool listing and execution over transport
Missing:
- [ ] Real MCP client support
- [ ] MCP server integration
- [ ] Full MCP-backed tool parity beyond the current local resource list/read tools
- [ ] Full MCP-backed tool parity beyond the current stdio resource/tool list/read/call support
- [ ] Plugin discovery and loading
- [ ] Bundled plugin support
- [ ] Plugin lifecycle management
@@ -376,12 +446,15 @@ Missing:
Done:
- [x] Session save/resume on local disk
- [x] Local manifest-backed remote profile/runtime state
- [x] Local remote connect/disconnect session state
- [x] Local background agent processes
- [x] Local background attach/log/kill workflows
- [x] Local daemon-style wrapper over background agent sessions
Missing:
- [ ] Remote execution modes
- [ ] Real remote execution modes beyond the current local manifest-backed remote runtime and CLI/profile flows
- [ ] Team runtime features
- [ ] Team messaging features
- [ ] Shared remote state
@@ -410,6 +483,7 @@ Missing:
Done:
- [x] Minimal internal service layer required by the current Python runtime
- [x] Local account/auth runtime for manifest-backed profile discovery and persisted login state
Missing:
@@ -435,14 +509,19 @@ Working Python runtime today:
- [x] `src/agent_context_usage.py`
- [x] `src/agent_session.py`
- [x] `src/agent_slash_commands.py`
- [x] `src/account_runtime.py`
- [x] `src/config_runtime.py`
- [x] `src/agent_types.py`
- [x] `src/mcp_runtime.py`
- [x] `src/plan_runtime.py`
- [x] `src/plugin_runtime.py`
- [x] `src/remote_runtime.py`
- [x] `src/search_runtime.py`
- [x] `src/hook_policy.py`
- [x] `src/background_runtime.py`
- [x] `src/task.py`
- [x] `src/task_runtime.py`
- [x] `src/tokenizer_runtime.py`
- [x] `src/openai_compat.py`
- [x] `src/session_store.py`
- [x] `src/permissions.py`
@@ -464,11 +543,11 @@ Mirrored inventory / scaffold areas that still need real implementation work:
- [ ] Expand the real Python tool registry toward upstream `tools.ts`
- [ ] Replace more snapshot-backed mirrored modules with working runtime code
- [ ] Implement real MCP support
- [ ] Expand MCP parity beyond the current stdio resource/tool transport support
- [ ] Expand hooks and policy parity beyond the current manifest/runtime implementation
- [ ] Build a real interactive REPL / TUI
- [ ] Add tokenizer-accurate context accounting
- [ ] Expand background session parity beyond the current local worker/log/attach model
- [ ] Add real remote session modes
- [ ] Add real remote session transport and shared remote state beyond the current local remote-profile runtime
- [ ] Port more of the command/task system
- [ ] Close the gap between the mirrored workspace and the working runtime
+9 -1
View File
@@ -40,6 +40,8 @@
| 🆕 | **LiteLLM Proxy Support** | Route through LiteLLM Proxy to any provider |
| 🆕 | **OpenRouter Support** | Cloud API gateway — access OpenAI, Anthropic, Google models via one endpoint |
| 🆕 | **Query Engine** | Runtime event counters, transcript summaries, orchestration reports |
| 🆕 | **Remote Runtime** | Manifest-backed local remote profiles, connect/disconnect state, and remote CLI/slash flows |
| 🆕 | **Daemon Commands** | Local `daemon start/ps/logs/attach/kill` wrapper over background agent sessions |
| 🆕 | **Testing Guide** | Comprehensive [TESTING_GUIDE.md](TESTING_GUIDE.md) with commands for every feature |
| 🆕 | **Parity Checklist** | Full [PARITY_CHECKLIST.md](PARITY_CHECKLIST.md) tracking implementation status vs npm source |
@@ -72,6 +74,7 @@ Built on the public porting workspace from [instructkr/claw-code](https://github
| 🪆 **Nested Delegation** | Delegate subtasks to child agents with dependency-aware topological batching |
| 📡 **Streaming** | Token-by-token streaming output with `--stream` |
| 💬 **Slash Commands** | Local commands: `/help`, `/context`, `/tools`, `/memory`, `/status`, `/model`, and more |
| 🌐 **Remote Runtime** | Manifest-backed remote profiles with local `remote-mode`, `ssh-mode`, `teleport-mode`, and connect/disconnect state |
| 🧠 **Context Engine** | Automatic context building with CLAUDE.md discovery, compaction, and snipping |
| 🔄 **Session Persistence** | Save and resume agent sessions with file-history replay |
| 💰 **Cost & Budget Control** | Token budgets, cost limits, tool-call caps, model-call caps |
@@ -114,6 +117,8 @@ Built on the public porting workspace from [instructkr/claw-code](https://github
- [x] File history journaling with snapshot IDs and replay summaries
- [x] Nested agent delegation with dependency-aware topological batching
- [x] Agent manager with lineage tracking and group membership
- [x] Local daemon-style background command family
- [x] Local remote runtime: manifest discovery, profile listing, connect/disconnect persistence, and CLI/slash flows
- [x] Plugin runtime: manifest discovery, hooks, aliases, virtual tools, tool blocking
- [x] Plugin lifecycle hooks: resume, persist, delegate phases
- [x] Plugin session-state persistence and resume restoration
@@ -129,7 +134,7 @@ Built on the public porting workspace from [instructkr/claw-code](https://github
- [ ] Full interactive REPL / TUI behavior
- [ ] Exact tokenizer-accurate context accounting
- [ ] Hooks system parity
- [ ] Remote runtime modes (SSH, teleport, deep-link)
- [ ] Real remote transport/runtime parity beyond the current local remote-profile runtime
- [ ] Voice and VIM modes
- [ ] Editor and platform integrations
- [ ] Background and team features
@@ -164,6 +169,9 @@ claw-code/
│ ├── session_store.py # Session serialization & persistence
│ ├── transcript.py # Transcript block export & mutation tracking
│ ├── query_engine.py # Query engine facade & runtime orchestration
│ ├── remote_runtime.py # Local remote profiles, connect/disconnect state, remote CLI support
│ ├── account_runtime.py # Local account profiles, login/logout state, account CLI support
│ ├── config_runtime.py # Local workspace config/settings discovery and mutation
│ ├── permissions.py # Tool permission filtering
│ ├── cost_tracker.py # Cost & budget enforcement
│ ├── tools.py # Mirrored tool inventory
+549 -56
View File
@@ -75,12 +75,16 @@ python3 -m src.main agent "/help"
python3 -m src.main agent "/commands"
python3 -m src.main agent "/context" --cwd ..
python3 -m src.main agent "/context-raw" --cwd ..
python3 -m src.main agent "/search" --cwd ..
python3 -m src.main agent "/remote" --cwd ..
python3 -m src.main agent "/remotes" --cwd ..
python3 -m src.main agent "/plan" --cwd ..
python3 -m src.main agent "/prompt" --cwd ..
python3 -m src.main agent "/permissions" --cwd ..
python3 -m src.main agent "/hooks" --cwd ..
python3 -m src.main agent "/policy" --cwd ..
python3 -m src.main agent "/trust" --cwd ..
python3 -m src.main agent "/task-next" --cwd ..
python3 -m src.main agent "/tools" --cwd ..
python3 -m src.main agent "/memory" --cwd ..
python3 -m src.main agent "/status" --cwd ..
@@ -180,6 +184,89 @@ python3 -m src.main agent-context-raw --cwd ./test_cases
python3 -m src.main agent-prompt --cwd ./test_cases
```
### 4.9 Remote runtime context and prompt inspection
Create a local remote manifest:
```bash
cat > ./test_cases/.claw-remote.json <<'EOF'
{
"profiles": [
{
"name": "staging",
"mode": "ssh",
"target": "dev@staging",
"workspaceCwd": "/srv/app",
"sessionUrl": "wss://remote/session"
},
{
"name": "preview",
"mode": "deep-link",
"target": "preview://session"
}
]
}
EOF
```
Inspect the runtime view:
```bash
python3 -m src.main agent "/remote" --cwd ./test_cases
python3 -m src.main agent "/remotes" --cwd ./test_cases
python3 -m src.main agent-context-raw --cwd ./test_cases
python3 -m src.main agent-prompt --cwd ./test_cases
```
### 4.10 Search runtime context and prompt inspection
Create a local search manifest:
```bash
cat > ./test_cases/.claw-search.json <<'EOF'
{
"providers": [
{
"name": "local-search",
"provider": "searxng",
"baseUrl": "http://127.0.0.1:8080"
}
]
}
EOF
```
Inspect the runtime view:
```bash
python3 -m src.main agent "/search" --cwd ./test_cases
python3 -m src.main agent "/search providers" --cwd ./test_cases
python3 -m src.main agent-context-raw --cwd ./test_cases
python3 -m src.main agent-prompt --cwd ./test_cases
```
### 4.11 Tokenizer-aware context accounting
Inspect which token counter backend the runtime is using:
```bash
python3 -m src.main agent "/status" --cwd ./test_cases
python3 -m src.main agent-context --cwd ./test_cases
```
Force a local tokenizer path or model override for context accounting:
```bash
export CLAW_CODE_TOKENIZER_PATH=/path/to/local/tokenizer
# or
export CLAW_CODE_TOKENIZER_MODEL=Qwen/Qwen3-Coder-30B-A3B-Instruct
python3 -m src.main agent "/status" --cwd ./test_cases
python3 -m src.main agent-context --cwd ./test_cases
```
If no tokenizer backend is available, the runtime will fall back to a heuristic counter and `/status` will show that.
## 5. Core Agent Loop
### 5.1 Read-only run
@@ -223,6 +310,87 @@ python3 -m src.main agent-chat \
--cwd ..
```
## 6. Search Runtime And Real Web Search
### 6.1 Configure a provider with a local manifest
```bash
cat > ./test_cases/.claw-search.json <<'EOF'
{
"providers": [
{
"name": "local-search",
"provider": "searxng",
"baseUrl": "http://127.0.0.1:8080"
},
{
"name": "backup-search",
"provider": "tavily",
"apiKeyEnv": "TAVILY_API_KEY"
}
]
}
EOF
```
### 6.2 Configure a provider from environment variables
Use one of these:
```bash
export SEARXNG_BASE_URL=http://127.0.0.1:8080
export BRAVE_SEARCH_API_KEY=your-brave-key
export TAVILY_API_KEY=your-tavily-key
```
### 6.3 Inspect providers from the CLI
```bash
python3 -m src.main search-status --cwd ./test_cases
python3 -m src.main search-providers --cwd ./test_cases
python3 -m src.main search-status --cwd ./test_cases --provider local-search
python3 -m src.main search-activate local-search --cwd ./test_cases
```
### 6.4 Run a real web search from the CLI
```bash
python3 -m src.main search \
"python argparse mutually exclusive group" \
--cwd ./test_cases \
--provider local-search \
--max-results 5
```
Limit results to specific domains:
```bash
python3 -m src.main search \
"OpenAI Responses API" \
--cwd ./test_cases \
--domain openai.com \
--domain platform.openai.com
```
### 6.5 Run a real web search through slash commands
```bash
python3 -m src.main agent "/search" --cwd ./test_cases
python3 -m src.main agent "/search providers" --cwd ./test_cases
python3 -m src.main agent "/search provider local-search" --cwd ./test_cases
python3 -m src.main agent "/search use local-search" --cwd ./test_cases
python3 -m src.main agent "/search python unittest mock patch examples" --cwd ./test_cases
```
### 6.6 Run a real web search through the model tool loop
```bash
python3 -m src.main agent \
"Use web_search to find Python unittest mocking references, then summarize the top results." \
--cwd ./test_cases \
--show-transcript
```
Inside chat mode:
- type normal prompts to continue the same session
@@ -340,6 +508,15 @@ python3 -m src.main agent "/plan" --cwd ./test_cases
python3 -m src.main agent "/tasks" --cwd ./test_cases
```
### 6.9 Remote tools
```bash
python3 -m src.main agent \
"List the configured remote profiles, connect to staging, then report the active remote status." \
--cwd ./test_cases \
--show-transcript
```
## 7. Session Persistence And Resume
### 7.1 Create a saved session
@@ -417,7 +594,38 @@ python3 -m src.main agent-attach <background-id> --tail 40
python3 -m src.main agent-kill <background-id>
```
## 9. Structured Output / JSON Schema
### 8.6 Daemon-style wrappers
```bash
python3 -m src.main daemon start "/help" --cwd ./test_cases
python3 -m src.main daemon ps
python3 -m src.main daemon logs <background-id>
python3 -m src.main daemon attach <background-id>
python3 -m src.main daemon kill <background-id>
```
## 9. Remote Runtime CLI
### 9.1 Remote mode commands
```bash
python3 -m src.main remote-mode staging --cwd ./test_cases
python3 -m src.main ssh-mode staging --cwd ./test_cases
python3 -m src.main teleport-mode preview --cwd ./test_cases
python3 -m src.main direct-connect-mode direct://workspace --cwd ./test_cases
python3 -m src.main deep-link-mode preview --cwd ./test_cases
```
### 9.2 Inspect and clear remote state
```bash
python3 -m src.main remote-status --cwd ./test_cases
python3 -m src.main remote-profiles --cwd ./test_cases
python3 -m src.main remote-disconnect --cwd ./test_cases
python3 -m src.main remote-status --cwd ./test_cases
```
## 10. Structured Output / JSON Schema
Create a schema file:
@@ -446,9 +654,9 @@ python3 -m src.main agent \
--response-schema-strict
```
## 10. Budgets And Limits
## 11. Budgets And Limits
### 10.1 Total token budget
### 11.1 Total token budget
```bash
python3 -m src.main agent \
@@ -457,7 +665,7 @@ python3 -m src.main agent \
--max-total-tokens 50
```
### 10.2 Input / output token budgets
### 11.2 Input / output token budgets
```bash
python3 -m src.main agent \
@@ -467,7 +675,7 @@ python3 -m src.main agent \
--max-output-tokens 80
```
### 10.3 Reasoning-token budget
### 11.3 Reasoning-token budget
```bash
python3 -m src.main agent \
@@ -476,7 +684,7 @@ python3 -m src.main agent \
--max-reasoning-tokens 10
```
### 10.4 Tool-call budget
### 11.4 Tool-call budget
```bash
python3 -m src.main agent \
@@ -485,7 +693,7 @@ python3 -m src.main agent \
--max-tool-calls 1
```
### 10.5 Delegated-task budget
### 11.5 Delegated-task budget
```bash
python3 -m src.main agent \
@@ -494,7 +702,7 @@ python3 -m src.main agent \
--max-delegated-tasks 1
```
### 10.6 Cost budget
### 11.6 Cost budget
```bash
python3 -m src.main agent \
@@ -505,7 +713,7 @@ python3 -m src.main agent \
--max-budget-usd 0.000001
```
### 10.7 Model-call budget
### 11.7 Model-call budget
```bash
python3 -m src.main agent \
@@ -514,7 +722,7 @@ python3 -m src.main agent \
--max-model-calls 1
```
### 10.8 Session-turn budget
### 11.8 Session-turn budget
```bash
python3 -m src.main agent \
@@ -523,7 +731,7 @@ python3 -m src.main agent \
--max-session-turns 1
```
### 10.9 Budget overrides from local policy
### 11.9 Budget overrides from local policy
```bash
cat > ./test_cases/.claw-policy.json <<'EOF'
@@ -541,9 +749,9 @@ python3 -m src.main agent \
Expected result: the run stops with a model-call budget exceeded message even though you did not pass `--max-model-calls` on the CLI.
## 11. Streaming, Continuation, And Context Reduction
## 12. Streaming, Continuation, And Context Reduction
### 11.1 Streaming assistant output
### 12.1 Streaming assistant output
```bash
python3 -m src.main agent \
@@ -553,7 +761,7 @@ python3 -m src.main agent \
--show-transcript
```
### 11.2 Automatic continuation after truncation
### 12.2 Automatic continuation after truncation
Use a small output budget so the backend is more likely to stop early:
@@ -565,7 +773,7 @@ python3 -m src.main agent \
--show-transcript
```
### 11.3 Snipping older context
### 12.3 Snipping older context
```bash
python3 -m src.main agent \
@@ -576,7 +784,7 @@ python3 -m src.main agent \
--show-transcript
```
### 11.4 Compaction boundaries
### 12.4 Compaction boundaries
```bash
python3 -m src.main agent \
@@ -587,9 +795,9 @@ python3 -m src.main agent \
--show-transcript
```
## 12. File History Replay
## 13. File History Replay
### 12.1 Create file history
### 13.1 Create file history
```bash
python3 -m src.main agent \
@@ -598,7 +806,7 @@ python3 -m src.main agent \
--allow-write
```
### 12.2 Resume and inspect replay
### 13.2 Resume and inspect replay
```bash
python3 -m src.main agent-resume \
@@ -610,9 +818,9 @@ python3 -m src.main agent-resume \
Look for `file_history_replay` messages in the transcript.
## 13. Nested Delegation
## 14. Nested Delegation
### 13.1 Basic delegated subtask
### 14.1 Basic delegated subtask
```bash
python3 -m src.main agent \
@@ -621,7 +829,7 @@ python3 -m src.main agent \
--show-transcript
```
### 13.2 Multiple delegated subtasks
### 14.2 Multiple delegated subtasks
```bash
python3 -m src.main agent \
@@ -630,7 +838,7 @@ python3 -m src.main agent \
--show-transcript
```
### 13.3 Resume a delegated child session
### 14.3 Resume a delegated child session
1. Seed a normal saved session:
@@ -649,7 +857,7 @@ python3 -m src.main agent \
--show-transcript
```
### 13.4 Topological dependency batches
### 14.4 Topological dependency batches
```bash
python3 -m src.main agent \
@@ -664,7 +872,7 @@ Look for:
- `delegate_group_result`
- `batch_index=...`
## 14. Plugin Runtime
## 15. Plugin Runtime
Create a local plugin manifest:
@@ -705,14 +913,14 @@ cat > ./test_cases/plugins/demo/plugin.json <<'EOF'
EOF
```
### 14.1 Plugin prompt/context discovery
### 15.1 Plugin prompt/context discovery
```bash
python3 -m src.main agent-prompt --cwd ./test_cases
python3 -m src.main agent-context-raw --cwd ./test_cases
```
### 14.2 Plugin alias tool
### 15.2 Plugin alias tool
```bash
echo "hello plugin" > ./test_cases/hello.txt
@@ -722,7 +930,7 @@ python3 -m src.main agent \
--show-transcript
```
### 14.3 Plugin virtual tool
### 15.3 Plugin virtual tool
```bash
python3 -m src.main agent \
@@ -731,7 +939,7 @@ python3 -m src.main agent \
--show-transcript
```
### 14.4 Plugin before/after tool guidance
### 15.4 Plugin before/after tool guidance
```bash
python3 -m src.main agent \
@@ -740,7 +948,7 @@ python3 -m src.main agent \
--show-transcript
```
### 14.5 Plugin lifecycle with resume/persist
### 15.5 Plugin lifecycle with resume/persist
1. Start a session:
@@ -765,7 +973,7 @@ Look for:
- `Plugin resume hooks:`
- `Plugin runtime state:`
## 15. MCP Runtime
## 16. MCP Runtime
Create a local MCP manifest:
@@ -796,7 +1004,7 @@ cat > ./test_cases_mcp/.claw-mcp.json <<'EOF'
EOF
```
### 15.1 MCP context and slash commands
### 16.1 MCP context and slash commands
```bash
python3 -m src.main agent "/mcp" --cwd ./test_cases_mcp
@@ -807,7 +1015,7 @@ python3 -m src.main agent-context-raw --cwd ./test_cases_mcp
python3 -m src.main agent-prompt --cwd ./test_cases_mcp
```
### 15.2 MCP tools through the model loop
### 16.2 MCP tools through the model loop
```bash
python3 -m src.main agent \
@@ -816,7 +1024,7 @@ python3 -m src.main agent \
--show-transcript
```
### 15.3 Read inline MCP resources
### 16.3 Read inline MCP resources
```bash
python3 -m src.main agent \
@@ -825,7 +1033,161 @@ python3 -m src.main agent \
--show-transcript
```
## 16. Task Runtime
### 16.4 Real stdio MCP server transport
Create a simple stdio MCP server:
```bash
cat > ./test_cases_mcp/fake_stdio_mcp.py <<'EOF'
import json
import sys
RESOURCES = [
{
"uri": "mcp://remote/notes",
"name": "Remote Notes",
"mimeType": "text/plain",
}
]
TOOLS = [
{
"name": "echo",
"description": "Echo text",
"inputSchema": {
"type": "object",
"properties": {
"text": {"type": "string"}
}
}
}
]
for raw in sys.stdin:
raw = raw.strip()
if not raw:
continue
message = json.loads(raw)
method = message.get("method")
if method == "initialize":
print(json.dumps({
"jsonrpc": "2.0",
"id": message["id"],
"result": {
"protocolVersion": "2025-11-25",
"capabilities": {"resources": {}, "tools": {}},
"serverInfo": {"name": "fake-remote", "version": "1.0.0"}
}
}), flush=True)
continue
if method == "notifications/initialized":
continue
if method == "resources/list":
print(json.dumps({
"jsonrpc": "2.0",
"id": message["id"],
"result": {"resources": RESOURCES}
}), flush=True)
continue
if method == "resources/read":
uri = message.get("params", {}).get("uri")
print(json.dumps({
"jsonrpc": "2.0",
"id": message["id"],
"result": {
"contents": [
{
"uri": uri,
"mimeType": "text/plain",
"text": "remote notes via stdio"
}
]
}
}), flush=True)
continue
if method == "tools/list":
print(json.dumps({
"jsonrpc": "2.0",
"id": message["id"],
"result": {"tools": TOOLS}
}), flush=True)
continue
if method == "tools/call":
text = message.get("params", {}).get("arguments", {}).get("text", "")
print(json.dumps({
"jsonrpc": "2.0",
"id": message["id"],
"result": {
"content": [{"type": "text", "text": "echo:" + text}],
"isError": False
}
}), flush=True)
continue
EOF
```
Add the stdio server to the MCP manifest:
```bash
cat > ./test_cases_mcp/.claw-mcp.json <<'EOF'
{
"servers": [
{
"name": "workspace",
"resources": [
{
"uri": "mcp://workspace/notes",
"name": "Notes",
"path": "notes.txt",
"mimeType": "text/plain"
},
{
"uri": "mcp://workspace/inline",
"name": "Inline",
"text": "inline body"
}
]
}
],
"mcpServers": {
"remote": {
"command": "python3",
"args": ["-u", "./fake_stdio_mcp.py"]
}
}
}
EOF
```
### 16.5 MCP transport CLI commands
```bash
python3 -m src.main mcp-status --cwd ./test_cases_mcp
python3 -m src.main mcp-resources --cwd ./test_cases_mcp
python3 -m src.main mcp-resource mcp://remote/notes --cwd ./test_cases_mcp
python3 -m src.main mcp-tools --cwd ./test_cases_mcp
python3 -m src.main mcp-call-tool echo --arguments-json '{"text":"hello"}' --cwd ./test_cases_mcp
```
### 16.6 MCP transport slash commands
```bash
python3 -m src.main agent "/mcp" --cwd ./test_cases_mcp
python3 -m src.main agent "/mcp tools" --cwd ./test_cases_mcp
python3 -m src.main agent "/mcp tool echo" --cwd ./test_cases_mcp
python3 -m src.main agent "/resources" --cwd ./test_cases_mcp
python3 -m src.main agent "/resource mcp://remote/notes" --cwd ./test_cases_mcp
```
### 16.7 MCP transport tools through the model loop
```bash
python3 -m src.main agent \
"List the available MCP tools, call the remote echo tool with text=hello, then summarize the result." \
--cwd ./test_cases_mcp \
--show-transcript
```
## 17. Task Runtime
Create a clean task workspace:
@@ -834,7 +1196,7 @@ mkdir -p ./test_cases_tasks
rm -rf ./test_cases_tasks/.port_sessions
```
### 16.1 Task slash commands
### 17.1 Task slash commands
```bash
python3 -m src.main agent "/tasks" --cwd ./test_cases_tasks
@@ -844,7 +1206,7 @@ python3 -m src.main agent-context-raw --cwd ./test_cases_tasks
python3 -m src.main agent-prompt --cwd ./test_cases_tasks
```
### 16.2 Create and update tasks through the model loop
### 17.2 Create and update tasks through the model loop
```bash
python3 -m src.main agent \
@@ -860,7 +1222,7 @@ Then inspect the stored task file:
cat ./test_cases_tasks/.port_sessions/task_runtime.json
```
### 16.3 Replace the todo list
### 17.3 Replace the todo list
```bash
python3 -m src.main agent \
@@ -870,17 +1232,18 @@ python3 -m src.main agent \
--show-transcript
```
### 16.4 Read back task state
### 17.4 Read back task state
```bash
python3 -m src.main agent "/tasks" --cwd ./test_cases_tasks
python3 -m src.main agent "/task-next" --cwd ./test_cases_tasks
python3 -m src.main agent \
"List the current tasks and show me the id of each one." \
--cwd ./test_cases_tasks \
--show-transcript
```
### 16.5 Plan runtime and task sync
### 17.5 Plan runtime and task sync
```bash
python3 -m src.main agent \
@@ -894,9 +1257,49 @@ python3 -m src.main agent "/tasks" --cwd ./test_cases_tasks
cat ./test_cases_tasks/.port_sessions/plan_runtime.json
```
## 17. Query Engine And Workspace Commands
### 17.6 Dependency-aware task execution
### 17.1 Workspace inventory
Create a blocked task graph through the model loop:
```bash
python3 -m src.main agent \
"Use todo_write to create two tasks: scan with status pending, and patch with status blocked and blocked_by scan. Then show the next actionable tasks." \
--cwd ./test_cases_tasks \
--allow-write \
--show-transcript
```
Then advance the task state:
```bash
python3 -m src.main agent \
"Mark task scan as completed, then show the next actionable tasks and start task patch with active_form 'Patching files'." \
--cwd ./test_cases_tasks \
--allow-write \
--show-transcript
```
Inspect the resulting task state:
```bash
python3 -m src.main agent "/task-next" --cwd ./test_cases_tasks
python3 -m src.main agent "/tasks" --cwd ./test_cases_tasks
cat ./test_cases_tasks/.port_sessions/task_runtime.json
```
### 17.7 Task execution tools directly
```bash
python3 -m src.main agent \
"Use todo_write to create task scan and task patch where patch is blocked_by scan. Then use task_next, task_complete for scan, and task_start for patch." \
--cwd ./test_cases_tasks \
--allow-write \
--show-transcript
```
## 18. Query Engine And Workspace Commands
### 18.1 Workspace inventory
```bash
python3 -m src.main summary
@@ -906,7 +1309,7 @@ python3 -m src.main commands --limit 20
python3 -m src.main tools --limit 20
```
### 17.2 Query routing and bootstrap reports
### 18.2 Query routing and bootstrap reports
```bash
python3 -m src.main route "inspect the runtime and tools" --limit 10
@@ -914,25 +1317,13 @@ python3 -m src.main bootstrap "inspect the runtime and tools" --limit 10
python3 -m src.main turn-loop "inspect the runtime and tools" --limit 5 --max-turns 3
```
### 17.3 Session flushing for the mirrored workspace
### 18.3 Session flushing for the mirrored workspace
```bash
python3 -m src.main flush-transcript "store a temporary transcript"
python3 -m src.main load-session <session-id>
```
## 18. Remote/Direct Mode Simulations
These are mirrored workspace simulation commands, not the real agent runtime:
```bash
python3 -m src.main remote-mode demo-target
python3 -m src.main ssh-mode demo-target
python3 -m src.main teleport-mode demo-target
python3 -m src.main direct-connect-mode demo-target
python3 -m src.main deep-link-mode demo-target
```
## 19. Parity Tracking Workflow
Use this every time a new feature lands:
@@ -950,3 +1341,105 @@ Rule for future work:
- every new implemented feature should add a checked item in `PARITY_CHECKLIST.md`
- every user-testable feature should add at least one concrete command example in `TESTING_GUIDE.md`
## 20. Config Runtime
Inspect local config state:
```bash
python3 -m src.main config-status --cwd ./test_cases
python3 -m src.main config-effective --cwd ./test_cases
```
Read a value or a source directly:
```bash
python3 -m src.main config-get review.mode --cwd ./test_cases
python3 -m src.main config-source project --cwd ./test_cases
```
Write a value into the local settings layer:
```bash
python3 -m src.main config-set review.mode '"strict"' --cwd ./test_cases
python3 -m src.main config-set review.enabled true --cwd ./test_cases
```
Test the slash commands:
```bash
python3 -m src.main agent "/config" --cwd ./test_cases
python3 -m src.main agent "/config effective" --cwd ./test_cases
python3 -m src.main agent "/config get review.mode" --cwd ./test_cases
python3 -m src.main agent "/settings source local" --cwd ./test_cases
```
Test the config tools through the real agent loop:
```bash
python3 -m src.main agent \
"List the current config keys, set review.mode to strict in local config, then read it back." \
--cwd ./test_cases \
--allow-write
```
## 21. Account Runtime
Inspect local account state:
```bash
python3 -m src.main account-status --cwd ./test_cases
python3 -m src.main account-profiles --cwd ./test_cases
```
Activate and clear a local account session:
```bash
python3 -m src.main account-login local --cwd ./test_cases
python3 -m src.main account-logout --cwd ./test_cases
```
Test the slash commands:
```bash
python3 -m src.main agent "/account" --cwd ./test_cases
python3 -m src.main agent "/account profiles" --cwd ./test_cases
python3 -m src.main agent "/login local" --cwd ./test_cases
python3 -m src.main agent "/logout" --cwd ./test_cases
```
Test the account tools through the real agent loop:
```bash
python3 -m src.main agent \
"List the configured account profiles, activate the local profile, then report the active account session." \
--cwd ./test_cases
```
## 22. Extended Tool Slice
Test the new tool-surface additions through the real agent loop:
```bash
python3 -m src.main agent \
"Use tool_search to find file-related tools and summarize the best ones for reading and editing files." \
--cwd ./test_cases
```
```bash
python3 -m src.main agent \
"Use web_fetch on file://$(pwd)/README.md and summarize the first section." \
--cwd .
```
```bash
python3 -m src.main agent \
"Call the sleep tool for 0.1 seconds, then tell me it completed." \
--cwd ./test_cases
```
Run the direct unit tests for this slice:
```bash
python3 -m unittest tests.test_extended_tools -v
```
+1
View File
@@ -0,0 +1 @@
"""Claw Code Agent local benchmark suite."""
+352
View File
@@ -0,0 +1,352 @@
#!/usr/bin/env python3
"""
Local benchmark runner for claw-code-agent.
Runs the REAL agent binary against a suite of coding tasks and scores
pass/fail automatically. No Docker required.
Usage:
# Run all tasks
python3 -m benchmarks.run
# Run a single task
python3 -m benchmarks.run --task file-create-basic
# Run a category
python3 -m benchmarks.run --category bugfix
# Run a difficulty level
python3 -m benchmarks.run --difficulty easy
# List available tasks
python3 -m benchmarks.run --list
# Verbose output
python3 -m benchmarks.run --verbose
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from benchmarks.tasks.definitions import TASKS, BenchmarkTask, get_task
# ---------------------------------------------------------------------------
# Result tracking
# ---------------------------------------------------------------------------
@dataclass
class TaskResult:
task_id: str
category: str
difficulty: str
passed: bool
duration_sec: float
agent_exit_code: int
verify_exit_code: int
error: str = ""
# ---------------------------------------------------------------------------
# Task execution
# ---------------------------------------------------------------------------
def _run_shell(cmd: str, cwd: str, timeout: float = 30.0) -> tuple[int, str]:
"""Run a shell command, return (exit_code, combined_output)."""
try:
proc = subprocess.run(
cmd,
shell=True,
cwd=cwd,
capture_output=True,
text=True,
timeout=timeout,
)
output = (proc.stdout + proc.stderr).strip()
return proc.returncode, output
except subprocess.TimeoutExpired:
return 1, f"[TIMEOUT after {timeout}s]"
except Exception as exc:
return 1, str(exc)
def run_task(
task: BenchmarkTask,
*,
project_root: str,
agent_timeout: float = 300.0,
verbose: bool = False,
) -> TaskResult:
"""Run a single benchmark task end-to-end."""
# Create isolated temp workspace
workspace = tempfile.mkdtemp(prefix=f"claw_bench_{task.id}_")
if verbose:
print(f" workspace: {workspace}")
try:
# --- SETUP ---
if task.setup:
code, out = _run_shell(task.setup, cwd=workspace)
if code != 0:
return TaskResult(
task_id=task.id,
category=task.category,
difficulty=task.difficulty,
passed=False,
duration_sec=0.0,
agent_exit_code=-1,
verify_exit_code=-1,
error=f"Setup failed: {out}",
)
# --- RUN AGENT ---
agent_cmd = (
f"{sys.executable} -m src.main agent "
f"{_shell_quote(task.instruction)} "
f"--cwd {_shell_quote(workspace)} "
f"--allow-write "
f"--allow-shell"
)
if verbose:
print(f" agent cmd: {agent_cmd[:120]}...")
start = time.time()
agent_code, agent_out = _run_shell(
agent_cmd,
cwd=project_root,
timeout=agent_timeout,
)
duration = time.time() - start
if verbose:
print(f" agent exit={agent_code} duration={duration:.1f}s")
if agent_out:
# Print last few lines of agent output
lines = agent_out.split("\n")
for line in lines[-5:]:
print(f" > {line}")
# --- VERIFY ---
verify_code, verify_out = _run_shell(task.verify, cwd=workspace, timeout=30.0)
if verbose:
status = "PASS" if verify_code == 0 else "FAIL"
print(f" verify exit={verify_code} -> {status}")
if verify_code != 0 and verify_out:
print(f" verify output: {verify_out[:200]}")
return TaskResult(
task_id=task.id,
category=task.category,
difficulty=task.difficulty,
passed=(verify_code == 0),
duration_sec=duration,
agent_exit_code=agent_code,
verify_exit_code=verify_code,
error=verify_out if verify_code != 0 else "",
)
finally:
# Clean up workspace
shutil.rmtree(workspace, ignore_errors=True)
def _shell_quote(s: str) -> str:
"""Quote a string for shell use."""
import shlex
return shlex.quote(s)
# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------
def print_results(results: list[TaskResult]) -> None:
"""Print a formatted results table."""
total = len(results)
passed = sum(1 for r in results if r.passed)
failed = total - passed
print()
print("=" * 72)
print(" CLAW CODE AGENT — BENCHMARK RESULTS")
print("=" * 72)
print()
print(f" {'Task ID':<30} {'Category':<12} {'Diff':<8} {'Result':<8} {'Time':>6}")
print(f" {'' * 30} {'' * 12} {'' * 8} {'' * 8} {'' * 6}")
for r in results:
status = "PASS" if r.passed else "FAIL"
icon = "" if r.passed else ""
time_str = f"{r.duration_sec:.1f}s"
print(f"{icon} {r.task_id:<30} {r.category:<12} {r.difficulty:<8} {status:<8} {time_str:>6}")
print()
print("" * 72)
print(f" Total: {total} | Passed: {passed} | Failed: {failed} | Score: {passed}/{total} ({100*passed/total:.0f}%)")
print("" * 72)
# Breakdown by category
categories: dict[str, list[TaskResult]] = {}
for r in results:
categories.setdefault(r.category, []).append(r)
print()
print(" Category Breakdown:")
for cat, cat_results in sorted(categories.items()):
cat_passed = sum(1 for r in cat_results if r.passed)
cat_total = len(cat_results)
bar = "" * cat_passed + "" * (cat_total - cat_passed)
print(f" {cat:<14} {bar} {cat_passed}/{cat_total}")
# Breakdown by difficulty
difficulties: dict[str, list[TaskResult]] = {}
for r in results:
difficulties.setdefault(r.difficulty, []).append(r)
print()
print(" Difficulty Breakdown:")
for diff in ("easy", "medium", "hard"):
if diff in difficulties:
diff_results = difficulties[diff]
diff_passed = sum(1 for r in diff_results if r.passed)
diff_total = len(diff_results)
print(f" {diff:<14} {diff_passed}/{diff_total} ({100*diff_passed/diff_total:.0f}%)")
total_time = sum(r.duration_sec for r in results)
print()
print(f" Total time: {total_time:.1f}s")
print()
def save_results(results: list[TaskResult], output_path: str) -> None:
"""Save results to JSON."""
data = {
"benchmark": "claw-code-agent-local",
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
"model": os.environ.get("OPENAI_MODEL", "unknown"),
"total": len(results),
"passed": sum(1 for r in results if r.passed),
"score_pct": round(100 * sum(1 for r in results if r.passed) / len(results), 1) if results else 0,
"results": [
{
"task_id": r.task_id,
"category": r.category,
"difficulty": r.difficulty,
"passed": r.passed,
"duration_sec": round(r.duration_sec, 2),
"agent_exit_code": r.agent_exit_code,
"verify_exit_code": r.verify_exit_code,
"error": r.error,
}
for r in results
],
}
with open(output_path, "w") as f:
json.dump(data, f, indent=2)
print(f" Results saved to {output_path}")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(description="Claw Code Agent local benchmark")
parser.add_argument("--task", help="Run a single task by ID")
parser.add_argument("--category", help="Run tasks in a category")
parser.add_argument("--difficulty", choices=["easy", "medium", "hard"], help="Run tasks by difficulty")
parser.add_argument("--list", action="store_true", help="List available tasks")
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
parser.add_argument("--timeout", type=float, default=300.0, help="Agent timeout per task in seconds (default: 300)")
parser.add_argument("--output", "-o", help="Save results to JSON file")
args = parser.parse_args()
if args.list:
print(f"\n {'ID':<30} {'Category':<12} {'Difficulty':<10}")
print(f" {'' * 30} {'' * 12} {'' * 10}")
for t in TASKS:
print(f" {t.id:<30} {t.category:<12} {t.difficulty:<10}")
print(f"\n Total: {len(TASKS)} tasks\n")
return
# Select tasks
tasks_to_run: list[BenchmarkTask] = []
if args.task:
t = get_task(args.task)
if t is None:
print(f"Unknown task: {args.task}")
print("Use --list to see available tasks")
sys.exit(1)
tasks_to_run = [t]
else:
tasks_to_run = list(TASKS)
if args.category:
tasks_to_run = [t for t in tasks_to_run if t.category == args.category]
if args.difficulty:
tasks_to_run = [t for t in tasks_to_run if t.difficulty == args.difficulty]
if not tasks_to_run:
print("No tasks matched the filters.")
sys.exit(1)
# Find project root
project_root = str(Path(__file__).resolve().parent.parent)
# Check environment
model = os.environ.get("OPENAI_MODEL", "not set")
base_url = os.environ.get("OPENAI_BASE_URL", "not set")
print()
print("=" * 72)
print(" CLAW CODE AGENT — LOCAL BENCHMARK")
print("=" * 72)
print(f" Model: {model}")
print(f" Base URL: {base_url}")
print(f" Tasks: {len(tasks_to_run)}")
print(f" Timeout: {args.timeout}s per task")
print("=" * 72)
print()
# Run tasks
results: list[TaskResult] = []
for i, task in enumerate(tasks_to_run, 1):
print(f"[{i}/{len(tasks_to_run)}] {task.id} ({task.category}, {task.difficulty})")
result = run_task(
task,
project_root=project_root,
agent_timeout=args.timeout,
verbose=args.verbose,
)
results.append(result)
status = "PASS ✅" if result.passed else "FAIL ❌"
print(f" -> {status} ({result.duration_sec:.1f}s)")
print()
# Report
print_results(results)
if args.output:
save_results(results, args.output)
if __name__ == "__main__":
main()
View File
+654
View File
@@ -0,0 +1,654 @@
"""
Benchmark task definitions for claw-code-agent.
Each task has:
- id: unique identifier
- category: what skill is being tested
- difficulty: easy / medium / hard
- instruction: what the agent is told to do
- setup: shell commands to prepare the workspace (run BEFORE the agent)
- verify: shell commands that return exit 0 on success (run AFTER the agent)
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class BenchmarkTask:
id: str
category: str
difficulty: str
instruction: str
setup: str
verify: str
TASKS: tuple[BenchmarkTask, ...] = (
# ------------------------------------------------------------------
# 1. FILE CREATION
# ------------------------------------------------------------------
BenchmarkTask(
id="file-create-basic",
category="file-ops",
difficulty="easy",
instruction="Create a file called hello.txt containing exactly the text: Hello, World!",
setup="",
verify='[ -f hello.txt ] && grep -qx "Hello, World!" hello.txt',
),
BenchmarkTask(
id="file-create-nested",
category="file-ops",
difficulty="easy",
instruction="Create the directory structure src/utils/ and inside it create a file called helpers.py containing a Python function called greet that takes a name parameter and returns the string 'Hello, <name>!'.",
setup="",
verify=(
'[ -f src/utils/helpers.py ] && '
'python3 -c "from src.utils.helpers import greet; assert greet(\'World\') == \'Hello, World!\'"'
),
),
# ------------------------------------------------------------------
# 2. FILE EDITING
# ------------------------------------------------------------------
BenchmarkTask(
id="edit-replace-string",
category="file-edit",
difficulty="easy",
instruction="In the file config.txt, replace every occurrence of 'localhost' with '0.0.0.0'.",
setup=(
'echo "host=localhost\nport=8080\ndb_host=localhost\nbackup=localhost:3000" > config.txt'
),
verify=(
'! grep -q "localhost" config.txt && '
'grep -q "0.0.0.0" config.txt && '
'[ "$(grep -c "0.0.0.0" config.txt)" = "3" ]'
),
),
BenchmarkTask(
id="edit-add-function",
category="file-edit",
difficulty="medium",
instruction="The file math_utils.py has an add function. Add a new function called multiply that takes two arguments a and b and returns a * b. Do not change the existing add function.",
setup=(
'cat > math_utils.py << \'PYEOF\'\n'
'def add(a, b):\n'
' return a + b\n'
'PYEOF'
),
verify=(
'python3 -c "'
'from math_utils import add, multiply; '
'assert add(2, 3) == 5; '
'assert multiply(4, 5) == 20; '
'assert multiply(0, 10) == 0; '
'assert multiply(-2, 3) == -6'
'"'
),
),
# ------------------------------------------------------------------
# 3. BUG FIXING
# ------------------------------------------------------------------
BenchmarkTask(
id="bugfix-off-by-one",
category="bugfix",
difficulty="medium",
instruction="The file fibonacci.py has a function that should return the first n Fibonacci numbers as a list. For example, fibonacci(5) should return [0, 1, 1, 2, 3]. But it has a bug. Find and fix it.",
setup=(
'cat > fibonacci.py << \'PYEOF\'\n'
'def fibonacci(n):\n'
' if n <= 0:\n'
' return []\n'
' if n == 1:\n'
' return [0]\n'
' fibs = [0, 1]\n'
' for i in range(2, n + 1):\n'
' fibs.append(fibs[i-1] + fibs[i-2])\n'
' return fibs\n'
'PYEOF'
),
verify=(
'python3 -c "'
'from fibonacci import fibonacci; '
'assert fibonacci(0) == []; '
'assert fibonacci(1) == [0]; '
'assert fibonacci(2) == [0, 1]; '
'assert fibonacci(5) == [0, 1, 1, 2, 3]; '
'assert fibonacci(8) == [0, 1, 1, 2, 3, 5, 8, 13]'
'"'
),
),
BenchmarkTask(
id="bugfix-syntax-error",
category="bugfix",
difficulty="easy",
instruction="The file broken.py has syntax errors that prevent it from running. Fix all syntax errors so that running 'python3 broken.py' prints 'All tests passed'.",
setup=(
'cat > broken.py << \'PYEOF\'\n'
'def calculate(x, y)\n'
' result = x + y\n'
' return result\n'
'\n'
'def main():\n'
' total = calculate(10, 20\n'
' if total == 30:\n'
' print("All tests passed")\n'
' else\n'
' print("Failed")\n'
'\n'
'if __name__ == "__main__":\n'
' main()\n'
'PYEOF'
),
verify='python3 broken.py 2>&1 | grep -qx "All tests passed"',
),
BenchmarkTask(
id="bugfix-logic-error",
category="bugfix",
difficulty="medium",
instruction="The file sorter.py has a function called bubble_sort that should sort a list in ascending order, but it produces wrong results. Find the bug and fix it. Do not replace the algorithm with a different one — fix the existing bubble sort logic.",
setup=(
'cat > sorter.py << \'PYEOF\'\n'
'def bubble_sort(arr):\n'
' n = len(arr)\n'
' for i in range(n):\n'
' for j in range(0, n - 1):\n'
' if arr[j] > arr[j + 1]:\n'
' arr[j] = arr[j + 1]\n'
' arr[j + 1] = arr[j]\n'
' return arr\n'
'PYEOF'
),
verify=(
'python3 -c "'
'from sorter import bubble_sort; '
'assert bubble_sort([3,1,2]) == [1,2,3]; '
'assert bubble_sort([5,4,3,2,1]) == [1,2,3,4,5]; '
'assert bubble_sort([]) == []; '
'assert bubble_sort([1]) == [1]; '
'assert bubble_sort([2,2,1]) == [1,2,2]'
'"'
),
),
# ------------------------------------------------------------------
# 4. CODE GENERATION
# ------------------------------------------------------------------
BenchmarkTask(
id="codegen-csv-parser",
category="codegen",
difficulty="medium",
instruction=(
"Create a file called csv_parser.py with a function called parse_csv that takes a "
"filename (string) and returns a list of dictionaries. The first row of the CSV is "
"the header. Each subsequent row becomes a dict mapping header names to values. "
"Use only the Python standard library."
),
setup=(
'cat > data.csv << \'CSVEOF\'\n'
'name,age,city\n'
'Alice,30,NYC\n'
'Bob,25,LA\n'
'CSVEOF'
),
verify=(
'python3 -c "'
'from csv_parser import parse_csv; '
'rows = parse_csv(\"data.csv\"); '
'assert len(rows) == 2; '
'assert rows[0][\"name\"] == \"Alice\"; '
'assert rows[0][\"age\"] == \"30\"; '
'assert rows[0][\"city\"] == \"NYC\"; '
'assert rows[1][\"name\"] == \"Bob\"'
'"'
),
),
BenchmarkTask(
id="codegen-stack",
category="codegen",
difficulty="medium",
instruction=(
"Create a file called stack.py with a class called Stack. "
"It should support: push(item), pop() which returns the item (raises IndexError if empty), "
"peek() which returns the top item without removing it (raises IndexError if empty), "
"is_empty() which returns True/False, and size() which returns the count."
),
setup="",
verify=(
'python3 -c "'
'from stack import Stack; '
's = Stack(); '
'assert s.is_empty() == True; '
'assert s.size() == 0; '
's.push(10); s.push(20); s.push(30); '
'assert s.size() == 3; '
'assert s.peek() == 30; '
'assert s.pop() == 30; '
'assert s.pop() == 20; '
'assert s.size() == 1; '
'assert s.is_empty() == False; '
's.pop(); '
'try:\n'
' s.pop()\n'
' assert False\n'
'except IndexError:\n'
' pass; '
'try:\n'
' s.peek()\n'
' assert False\n'
'except IndexError:\n'
' pass'
'"'
),
),
BenchmarkTask(
id="codegen-rest-api",
category="codegen",
difficulty="hard",
instruction=(
"Create a file called todo_api.py that implements a minimal TODO API using only "
"the Python standard library (http.server). It should handle:\n"
" GET /todos -> return JSON list of all todos\n"
" POST /todos -> create a todo from JSON body {\"title\": \"...\"}, return it with an auto-increment id and done=false\n"
" GET /todos/<id> -> return a single todo by id, or 404\n"
"Todos are stored in memory (no database). Each todo has: id (int), title (str), done (bool)."
),
setup="",
verify=(
'python3 << \'TESTEOF\'\n'
'import subprocess, time, json, urllib.request, urllib.error, sys, signal, os\n'
'proc = subprocess.Popen([sys.executable, "todo_api.py"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)\n'
'time.sleep(2)\n'
'try:\n'
' # GET empty\n'
' r = urllib.request.urlopen("http://127.0.0.1:8080/todos")\n'
' assert json.loads(r.read()) == [], "GET /todos should be empty"\n'
' # POST\n'
' data = json.dumps({"title": "Buy milk"}).encode()\n'
' req = urllib.request.Request("http://127.0.0.1:8080/todos", data=data, headers={"Content-Type": "application/json"}, method="POST")\n'
' r = urllib.request.urlopen(req)\n'
' todo = json.loads(r.read())\n'
' assert todo["id"] == 1\n'
' assert todo["title"] == "Buy milk"\n'
' assert todo["done"] == False\n'
' # GET by id\n'
' r = urllib.request.urlopen("http://127.0.0.1:8080/todos/1")\n'
' assert json.loads(r.read())["title"] == "Buy milk"\n'
' # 404\n'
' try:\n'
' urllib.request.urlopen("http://127.0.0.1:8080/todos/999")\n'
' assert False\n'
' except urllib.error.HTTPError as e:\n'
' assert e.code == 404\n'
' # GET all\n'
' r = urllib.request.urlopen("http://127.0.0.1:8080/todos")\n'
' assert len(json.loads(r.read())) == 1\n'
' print("ALL_TESTS_PASSED")\n'
'finally:\n'
' proc.terminate()\n'
' proc.wait()\n'
'TESTEOF\n'
'[ $? -eq 0 ]'
),
),
# ------------------------------------------------------------------
# 5. SHELL / SYSTEM TASKS
# ------------------------------------------------------------------
BenchmarkTask(
id="shell-find-largest",
category="shell",
difficulty="easy",
instruction=(
"There are several .txt files in the workspace. Find which .txt file has the most lines "
"and write its filename (just the name, e.g. 'data3.txt') into a file called answer.txt."
),
setup=(
'for i in 1 2 3 4 5; do\n'
' head -c $((i * 50)) /dev/urandom | base64 | head -n $((i * 3)) > "data${i}.txt"\n'
'done'
),
verify=(
'EXPECTED=$(wc -l data*.txt | sort -n | tail -2 | head -1 | awk \'{print $2}\') && '
'[ -f answer.txt ] && '
'ANSWER=$(cat answer.txt | tr -d "[:space:]") && '
'[ "$ANSWER" = "$EXPECTED" ]'
),
),
BenchmarkTask(
id="shell-count-python-funcs",
category="shell",
difficulty="medium",
instruction=(
"Count the total number of Python function definitions (lines starting with 'def ') "
"across ALL .py files in the project/ directory (recursively). Write just the number "
"into a file called answer.txt."
),
setup=(
'mkdir -p project/sub\n'
'cat > project/a.py << \'PY\'\n'
'def foo():\n'
' pass\n'
'def bar():\n'
' pass\n'
'PY\n'
'cat > project/b.py << \'PY\'\n'
'def baz():\n'
' pass\n'
'PY\n'
'cat > project/sub/c.py << \'PY\'\n'
'def one():\n'
' pass\n'
'def two():\n'
' pass\n'
'def three():\n'
' pass\n'
'PY'
),
verify=(
'[ -f answer.txt ] && '
'ANSWER=$(cat answer.txt | tr -d "[:space:]") && '
'[ "$ANSWER" = "6" ]'
),
),
# ------------------------------------------------------------------
# 6. REFACTORING
# ------------------------------------------------------------------
BenchmarkTask(
id="refactor-extract-function",
category="refactor",
difficulty="medium",
instruction=(
"The file process.py has duplicated logic for validating emails in two places. "
"Extract the common validation logic into a single function called is_valid_email "
"and make both register_user and update_email call it. All existing behavior must "
"remain the same."
),
setup=(
'cat > process.py << \'PYEOF\'\n'
'def register_user(name, email):\n'
' if "@" not in email or "." not in email.split("@")[-1]:\n'
' return {"error": "invalid email"}\n'
' return {"name": name, "email": email, "status": "registered"}\n'
'\n'
'def update_email(user, new_email):\n'
' if "@" not in new_email or "." not in new_email.split("@")[-1]:\n'
' return {"error": "invalid email"}\n'
' user["email"] = new_email\n'
' return user\n'
'PYEOF'
),
verify=(
'python3 -c "'
'from process import register_user, update_email, is_valid_email; '
'assert is_valid_email(\"test@example.com\") == True; '
'assert is_valid_email(\"bad\") == False; '
'assert is_valid_email(\"no@dot\") == False; '
'r = register_user(\"Alice\", \"a@b.c\"); assert r[\"status\"] == \"registered\"; '
'r = register_user(\"Bob\", \"bad\"); assert r[\"error\"] == \"invalid email\"; '
'u = {\"name\": \"X\", \"email\": \"old@o.com\"}; '
'r = update_email(u, \"new@n.com\"); assert r[\"email\"] == \"new@n.com\"; '
'r = update_email(u, \"bad\"); assert r[\"error\"] == \"invalid email\"'
'"'
),
),
# ------------------------------------------------------------------
# 7. TESTING / TEST WRITING
# ------------------------------------------------------------------
BenchmarkTask(
id="testgen-write-tests",
category="testing",
difficulty="hard",
instruction=(
"The file calculator.py has a Calculator class with add, subtract, multiply, and "
"divide methods. Write a test file called test_calculator.py using unittest that "
"has at least 8 test cases covering normal usage AND edge cases (division by zero "
"should raise ValueError). All tests must pass when run with 'python3 -m unittest test_calculator -v'."
),
setup=(
'cat > calculator.py << \'PYEOF\'\n'
'class Calculator:\n'
' def add(self, a, b):\n'
' return a + b\n'
'\n'
' def subtract(self, a, b):\n'
' return a - b\n'
'\n'
' def multiply(self, a, b):\n'
' return a * b\n'
'\n'
' def divide(self, a, b):\n'
' if b == 0:\n'
' raise ValueError("Cannot divide by zero")\n'
' return a / b\n'
'PYEOF'
),
verify=(
'python3 -m unittest test_calculator -v 2>&1 | tail -1 | grep -q "OK" && '
'TESTS=$(python3 -m unittest test_calculator -v 2>&1 | grep -c "\\.\\.\\. ok") && '
'[ "$TESTS" -ge 8 ]'
),
),
# ------------------------------------------------------------------
# 8. DATA PROCESSING
# ------------------------------------------------------------------
BenchmarkTask(
id="data-json-transform",
category="data",
difficulty="medium",
instruction=(
"The file users.json contains an array of user objects with fields: name, age, city. "
"Create a Python script called transform.py that reads users.json and writes a new "
"file called summary.json containing: {\"total\": <count>, \"average_age\": <float rounded to 1 decimal>, "
"\"cities\": [<sorted unique city list>]}."
),
setup=(
'cat > users.json << \'JSONEOF\'\n'
'[\n'
' {"name": "Alice", "age": 30, "city": "NYC"},\n'
' {"name": "Bob", "age": 25, "city": "LA"},\n'
' {"name": "Carol", "age": 35, "city": "NYC"},\n'
' {"name": "Dave", "age": 28, "city": "Chicago"},\n'
' {"name": "Eve", "age": 22, "city": "LA"}\n'
']\n'
'JSONEOF'
),
verify=(
'python3 transform.py && '
'python3 -c "'
'import json; '
'd = json.load(open(\"summary.json\")); '
'assert d[\"total\"] == 5; '
'assert d[\"average_age\"] == 28.0; '
'assert d[\"cities\"] == [\"Chicago\", \"LA\", \"NYC\"]'
'"'
),
),
# ------------------------------------------------------------------
# 9. MULTI-FILE PROJECT
# ------------------------------------------------------------------
BenchmarkTask(
id="project-fix-imports",
category="project",
difficulty="hard",
instruction=(
"The project has three files: app/main.py, app/models.py, and app/utils.py. "
"The main.py tries to import from models and utils but has broken imports. "
"Also, app/__init__.py is missing. Fix everything so that running "
"'python3 -m app.main' prints 'App running: User(admin) validated'."
),
setup=(
'mkdir -p app\n'
'cat > app/models.py << \'PY\'\n'
'class User:\n'
' def __init__(self, name):\n'
' self.name = name\n'
' def __repr__(self):\n'
' return f"User({self.name})"\n'
'PY\n'
'cat > app/utils.py << \'PY\'\n'
'def validate(user):\n'
' return user.name is not None and len(user.name) > 0\n'
'PY\n'
'cat > app/main.py << \'PY\'\n'
'from models import User\n'
'from utils import validate\n'
'\n'
'def run():\n'
' u = User("admin")\n'
' v = validate(u)\n'
' status = "validated" if v else "invalid"\n'
' print(f"App running: {u} {status}")\n'
'\n'
'if __name__ == "__main__":\n'
' run()\n'
'PY'
),
verify='python3 -m app.main 2>&1 | grep -qx "App running: User(admin) validated"',
),
# ------------------------------------------------------------------
# 10. ALGORITHM IMPLEMENTATION
# ------------------------------------------------------------------
BenchmarkTask(
id="algo-binary-search",
category="algorithm",
difficulty="medium",
instruction=(
"Create a file called search.py with a function called binary_search that takes "
"a sorted list and a target value. It should return the index of the target if found, "
"or -1 if not found. Implement it using actual binary search (not list.index or linear scan)."
),
setup="",
verify=(
'python3 -c "'
'from search import binary_search; '
'assert binary_search([1,2,3,4,5], 3) == 2; '
'assert binary_search([1,2,3,4,5], 1) == 0; '
'assert binary_search([1,2,3,4,5], 5) == 4; '
'assert binary_search([1,2,3,4,5], 6) == -1; '
'assert binary_search([], 1) == -1; '
'assert binary_search([10], 10) == 0; '
'assert binary_search([10], 5) == -1; '
'assert binary_search(list(range(1000)), 500) == 500'
'"'
),
),
# ------------------------------------------------------------------
# 11. DEBUGGING WITH READING
# ------------------------------------------------------------------
BenchmarkTask(
id="debug-read-and-fix",
category="debug",
difficulty="hard",
instruction=(
"The file server_config.py has a function called parse_config that reads a .ini "
"style config file and returns a dictionary. But it crashes on the provided "
"settings.ini file. Read both files, find the bug, and fix parse_config so it "
"works correctly. Do not modify settings.ini."
),
setup=(
'cat > settings.ini << \'INI\'\n'
'[database]\n'
'host = localhost\n'
'port = 5432\n'
'\n'
'# This is a comment\n'
'[server]\n'
'debug = true\n'
'workers = 4\n'
'\n'
'[logging]\n'
'level = info\n'
'INI\n'
'cat > server_config.py << \'PYEOF\'\n'
'def parse_config(filename):\n'
' result = {}\n'
' current_section = None\n'
' with open(filename) as f:\n'
' for line in f:\n'
' line = line.strip()\n'
' if line.startswith("["):\n'
' current_section = line[1:-1]\n'
' result[current_section] = {}\n'
' elif "=" in line:\n'
' key, value = line.split("=")\n'
' result[current_section][key.strip()] = value.strip()\n'
'PYEOF'
),
verify=(
'python3 -c "'
'from server_config import parse_config; '
'c = parse_config(\"settings.ini\"); '
'assert c[\"database\"][\"host\"] == \"localhost\"; '
'assert c[\"database\"][\"port\"] == \"5432\"; '
'assert c[\"server\"][\"debug\"] == \"true\"; '
'assert c[\"server\"][\"workers\"] == \"4\"; '
'assert c[\"logging\"][\"level\"] == \"info\"'
'"'
),
),
# ------------------------------------------------------------------
# 12. GREP + ANALYSIS
# ------------------------------------------------------------------
BenchmarkTask(
id="analysis-find-todos",
category="analysis",
difficulty="easy",
instruction=(
"Search all .py files in the codebase/ directory recursively for lines containing "
"'TODO'. Create a file called todos.txt where each line has the format: "
"'<filename>:<line_number>: <the TODO text>'. Sort by filename then line number."
),
setup=(
'mkdir -p codebase/sub\n'
'cat > codebase/alpha.py << \'PY\'\n'
'# TODO: add logging\n'
'def alpha():\n'
' pass # TODO: implement\n'
'PY\n'
'cat > codebase/sub/beta.py << \'PY\'\n'
'def beta():\n'
' # TODO: handle errors\n'
' return 42\n'
'PY'
),
verify=(
'[ -f todos.txt ] && '
'[ "$(wc -l < todos.txt | tr -d " ")" = "3" ] && '
'grep -q "alpha.py" todos.txt && '
'grep -q "beta.py" todos.txt && '
'grep -q "TODO" todos.txt'
),
),
)
def get_task(task_id: str) -> BenchmarkTask | None:
for t in TASKS:
if t.id == task_id:
return t
return None
def list_tasks() -> list[dict[str, str]]:
return [
{"id": t.id, "category": t.category, "difficulty": t.difficulty}
for t in TASKS
]
+22 -1
View File
@@ -1,5 +1,6 @@
"""Python porting workspace for the Claude Code rewrite effort."""
from .account_runtime import AccountRuntime, AccountProfile, AccountSessionState, AccountStatusReport
from .agent_context import (
AgentContextSnapshot,
build_context_snapshot,
@@ -15,17 +16,20 @@ from .agent_tools import build_tool_context, default_tool_registry, execute_tool
from .agent_types import AgentPermissions, AgentRunResult, AgentRuntimeConfig, ModelConfig
from .background_runtime import BackgroundSessionRuntime
from .commands import PORTED_COMMANDS, build_command_backlog
from .mcp_runtime import MCPRuntime
from .config_runtime import ConfigMutation, ConfigRuntime
from .mcp_runtime import MCPRuntime, MCPResource, MCPServerProfile, MCPTool
from .parity_audit import ParityAuditResult, run_parity_audit
from .plan_runtime import PlanRuntime, PlanStep
from .plugin_runtime import PluginRuntime
from .port_manifest import PortManifest, build_port_manifest
from .query_engine import QueryEnginePort, TurnResult
from .runtime import PortRuntime, RuntimeSession
from .search_runtime import SearchProviderProfile, SearchResult, SearchRuntime, SearchStatusReport
from .session_store import StoredSession, load_session, save_session
from .system_init import build_system_init_message
from .task import PortingTask
from .task_runtime import TaskRuntime
from .tokenizer_runtime import TokenCounterInfo, clear_token_counter_cache, count_tokens, describe_token_counter
from .tools import PORTED_TOOLS, build_tool_backlog
__all__ = [
@@ -34,11 +38,20 @@ __all__ = [
'AgentPermissions',
'AgentRunResult',
'AgentRuntimeConfig',
'AccountProfile',
'AccountRuntime',
'AccountSessionState',
'AccountStatusReport',
'AgentMessage',
'AgentSessionState',
'BackgroundSessionRuntime',
'ConfigMutation',
'ConfigRuntime',
'LocalCodingAgent',
'MCPResource',
'MCPRuntime',
'MCPServerProfile',
'MCPTool',
'ModelConfig',
'ParityAuditResult',
'PlanRuntime',
@@ -49,8 +62,13 @@ __all__ = [
'PortingTask',
'QueryEnginePort',
'RuntimeSession',
'SearchProviderProfile',
'SearchResult',
'SearchRuntime',
'SearchStatusReport',
'StoredSession',
'TaskRuntime',
'TokenCounterInfo',
'TurnResult',
'PORTED_COMMANDS',
'PORTED_TOOLS',
@@ -61,7 +79,10 @@ __all__ = [
'build_tool_backlog',
'build_tool_context',
'clear_context_caches',
'clear_token_counter_cache',
'count_tokens',
'default_tool_registry',
'describe_token_counter',
'execute_tool',
'get_system_context',
'get_user_context',
+470
View File
@@ -0,0 +1,470 @@
from __future__ import annotations
import json
import os
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
DEFAULT_ACCOUNT_STATE_DIR = Path('.port_sessions')
DEFAULT_ACCOUNT_STATE_FILE = DEFAULT_ACCOUNT_STATE_DIR / 'account_runtime.json'
ACCOUNT_MANIFEST_PATHS = (
Path('.claw-account.json'),
Path('.claude/account.json'),
Path('.claude/auth.json'),
)
CREDENTIAL_ENV_VARS = (
'OPENAI_API_KEY',
'ANTHROPIC_API_KEY',
'OPENROUTER_API_KEY',
'LITELLM_MASTER_KEY',
)
@dataclass(frozen=True)
class AccountProfile:
name: str
provider: str
identity: str
source_manifest: str
description: str | None = None
org: str | None = None
auth_mode: str | None = None
api_base: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class AccountSessionState:
provider: str
identity: str
logged_in: bool
logged_in_at: str
profile_name: str | None = None
org: str | None = None
auth_mode: str | None = None
api_base: str | None = None
source_manifest: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class AccountStatusReport:
logged_in: bool
detail: str
provider: str | None = None
identity: str | None = None
profile_name: str | None = None
org: str | None = None
auth_mode: str | None = None
api_base: str | None = None
source_manifest: str | None = None
manifest_count: int = 0
profile_count: int = 0
credential_env_vars: tuple[str, ...] = ()
metadata: dict[str, Any] = field(default_factory=dict)
def as_text(self) -> str:
lines = [
f'logged_in={self.logged_in}',
f'detail={self.detail}',
f'manifest_count={self.manifest_count}',
f'profile_count={self.profile_count}',
]
if self.provider:
lines.append(f'provider={self.provider}')
if self.identity:
lines.append(f'identity={self.identity}')
if self.profile_name:
lines.append(f'profile={self.profile_name}')
if self.org:
lines.append(f'org={self.org}')
if self.auth_mode:
lines.append(f'auth_mode={self.auth_mode}')
if self.api_base:
lines.append(f'api_base={self.api_base}')
if self.source_manifest:
lines.append(f'source_manifest={self.source_manifest}')
if self.credential_env_vars:
lines.append('credential_env=' + ','.join(self.credential_env_vars))
if self.metadata:
for key, value in sorted(self.metadata.items()):
lines.append(f'metadata.{key}={value}')
return '\n'.join(lines)
@dataclass
class AccountRuntime:
cwd: Path
profiles: tuple[AccountProfile, ...] = field(default_factory=tuple)
manifests: tuple[str, ...] = field(default_factory=tuple)
state_path: Path = field(default_factory=lambda: DEFAULT_ACCOUNT_STATE_FILE.resolve())
active_session: AccountSessionState | None = None
history: tuple[dict[str, Any], ...] = field(default_factory=tuple)
credential_env_vars: tuple[str, ...] = field(default_factory=tuple)
@classmethod
def from_workspace(
cls,
cwd: Path,
additional_working_directories: tuple[str, ...] = (),
) -> 'AccountRuntime':
manifest_paths = _discover_manifest_paths(cwd, additional_working_directories)
profiles: list[AccountProfile] = []
for manifest_path in manifest_paths:
profiles.extend(_load_profiles_from_manifest(manifest_path))
state_path = (cwd.resolve() / DEFAULT_ACCOUNT_STATE_FILE).resolve()
payload = _load_state_payload(state_path)
active_session = _session_from_payload(payload.get('active_session'))
history_payload = payload.get('history')
history = tuple(
item for item in history_payload if isinstance(item, dict)
) if isinstance(history_payload, list) else ()
return cls(
cwd=cwd.resolve(),
profiles=tuple(profiles),
manifests=tuple(str(path) for path in manifest_paths),
state_path=state_path,
active_session=active_session,
history=history,
credential_env_vars=_detect_credential_env_vars(),
)
def has_account_state(self) -> bool:
return bool(self.profiles or self.active_session is not None or self.credential_env_vars)
def list_profiles(
self,
*,
query: str | None = None,
limit: int | None = None,
) -> tuple[AccountProfile, ...]:
profiles = self.profiles
if query:
needle = query.lower()
profiles = tuple(
profile
for profile in profiles
if needle in profile.name.lower()
or needle in profile.provider.lower()
or needle in profile.identity.lower()
or needle in (profile.org or '').lower()
)
if limit is not None and limit >= 0:
profiles = profiles[:limit]
return profiles
def get_profile(self, name_or_identity: str) -> AccountProfile | None:
needle = name_or_identity.strip().lower()
if not needle:
return None
for profile in self.profiles:
if profile.name.lower() == needle or profile.identity.lower() == needle:
return profile
return None
def login(
self,
target: str,
*,
provider: str | None = None,
auth_mode: str | None = None,
) -> AccountStatusReport:
profile = self.get_profile(target)
if profile is not None:
session = AccountSessionState(
provider=profile.provider,
identity=profile.identity,
logged_in=True,
logged_in_at=_utc_now(),
profile_name=profile.name,
org=profile.org,
auth_mode=profile.auth_mode,
api_base=profile.api_base,
source_manifest=profile.source_manifest,
metadata=dict(profile.metadata),
)
detail = f'Activated account profile {profile.name}'
else:
session = AccountSessionState(
provider=(provider or 'custom').strip() or 'custom',
identity=target.strip(),
logged_in=True,
logged_in_at=_utc_now(),
auth_mode=(auth_mode or 'token').strip() or 'token',
metadata={'ephemeral': True},
)
detail = f'Activated ephemeral account identity {target.strip()}'
self.active_session = session
self._append_history(
{
'action': 'login',
'provider': session.provider,
'identity': session.identity,
'profile_name': session.profile_name,
'logged_in_at': session.logged_in_at,
}
)
self._persist_state()
return self.current_report(detail=detail)
def logout(self, *, reason: str = 'manual_logout') -> AccountStatusReport:
previous = self.active_session
detail = (
f'Logged out {previous.identity}'
if previous is not None
else 'No active account session was present.'
)
if previous is not None:
self._append_history(
{
'action': 'logout',
'provider': previous.provider,
'identity': previous.identity,
'profile_name': previous.profile_name,
'reason': reason,
'logged_out_at': _utc_now(),
}
)
self.active_session = None
self._persist_state()
return AccountStatusReport(
logged_in=False,
detail=detail,
manifest_count=len(self.manifests),
profile_count=len(self.profiles),
credential_env_vars=self.credential_env_vars,
)
def current_report(self, *, detail: str | None = None) -> AccountStatusReport:
if self.active_session is None:
return AccountStatusReport(
logged_in=False,
detail=detail or 'No active account session.',
manifest_count=len(self.manifests),
profile_count=len(self.profiles),
credential_env_vars=self.credential_env_vars,
)
session = self.active_session
return AccountStatusReport(
logged_in=session.logged_in,
detail=detail or f'Active account session for {session.identity}',
provider=session.provider,
identity=session.identity,
profile_name=session.profile_name,
org=session.org,
auth_mode=session.auth_mode,
api_base=session.api_base,
source_manifest=session.source_manifest,
manifest_count=len(self.manifests),
profile_count=len(self.profiles),
credential_env_vars=self.credential_env_vars,
metadata=dict(session.metadata),
)
def render_summary(self) -> str:
lines = [
f'Local account manifests: {len(self.manifests)}',
f'Configured account profiles: {len(self.profiles)}',
]
if self.credential_env_vars:
lines.append('- Credential env vars: ' + ', '.join(self.credential_env_vars))
for profile in self.profiles[:5]:
details = [profile.name, profile.provider, profile.identity]
if profile.org:
details.append(f'org={profile.org}')
if profile.auth_mode:
details.append(f'auth_mode={profile.auth_mode}')
lines.append('- Profile: ' + ' ; '.join(details))
if self.active_session is None:
lines.append('- Active account session: none')
else:
session = self.active_session
lines.append(
f'- Active account session: {session.provider} / {session.identity}'
)
if session.profile_name:
lines.append(f' - profile: {session.profile_name}')
if session.auth_mode:
lines.append(f' - auth_mode: {session.auth_mode}')
if session.org:
lines.append(f' - org: {session.org}')
if session.api_base:
lines.append(f' - api_base: {session.api_base}')
return '\n'.join(lines)
def render_profile(self, name_or_identity: str) -> str:
profile = self.get_profile(name_or_identity)
if profile is None:
return f'# Account Profile\n\nUnknown account profile: {name_or_identity}'
lines = [
'# Account Profile',
'',
f'- Name: {profile.name}',
f'- Provider: {profile.provider}',
f'- Identity: {profile.identity}',
f'- Source manifest: {profile.source_manifest}',
]
if profile.org:
lines.append(f'- Org: {profile.org}')
if profile.auth_mode:
lines.append(f'- Auth mode: {profile.auth_mode}')
if profile.api_base:
lines.append(f'- API base: {profile.api_base}')
if profile.description:
lines.extend(['', profile.description])
return '\n'.join(lines)
def render_profiles_index(self, *, query: str | None = None) -> str:
profiles = self.list_profiles(query=query, limit=100)
lines = ['# Account Profiles', '']
if not profiles:
lines.append('No local account profiles discovered.')
return '\n'.join(lines)
for profile in profiles:
details = [profile.name, profile.provider, profile.identity]
if profile.org:
details.append(f'org={profile.org}')
if profile.auth_mode:
details.append(f'auth_mode={profile.auth_mode}')
lines.append('- ' + ' ; '.join(details))
return '\n'.join(lines)
def _append_history(self, entry: dict[str, Any]) -> None:
self.history = (*self.history, entry)
def _persist_state(self) -> None:
payload = {
'active_session': (
asdict(self.active_session)
if self.active_session is not None
else None
),
'history': list(self.history[-100:]),
}
self.state_path.parent.mkdir(parents=True, exist_ok=True)
self.state_path.write_text(
json.dumps(payload, ensure_ascii=True, indent=2, sort_keys=True) + '\n',
encoding='utf-8',
)
def _discover_manifest_paths(
cwd: Path,
additional_working_directories: tuple[str, ...],
) -> tuple[Path, ...]:
candidate_roots = [cwd.resolve()]
for raw_path in additional_working_directories:
path = Path(raw_path).resolve()
if path not in candidate_roots:
candidate_roots.append(path)
discovered: list[Path] = []
seen: set[Path] = set()
for root in candidate_roots:
for relative_path in ACCOUNT_MANIFEST_PATHS:
path = (root / relative_path).resolve()
if path in seen or not path.exists() or not path.is_file():
continue
seen.add(path)
discovered.append(path)
return tuple(discovered)
def _load_profiles_from_manifest(path: Path) -> list[AccountProfile]:
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return []
if isinstance(payload, dict):
profiles_payload = payload.get('profiles')
if isinstance(profiles_payload, list):
return [
profile
for item in profiles_payload
for profile in [_profile_from_payload(item, path)]
if profile is not None
]
single = _profile_from_payload(payload, path)
return [single] if single is not None else []
return []
def _profile_from_payload(payload: Any, path: Path) -> AccountProfile | None:
if not isinstance(payload, dict):
return None
raw_name = payload.get('name') or payload.get('profile')
provider = payload.get('provider')
identity = payload.get('identity') or payload.get('email') or payload.get('user')
if not isinstance(provider, str) or not provider.strip():
return None
if not isinstance(identity, str) or not identity.strip():
return None
if not isinstance(raw_name, str) or not raw_name.strip():
raw_name = identity
metadata = payload.get('metadata')
return AccountProfile(
name=str(raw_name).strip(),
provider=provider.strip(),
identity=identity.strip(),
source_manifest=str(path),
description=_optional_str(payload.get('description')),
org=_optional_str(payload.get('org')),
auth_mode=_optional_str(payload.get('authMode') or payload.get('auth_mode')),
api_base=_optional_str(payload.get('apiBase') or payload.get('api_base')),
metadata=dict(metadata) if isinstance(metadata, dict) else {},
)
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 {}
def _session_from_payload(payload: Any) -> AccountSessionState | None:
if not isinstance(payload, dict):
return None
provider = payload.get('provider')
identity = payload.get('identity')
if not isinstance(provider, str) or not provider.strip():
return None
if not isinstance(identity, str) or not identity.strip():
return None
metadata = payload.get('metadata')
return AccountSessionState(
provider=provider.strip(),
identity=identity.strip(),
logged_in=bool(payload.get('logged_in', True)),
logged_in_at=str(payload.get('logged_in_at', _utc_now())),
profile_name=_optional_str(payload.get('profile_name')),
org=_optional_str(payload.get('org')),
auth_mode=_optional_str(payload.get('auth_mode')),
api_base=_optional_str(payload.get('api_base')),
source_manifest=_optional_str(payload.get('source_manifest')),
metadata=dict(metadata) if isinstance(metadata, dict) else {},
)
def _detect_credential_env_vars() -> tuple[str, ...]:
return tuple(
key
for key in CREDENTIAL_ENV_VARS
if isinstance(os.environ.get(key), str) and os.environ.get(key, '').strip()
)
def _optional_str(value: Any) -> str | None:
if not isinstance(value, str):
return None
stripped = value.strip()
return stripped or None
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
+16
View File
@@ -9,10 +9,14 @@ from functools import lru_cache
from pathlib import Path
from .agent_plugin_cache import load_plugin_cache_summary
from .account_runtime import AccountRuntime
from .config_runtime import ConfigRuntime
from .hook_policy import HookPolicyRuntime
from .mcp_runtime import MCPRuntime
from .plan_runtime import PlanRuntime
from .plugin_runtime import PluginRuntime
from .remote_runtime import RemoteRuntime
from .search_runtime import SearchRuntime
from .task_runtime import TaskRuntime
from .agent_types import AgentRuntimeConfig
@@ -224,6 +228,18 @@ def _get_user_context_cached(
mcp_runtime = MCPRuntime.from_workspace(Path(cwd), additional_working_directories)
if mcp_runtime.resources:
context['mcpRuntime'] = mcp_runtime.render_summary()
remote_runtime = RemoteRuntime.from_workspace(Path(cwd), additional_working_directories)
if remote_runtime.has_remote_config():
context['remoteRuntime'] = remote_runtime.render_summary()
search_runtime = SearchRuntime.from_workspace(Path(cwd), additional_working_directories)
if search_runtime.has_search_runtime():
context['searchRuntime'] = search_runtime.render_summary()
account_runtime = AccountRuntime.from_workspace(Path(cwd), additional_working_directories)
if account_runtime.has_account_state():
context['accountRuntime'] = account_runtime.render_summary()
config_runtime = ConfigRuntime.from_workspace(Path(cwd))
if config_runtime.has_config():
context['configRuntime'] = config_runtime.render_summary()
plan_runtime = PlanRuntime.from_workspace(Path(cwd))
if plan_runtime.steps:
context['planRuntime'] = plan_runtime.render_summary()
+22 -15
View File
@@ -1,12 +1,12 @@
from __future__ import annotations
import json
import math
import re
from dataclasses import dataclass
from .agent_prompting import SYSTEM_PROMPT_DYNAMIC_BOUNDARY
from .agent_session import AgentMessage, AgentSessionState
from .tokenizer_runtime import describe_token_counter, count_tokens
_PATH_HEADER_RE = re.compile(r'^## ((?:/|[A-Za-z]:[\\/]).+)$', re.MULTILINE)
@@ -48,12 +48,13 @@ class ContextUsageReport:
system_context_entries: tuple[UsageEntry, ...]
memory_files: tuple[UsageEntry, ...]
message_breakdown: MessageBreakdown
token_counter_backend: str
token_counter_source: str
token_counter_accurate: bool
def estimate_tokens(text: str) -> int:
if not text:
return 0
return max(1, math.ceil(len(text) / 4))
def estimate_tokens(text: str, model: str | None = None) -> int:
return count_tokens(text, model)
def infer_context_window(model: str) -> int:
@@ -78,21 +79,23 @@ def collect_context_usage(
strategy: str,
) -> ContextUsageReport:
raw_max_tokens = infer_context_window(model)
token_counter = describe_token_counter(model)
count = lambda text: estimate_tokens(text, model) # noqa: E731
system_prompt_sections = tuple(
UsageEntry(name=_section_name(part, idx), tokens=estimate_tokens(part))
UsageEntry(name=_section_name(part, idx), tokens=count(part))
for idx, part in enumerate(session.system_prompt_parts, start=1)
)
system_context_entries = tuple(
UsageEntry(name=key, tokens=estimate_tokens(f'{key}: {value}'))
UsageEntry(name=key, tokens=count(f'{key}: {value}'))
for key, value in session.system_context.items()
if value
)
user_context_entries = tuple(
UsageEntry(name=key, tokens=estimate_tokens(_render_user_context_chunk(key, value)))
UsageEntry(name=key, tokens=count(_render_user_context_chunk(key, value)))
for key, value in session.user_context.items()
if value
)
memory_files = tuple(_parse_memory_usage(session.user_context.get('claudeMd')))
memory_files = tuple(_parse_memory_usage(session.user_context.get('claudeMd'), model=model))
user_context_tokens = sum(entry.tokens for entry in user_context_entries)
system_prompt_tokens = (
@@ -112,20 +115,20 @@ def collect_context_usage(
if _is_user_context_message(session, index, message):
continue
if message.role == 'user':
conversation_user_tokens += estimate_tokens(message.content)
conversation_user_tokens += count(message.content)
continue
if message.role == 'assistant':
assistant_tokens += estimate_tokens(message.content)
assistant_tokens += count(message.content)
for tool_call in message.tool_calls:
serialized = json.dumps(tool_call, ensure_ascii=True)
tokens = estimate_tokens(serialized)
tokens = count(serialized)
tool_call_tokens += tokens
tool_name = _extract_tool_call_name(tool_call)
call_totals = tool_usage.setdefault(tool_name, [0, 0])
call_totals[0] += tokens
continue
if message.role == 'tool':
tokens = estimate_tokens(message.content)
tokens = count(message.content)
tool_result_tokens += tokens
result_totals = tool_usage.setdefault(message.name or 'tool', [0, 0])
result_totals[1] += tokens
@@ -176,6 +179,9 @@ def collect_context_usage(
user_context_tokens=user_context_tokens,
tool_calls_by_type=tool_calls_by_type,
),
token_counter_backend=token_counter.backend,
token_counter_source=token_counter.source,
token_counter_accurate=token_counter.accurate,
)
@@ -185,6 +191,7 @@ def format_context_usage(report: ContextUsageReport) -> str:
'',
f'**Model:** {report.model} ',
f'**Estimated tokens:** {_format_tokens(report.total_tokens)} / {_format_tokens(report.raw_max_tokens)} ({report.percentage:.1f}%) ',
f'**Token counter:** {report.token_counter_backend} ({report.token_counter_source}){" [accurate]" if report.token_counter_accurate else " [fallback]"} ',
f'**Context strategy:** {report.strategy} ',
f'**Messages in session:** {report.message_count}',
'',
@@ -330,7 +337,7 @@ def _is_user_context_message(
)
def _parse_memory_usage(claude_md: str | None) -> list[UsageEntry]:
def _parse_memory_usage(claude_md: str | None, *, model: str | None = None) -> list[UsageEntry]:
if not claude_md:
return []
matches = list(_PATH_HEADER_RE.finditer(claude_md))
@@ -341,7 +348,7 @@ def _parse_memory_usage(claude_md: str | None) -> list[UsageEntry]:
start = match.end()
end = matches[idx + 1].start() if idx + 1 < len(matches) else len(claude_md)
content = claude_md[start:end].strip()
entries.append(UsageEntry(name=match.group(1), tokens=estimate_tokens(content)))
entries.append(UsageEntry(name=match.group(1), tokens=estimate_tokens(content, model)))
return entries
+56 -2
View File
@@ -92,6 +92,10 @@ def build_system_prompt_parts(
get_using_your_tools_section(enabled_tool_names),
get_plugin_guidance_section(prompt_context),
get_mcp_guidance_section(prompt_context),
get_remote_guidance_section(prompt_context),
get_search_guidance_section(prompt_context),
get_account_guidance_section(prompt_context),
get_config_guidance_section(prompt_context),
get_plan_guidance_section(prompt_context),
get_task_guidance_section(prompt_context),
get_hook_policy_guidance_section(prompt_context),
@@ -229,13 +233,62 @@ def get_mcp_guidance_section(prompt_context: PromptContext) -> str:
if not mcp_runtime:
return ''
items = [
'Local MCP manifests may expose additional resources through the runtime.',
'Local MCP manifests may expose additional resources and transport-backed tools through the runtime.',
'Use MCP resource tools when the task depends on manifest-backed external context or curated workspace resources.',
'Treat MCP resource summaries as discoverability hints and prefer reading the specific resource URI before relying on its contents.',
'Use MCP transport tools when a configured MCP server exposes real callable tools that should stay outside the local Python tool registry.',
'Treat MCP resource and tool summaries as discoverability hints and prefer reading a specific resource URI or calling a specific MCP tool before relying on its contents.',
]
return '\n'.join(['# MCP', *prepend_bullets(items)])
def get_remote_guidance_section(prompt_context: PromptContext) -> str:
remote_runtime = prompt_context.user_context.get('remoteRuntime')
if not remote_runtime:
return ''
items = [
'Local remote manifests or an active remote connection may be available in the workspace context.',
'Use remote status or remote-connect flows before assuming a specific remote target is active.',
'Treat remote summaries as runtime state for the current workspace, including active target, session URL, and remote workspace path when present.',
]
return '\n'.join(['# Remote', *prepend_bullets(items)])
def get_search_guidance_section(prompt_context: PromptContext) -> str:
search_runtime = prompt_context.user_context.get('searchRuntime')
if not search_runtime:
return ''
items = [
'Local workspace web-search providers may be available through the runtime.',
'Use the web_search tool when the task requires discovering external pages rather than fetching a known URL directly.',
'Use web_fetch after web_search when you need to inspect the contents of a selected result page.',
]
return '\n'.join(['# Search', *prepend_bullets(items)])
def get_account_guidance_section(prompt_context: PromptContext) -> str:
account_runtime = prompt_context.user_context.get('accountRuntime')
if not account_runtime:
return ''
items = [
'Local workspace account or auth state may be available through the runtime.',
'Use account tools and account slash commands when the task depends on local login state, configured profiles, or auth metadata.',
'Treat local account summaries as workspace runtime state, including active identity, configured profiles, and visible credential env vars.',
]
return '\n'.join(['# Account', *prepend_bullets(items)])
def get_config_guidance_section(prompt_context: PromptContext) -> str:
config_runtime = prompt_context.user_context.get('configRuntime')
if not config_runtime:
return ''
items = [
'Local workspace config and settings files may be available through the runtime.',
'Use config tools instead of ad-hoc file edits when the task is specifically about settings or config state.',
'Treat the effective config as merged workspace state, and inspect the specific source when override order matters.',
]
return '\n'.join(['# Config', *prepend_bullets(items)])
def get_task_guidance_section(prompt_context: PromptContext) -> str:
task_runtime = prompt_context.user_context.get('taskRuntime')
if not task_runtime:
@@ -244,6 +297,7 @@ def get_task_guidance_section(prompt_context: PromptContext) -> str:
'A local runtime task list may be available to track ongoing work.',
'Use task and todo tools to keep the plan state current when the task spans multiple steps or files.',
'Prefer updating the stored task list instead of repeating the same progress summary in free-form text.',
'Use task_next and the richer task state tools when dependencies or blocked work matter.',
]
return '\n'.join(['# Tasks', *prepend_bullets(items)])
+299 -7
View File
@@ -7,9 +7,12 @@ from pathlib import Path
from typing import Any
from uuid import uuid4
from .account_runtime import AccountRuntime
from .agent_manager import AgentManager
from .agent_context import clear_context_caches
from .agent_context import render_context_report as render_agent_context_report
from .agent_context_usage import collect_context_usage, estimate_tokens, format_context_usage
from .config_runtime import ConfigRuntime
from .hook_policy import HookPolicyRuntime
from .mcp_runtime import MCPRuntime
from .agent_prompting import (
@@ -42,7 +45,10 @@ from .agent_types import (
from .openai_compat import OpenAICompatClient, OpenAICompatError
from .plan_runtime import PlanRuntime
from .plugin_runtime import PluginRuntime
from .remote_runtime import RemoteRuntime
from .search_runtime import SearchRuntime
from .task_runtime import TaskRuntime
from .tokenizer_runtime import describe_token_counter
from .session_store import (
StoredAgentSession,
load_agent_session,
@@ -75,6 +81,10 @@ class LocalCodingAgent:
plugin_runtime: PluginRuntime | None = None
hook_policy_runtime: HookPolicyRuntime | None = None
mcp_runtime: MCPRuntime | None = None
remote_runtime: RemoteRuntime | None = None
search_runtime: SearchRuntime | None = None
account_runtime: AccountRuntime | None = None
config_runtime: ConfigRuntime | None = None
plan_runtime: PlanRuntime | None = None
task_runtime: TaskRuntime | None = None
last_session: AgentSessionState | None = field(default=None, init=False, repr=False)
@@ -104,6 +114,23 @@ class LocalCodingAgent:
self.runtime_config.cwd,
tuple(str(path) for path in self.runtime_config.additional_working_directories),
)
if self.remote_runtime is None:
self.remote_runtime = RemoteRuntime.from_workspace(
self.runtime_config.cwd,
tuple(str(path) for path in self.runtime_config.additional_working_directories),
)
if self.search_runtime is None:
self.search_runtime = SearchRuntime.from_workspace(
self.runtime_config.cwd,
tuple(str(path) for path in self.runtime_config.additional_working_directories),
)
if self.account_runtime is None:
self.account_runtime = AccountRuntime.from_workspace(
self.runtime_config.cwd,
tuple(str(path) for path in self.runtime_config.additional_working_directories),
)
if self.config_runtime is None:
self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd)
if self.plan_runtime is None:
self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd)
if self.task_runtime is None:
@@ -120,12 +147,17 @@ class LocalCodingAgent:
self.client = OpenAICompatClient(self.model_config)
self.tool_context = build_tool_context(
self.runtime_config,
tool_registry=self.tool_registry,
extra_env=(
self.hook_policy_runtime.safe_env()
if self.hook_policy_runtime is not None
else None
),
search_runtime=self.search_runtime,
account_runtime=self.account_runtime,
config_runtime=self.config_runtime,
mcp_runtime=self.mcp_runtime,
remote_runtime=self.remote_runtime,
plan_runtime=self.plan_runtime,
task_runtime=self.task_runtime,
)
@@ -919,6 +951,7 @@ class LocalCodingAgent:
'preflight_count': len(plugin_preflight_messages),
}
)
self._refresh_runtime_views_for_tool_result(tool_call.name, tool_result)
history_entry = self._build_file_history_entry(
tool_call=tool_call,
tool_result=tool_result,
@@ -1270,9 +1303,9 @@ class LocalCodingAgent:
if current_total <= target_tokens and not reactive:
break
message = session.messages[index]
original_tokens = estimate_tokens(message.content)
original_tokens = estimate_tokens(message.content, self.model_config.model)
replacement = self._build_snipped_message_content(message)
replacement_tokens = estimate_tokens(replacement)
replacement_tokens = estimate_tokens(replacement, self.model_config.model)
if replacement_tokens >= original_tokens:
continue
session.tombstone_message(
@@ -2832,28 +2865,194 @@ class LocalCodingAgent:
return '# Memory\n\nNo CLAUDE.md memory files are currently loaded.'
return '\n'.join(['# Memory', '', claude_md])
def render_account_report(self, profile: str | None = None) -> str:
if self.account_runtime is None:
return '# Account\n\nNo local account runtime is available.'
if profile:
return self.account_runtime.render_profile(profile)
return '\n'.join(['# Account', '', self.account_runtime.render_summary()])
def render_search_report(
self,
query: str | None = None,
*,
provider: str | None = None,
max_results: int = 5,
domains: tuple[str, ...] = (),
) -> str:
if self.search_runtime is None or not self.search_runtime.has_search_runtime():
return (
'# Search\n\nNo local search provider is available. '
'Add a .claw-search.json or .claude/search.json manifest, '
'or set SEARXNG_BASE_URL, BRAVE_SEARCH_API_KEY, or TAVILY_API_KEY.'
)
if query:
try:
return self.search_runtime.render_search_results(
query,
provider_name=provider,
max_results=max_results,
domains=domains,
timeout_seconds=self.runtime_config.command_timeout_seconds,
)
except (KeyError, LookupError, OSError, ValueError) as exc:
return f'# Search\n\nSearch failed: {exc}'
if provider:
return self.search_runtime.render_provider(provider)
return '\n'.join(['# Search', '', self.search_runtime.render_summary()])
def render_search_providers_report(self, query: str | None = None) -> str:
if self.search_runtime is None or not self.search_runtime.has_search_runtime():
return '# Search Providers\n\nNo local search providers discovered.'
return self.search_runtime.render_providers_index(query=query)
def render_search_activate_report(self, provider: str) -> str:
if self.search_runtime is None or not self.search_runtime.has_search_runtime():
return '# Search\n\nNo local search provider is available.'
try:
report = self.search_runtime.activate_provider(provider)
except KeyError:
return f'# Search\n\nUnknown search provider: {provider}'
clear_context_caches()
self.tool_context = replace(
self.tool_context,
search_runtime=self.search_runtime,
)
return '\n'.join(['# Search', '', report.as_text()])
def render_account_profiles_report(self, query: str | None = None) -> str:
if self.account_runtime is None:
return '# Account Profiles\n\nNo local account runtime is available.'
return self.account_runtime.render_profiles_index(query=query)
def render_account_login_report(
self,
target: str,
*,
provider: str | None = None,
auth_mode: str | None = None,
) -> str:
if self.account_runtime is None:
return '# Account\n\nNo local account runtime is available.'
report = self.account_runtime.login(target, provider=provider, auth_mode=auth_mode)
clear_context_caches()
return '\n'.join(['# Account', '', report.as_text()])
def render_account_logout_report(self) -> str:
if self.account_runtime is None:
return '# Account\n\nNo local account runtime is available.'
report = self.account_runtime.logout(reason='slash_or_cli_logout')
clear_context_caches()
return '\n'.join(['# Account', '', report.as_text()])
def render_config_report(self) -> str:
if self.config_runtime is None:
return '# Config\n\nNo local config runtime is available.'
return '\n'.join(['# Config', '', self.config_runtime.render_summary()])
def render_config_effective_report(self) -> str:
if self.config_runtime is None:
return '# Config Effective\n\nNo local config runtime is available.'
return '\n'.join(['# Config Effective', '', self.config_runtime.render_effective_config()])
def render_config_source_report(self, source: str) -> str:
if self.config_runtime is None:
return '# Config Source\n\nNo local config runtime is available.'
return '\n'.join(['# Config Source', '', self.config_runtime.render_source(source)])
def render_config_value_report(self, key_path: str, source: str | None = None) -> str:
if self.config_runtime is None:
return '# Config Value\n\nNo local config runtime is available.'
try:
rendered = self.config_runtime.render_value(key_path, source=source)
except KeyError as exc:
label = source if source is not None else key_path
return f'# Config Value\n\nUnknown config key or source: {label or exc.args[0]}'
return '\n'.join(['# Config Value', '', rendered])
def render_mcp_report(self, query: str | None = None) -> str:
if self.mcp_runtime is None:
return '# MCP\n\nNo local MCP manifests or resources discovered.'
return '# MCP\n\nNo local MCP manifests, servers, or resources discovered.'
if query:
return self.mcp_runtime.render_resource_index(query=query)
return '\n'.join(['# MCP', '', self.mcp_runtime.render_summary()])
def render_remote_report(self, target: str | None = None) -> str:
if self.remote_runtime is None:
return '# Remote\n\nNo local remote runtime is available.'
if target:
report = self.remote_runtime.connect(target)
clear_context_caches()
return '\n'.join(['# Remote', '', report.as_text()])
return '\n'.join(['# Remote', '', self.remote_runtime.render_summary()])
def render_remote_mode_report(self, target: str, *, mode: str) -> str:
if self.remote_runtime is None:
return '# Remote\n\nNo local remote runtime is available.'
report = self.remote_runtime.connect(target, mode=mode)
clear_context_caches()
return '\n'.join(['# Remote', '', report.as_text()])
def render_remote_profiles_report(self, query: str | None = None) -> str:
if self.remote_runtime is None:
return '# Remote Profiles\n\nNo local remote runtime is available.'
return self.remote_runtime.render_profiles_index(query=query)
def render_remote_disconnect_report(self) -> str:
if self.remote_runtime is None:
return '# Remote\n\nNo local remote runtime is available.'
report = self.remote_runtime.disconnect()
clear_context_caches()
return '\n'.join(['# Remote', '', report.as_text()])
def render_mcp_resources_report(self, query: str | None = None) -> str:
if self.mcp_runtime is None:
return '# MCP Resources\n\nNo local MCP manifests or resources discovered.'
return '# MCP Resources\n\nNo local MCP manifests, servers, or resources discovered.'
return self.mcp_runtime.render_resource_index(query=query)
def render_mcp_resource_report(self, uri: str) -> str:
if self.mcp_runtime is None:
return '# MCP Resource\n\nNo local MCP manifests or resources discovered.'
return '# MCP Resource\n\nNo local MCP manifests, servers, or resources discovered.'
return self.mcp_runtime.render_resource(uri)
def render_mcp_tools_report(
self,
query: str | None = None,
*,
server: str | None = None,
) -> str:
if self.mcp_runtime is None:
return '# MCP Tools\n\nNo local MCP manifests, servers, or resources discovered.'
return self.mcp_runtime.render_tool_index(query=query, server_name=server)
def render_mcp_call_tool_report(
self,
tool_name: str,
*,
arguments: dict[str, Any] | None = None,
server: str | None = None,
) -> str:
if self.mcp_runtime is None:
return '# MCP Tool Result\n\nNo local MCP manifests, servers, or resources discovered.'
try:
return self.mcp_runtime.render_tool_call(
tool_name,
arguments=arguments,
server_name=server,
)
except FileNotFoundError as exc:
return f'# MCP Tool Result\n\n{exc}'
def render_tasks_report(self, status: str | None = None) -> str:
if self.task_runtime is None:
return '# Tasks\n\nNo local task runtime is available.'
return self.task_runtime.render_tasks(status=status)
def render_next_tasks_report(self) -> str:
if self.task_runtime is None:
return '# Next Tasks\n\nNo local task runtime is available.'
return self.task_runtime.render_next_tasks()
def render_plan_report(self) -> str:
if self.plan_runtime is None:
return '# Plan\n\nNo local plan runtime is available.'
@@ -2891,10 +3090,12 @@ class LocalCodingAgent:
return '\n'.join(lines)
def render_status_report(self) -> str:
token_counter = describe_token_counter(self.model_config.model)
lines = [
'# Status',
'',
f'- Model: {self.model_config.model}',
f'- Token counter: {token_counter.backend} ({token_counter.source})',
f'- Registered tools: {len(self.tool_registry)}',
f'- Streaming model responses: {self.runtime_config.stream_model_responses}',
f'- Session ID: {self.active_session_id or "none"}',
@@ -2904,8 +3105,37 @@ class LocalCodingAgent:
lines.append(
f'- Workspace trust mode: {"trusted" if self.hook_policy_runtime.is_trusted() else "untrusted"}'
)
if self.mcp_runtime is not None and self.mcp_runtime.resources:
lines.append(f'- MCP resources: {len(self.mcp_runtime.resources)}')
if self.mcp_runtime is not None:
if self.mcp_runtime.resources:
lines.append(f'- MCP local resources: {len(self.mcp_runtime.resources)}')
if self.mcp_runtime.servers:
lines.append(f'- MCP servers: {len(self.mcp_runtime.servers)}')
if self.remote_runtime is not None and self.remote_runtime.has_remote_config():
lines.append(f'- Remote profiles: {len(self.remote_runtime.profiles)}')
if self.remote_runtime.active_connection is not None:
connection = self.remote_runtime.active_connection
lines.append(
f'- Active remote: {connection.mode} -> {connection.target}'
)
if self.search_runtime is not None and self.search_runtime.has_search_runtime():
lines.append(f'- Search providers: {len(self.search_runtime.providers)}')
active_provider = self.search_runtime.current_provider()
if active_provider is not None:
lines.append(
f'- Active search provider: {active_provider.name} ({active_provider.provider})'
)
if self.account_runtime is not None and self.account_runtime.has_account_state():
lines.append(f'- Account profiles: {len(self.account_runtime.profiles)}')
if self.account_runtime.active_session is not None:
session = self.account_runtime.active_session
lines.append(
f'- Active account: {session.provider} -> {session.identity}'
)
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'- Effective config keys: {len(self.config_runtime.list_keys())}'
)
if self.plan_runtime is not None and 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:
@@ -2945,6 +3175,68 @@ class LocalCodingAgent:
)
self.resume_source_session_id = None
def _refresh_runtime_views_for_tool_result(
self,
tool_name: str,
tool_result: ToolExecutionResult,
) -> None:
if not tool_result.ok:
return
refresh_tool_names = {
'update_plan',
'plan_clear',
'task_create',
'task_update',
'task_start',
'task_complete',
'task_block',
'task_cancel',
'todo_write',
'search_activate_provider',
'remote_connect',
'remote_disconnect',
'account_login',
'account_logout',
'config_set',
}
if tool_name not in refresh_tool_names:
return
clear_context_caches()
additional_dirs = tuple(
str(path) for path in self.runtime_config.additional_working_directories
)
if tool_name.startswith('remote_'):
self.remote_runtime = RemoteRuntime.from_workspace(
self.runtime_config.cwd,
additional_working_directories=additional_dirs,
)
if tool_name.startswith('search_'):
self.search_runtime = SearchRuntime.from_workspace(
self.runtime_config.cwd,
additional_working_directories=additional_dirs,
)
if tool_name.startswith('account_'):
self.account_runtime = AccountRuntime.from_workspace(
self.runtime_config.cwd,
additional_working_directories=additional_dirs,
)
if tool_name == 'config_set':
self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd)
if tool_name.startswith('task_') or tool_name == 'todo_write':
self.task_runtime = TaskRuntime.from_workspace(self.runtime_config.cwd)
if tool_name.startswith('plan_') or tool_name == 'update_plan':
self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd)
self.tool_context = replace(
self.tool_context,
tool_registry=self.tool_registry,
search_runtime=self.search_runtime,
account_runtime=self.account_runtime,
config_runtime=self.config_runtime,
remote_runtime=self.remote_runtime,
plan_runtime=self.plan_runtime,
task_runtime=self.task_runtime,
)
def _apply_plugin_before_prompt_hooks(self, prompt: str) -> str:
if self.plugin_runtime is None:
return prompt
+193 -1
View File
@@ -114,6 +114,66 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]:
description='Show discovered local MCP manifests and resource counts.',
handler=_handle_mcp,
),
SlashCommandSpec(
names=('search',),
description='Show search runtime status, list or activate providers, or run a real web search query.',
handler=_handle_search,
),
SlashCommandSpec(
names=('remote',),
description='Show local remote runtime status or activate a remote target/profile.',
handler=_handle_remote,
),
SlashCommandSpec(
names=('account',),
description='Show local account runtime status or configured account profiles.',
handler=_handle_account,
),
SlashCommandSpec(
names=('login',),
description='Activate a local account profile or ephemeral identity.',
handler=_handle_login,
),
SlashCommandSpec(
names=('logout',),
description='Clear the active local account session.',
handler=_handle_logout,
),
SlashCommandSpec(
names=('config', 'settings'),
description='Show local config runtime state, effective config, config sources, or a config value.',
handler=_handle_config,
),
SlashCommandSpec(
names=('remotes',),
description='List configured local remote profiles.',
handler=_handle_remotes,
),
SlashCommandSpec(
names=('ssh',),
description='Activate a local SSH remote target/profile.',
handler=_handle_ssh,
),
SlashCommandSpec(
names=('teleport',),
description='Activate a local teleport remote target/profile.',
handler=_handle_teleport,
),
SlashCommandSpec(
names=('direct-connect',),
description='Activate a local direct-connect remote target/profile.',
handler=_handle_direct_connect,
),
SlashCommandSpec(
names=('deep-link',),
description='Activate a local deep-link remote target/profile.',
handler=_handle_deep_link,
),
SlashCommandSpec(
names=('disconnect', 'remote-disconnect'),
description='Disconnect the active local remote runtime target.',
handler=_handle_remote_disconnect,
),
SlashCommandSpec(
names=('resources',),
description='List local MCP resources, optionally filtered by a query string.',
@@ -129,6 +189,11 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]:
description='Show the local runtime task list, optionally filtered by status.',
handler=_handle_tasks,
),
SlashCommandSpec(
names=('task-next', 'next-task'),
description='Show the next actionable tasks from the local runtime task list.',
handler=_handle_task_next,
),
SlashCommandSpec(
names=('plan', 'planner'),
description='Show the current local runtime plan.',
@@ -221,8 +286,131 @@ def _handle_context_raw(agent: 'LocalCodingAgent', _args: str, input_text: str)
def _handle_mcp(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
command = args.strip()
if not command:
return _local_result(input_text, agent.render_mcp_report())
if command == 'tools':
return _local_result(input_text, agent.render_mcp_tools_report())
if command.startswith('tools '):
query = command.split(' ', 1)[1].strip()
return _local_result(input_text, agent.render_mcp_tools_report(query or None))
if command.startswith('tool '):
tool_name = command.split(' ', 1)[1].strip()
if not tool_name:
return _local_result(input_text, 'Usage: /mcp tool <tool-name>')
return _local_result(input_text, agent.render_mcp_call_tool_report(tool_name))
return _local_result(input_text, agent.render_mcp_report(command))
def _handle_search(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
command = args.strip()
if not command:
return _local_result(input_text, agent.render_search_report())
if command == 'providers':
return _local_result(input_text, agent.render_search_providers_report())
if command.startswith('providers '):
query = command.split(' ', 1)[1].strip()
return _local_result(input_text, agent.render_search_providers_report(query or None))
if command.startswith('provider '):
provider = command.split(' ', 1)[1].strip()
if not provider:
return _local_result(input_text, 'Usage: /search provider <name>')
return _local_result(input_text, agent.render_search_report(provider=provider))
if command.startswith('use '):
provider = command.split(' ', 1)[1].strip()
if not provider:
return _local_result(input_text, 'Usage: /search use <name>')
return _local_result(input_text, agent.render_search_activate_report(provider))
return _local_result(input_text, agent.render_search_report(command))
def _handle_remote(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
target = args or None
return _local_result(input_text, agent.render_remote_report(target))
def _handle_account(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
command = args.strip()
if not command:
return _local_result(input_text, agent.render_account_report())
if command == 'profiles':
return _local_result(input_text, agent.render_account_profiles_report())
if command.startswith('profile '):
profile = command.split(' ', 1)[1].strip()
if not profile:
return _local_result(input_text, 'Usage: /account profile <name>')
return _local_result(input_text, agent.render_account_report(profile))
return _local_result(input_text, 'Usage: /account [profiles|profile <name>]')
def _handle_login(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
target = args.strip()
if not target:
return _local_result(input_text, 'Usage: /login <profile-or-identity>')
return _local_result(input_text, agent.render_account_login_report(target))
def _handle_logout(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
return _local_result(input_text, agent.render_account_logout_report())
def _handle_config(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
command = args.strip()
if not command:
return _local_result(input_text, agent.render_config_report())
if command == 'effective':
return _local_result(input_text, agent.render_config_effective_report())
if command.startswith('source '):
source = command.split(' ', 1)[1].strip()
if not source:
return _local_result(input_text, 'Usage: /config source <source-name>')
return _local_result(input_text, agent.render_config_source_report(source))
if command.startswith('get '):
key_path = command.split(' ', 1)[1].strip()
if not key_path:
return _local_result(input_text, 'Usage: /config get <key-path>')
return _local_result(input_text, agent.render_config_value_report(key_path))
return _local_result(
input_text,
'Usage: /config [effective|source <name>|get <key-path>]',
)
def _handle_remotes(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
query = args or None
return _local_result(input_text, agent.render_mcp_report(query))
return _local_result(input_text, agent.render_remote_profiles_report(query))
def _handle_ssh(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
if not args:
return _local_result(input_text, 'Usage: /ssh <target-or-profile>')
return _local_result(input_text, agent.render_remote_mode_report(args, mode='ssh'))
def _handle_teleport(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
if not args:
return _local_result(input_text, 'Usage: /teleport <target-or-profile>')
return _local_result(input_text, agent.render_remote_mode_report(args, mode='teleport'))
def _handle_direct_connect(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
if not args:
return _local_result(input_text, 'Usage: /direct-connect <target-or-profile>')
return _local_result(input_text, agent.render_remote_mode_report(args, mode='direct-connect'))
def _handle_deep_link(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
if not args:
return _local_result(input_text, 'Usage: /deep-link <target-or-profile>')
return _local_result(input_text, agent.render_remote_mode_report(args, mode='deep-link'))
def _handle_remote_disconnect(
agent: 'LocalCodingAgent',
_args: str,
input_text: str,
) -> SlashCommandResult:
return _local_result(input_text, agent.render_remote_disconnect_report())
def _handle_resources(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
@@ -241,6 +429,10 @@ def _handle_tasks(agent: 'LocalCodingAgent', args: str, input_text: str) -> Slas
return _local_result(input_text, agent.render_tasks_report(status))
def _handle_task_next(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
return _local_result(input_text, agent.render_next_tasks_report())
def _handle_plan(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
return _local_result(input_text, agent.render_plan_report())
+1015 -5
View File
File diff suppressed because it is too large Load Diff
+296
View File
@@ -0,0 +1,296 @@
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
DEFAULT_CONFIG_DIR = Path('.claude')
PROJECT_SETTINGS_PATH = DEFAULT_CONFIG_DIR / 'settings.json'
LOCAL_SETTINGS_PATH = DEFAULT_CONFIG_DIR / 'settings.local.json'
LEGACY_CONFIG_PATHS = (
Path('.claw-config.json'),
Path('.codex-config.json'),
)
@dataclass(frozen=True)
class ConfigSource:
name: str
path: str
settings: dict[str, Any]
@dataclass(frozen=True)
class ConfigMutation:
source_name: str
key_path: str
store_path: str
before_sha256: str | None
after_sha256: str
before_preview: str | None
after_preview: str
effective_key_count: int
@dataclass
class ConfigRuntime:
cwd: Path
sources: tuple[ConfigSource, ...] = field(default_factory=tuple)
@classmethod
def from_workspace(cls, cwd: Path) -> 'ConfigRuntime':
root = cwd.resolve()
sources: list[ConfigSource] = []
for source_name, path in _discover_source_paths(root):
payload = _load_json_object(path)
if payload is None:
continue
sources.append(
ConfigSource(
name=source_name,
path=str(path),
settings=payload,
)
)
return cls(cwd=root, sources=tuple(sources))
def has_config(self) -> bool:
return bool(self.sources)
def effective_settings(self) -> dict[str, Any]:
merged: dict[str, Any] = {}
for source in self.sources:
merged = _deep_merge(merged, source.settings)
return merged
def list_keys(
self,
*,
source: str | None = None,
prefix: str | None = None,
limit: int | None = None,
) -> tuple[str, ...]:
payload = self._payload_for_source(source)
flattened = sorted(_flatten_keys(payload))
if prefix:
flattened = [key for key in flattened if key.startswith(prefix)]
if limit is not None and limit >= 0:
flattened = flattened[:limit]
return tuple(flattened)
def get_value(
self,
key_path: str,
*,
source: str | None = None,
) -> Any:
payload = self._payload_for_source(source)
return _get_nested_value(payload, key_path)
def set_value(
self,
key_path: str,
value: Any,
*,
source: str = 'local',
) -> ConfigMutation:
resolved_source, path = self._resolve_writable_source(source)
before_payload = _load_json_object(path) or {}
before_text = path.read_text(encoding='utf-8') if path.exists() else None
updated_payload = json.loads(json.dumps(before_payload))
_set_nested_value(updated_payload, key_path, value)
after_text = json.dumps(updated_payload, ensure_ascii=True, indent=2, sort_keys=True) + '\n'
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(after_text, encoding='utf-8')
refreshed = ConfigRuntime.from_workspace(self.cwd)
self.sources = refreshed.sources
return ConfigMutation(
source_name=resolved_source,
key_path=key_path,
store_path=str(path),
before_sha256=_sha256_or_none(before_text),
after_sha256=_sha256(after_text),
before_preview=_preview(before_text),
after_preview=_preview(after_text),
effective_key_count=len(_flatten_keys(self.effective_settings())),
)
def render_summary(self) -> str:
lines = [
f'Config sources: {len(self.sources)}',
f'Effective keys: {len(_flatten_keys(self.effective_settings()))}',
]
if not self.sources:
lines.append('- No local config files discovered.')
lines.append(f'- Project settings path: {(self.cwd / PROJECT_SETTINGS_PATH).resolve()}')
lines.append(f'- Local settings path: {(self.cwd / LOCAL_SETTINGS_PATH).resolve()}')
return '\n'.join(lines)
for source in self.sources:
lines.append(
f'- {source.name}: {source.path} ({len(_flatten_keys(source.settings))} key(s))'
)
return '\n'.join(lines)
def render_keys(
self,
*,
source: str | None = None,
prefix: str | None = None,
limit: int | None = None,
) -> str:
keys = self.list_keys(source=source, prefix=prefix, limit=limit)
if not keys:
return '(no config keys)'
return '\n'.join(keys)
def render_value(
self,
key_path: str,
*,
source: str | None = None,
) -> str:
return json.dumps(
self.get_value(key_path, source=source),
ensure_ascii=True,
indent=2,
sort_keys=True,
)
def render_effective_config(self) -> str:
return json.dumps(self.effective_settings(), ensure_ascii=True, indent=2, sort_keys=True)
def render_source(self, source: str) -> str:
resolved = self._find_source(source)
if resolved is None:
return f'# Config\n\nUnknown config source: {source}'
return json.dumps(resolved.settings, ensure_ascii=True, indent=2, sort_keys=True)
def _payload_for_source(self, source: str | None) -> dict[str, Any]:
if source is None:
return self.effective_settings()
resolved = self._find_source(source)
if resolved is None:
raise KeyError(source)
return resolved.settings
def _find_source(self, source: str) -> ConfigSource | None:
alias = _normalize_source_name(source)
for config_source in self.sources:
if _normalize_source_name(config_source.name) == alias:
return config_source
return None
def _resolve_writable_source(self, source: str) -> tuple[str, Path]:
alias = _normalize_source_name(source)
if alias in {'project', 'project-settings', 'settings'}:
return 'project', (self.cwd / PROJECT_SETTINGS_PATH).resolve()
if alias in {'local', 'local-settings'}:
return 'local', (self.cwd / LOCAL_SETTINGS_PATH).resolve()
if alias in {'legacy', 'legacy-project'}:
return 'legacy', (self.cwd / LEGACY_CONFIG_PATHS[0]).resolve()
raise KeyError(source)
def _discover_source_paths(cwd: Path) -> tuple[tuple[str, Path], ...]:
candidates = [
('legacy-claw', (cwd / LEGACY_CONFIG_PATHS[0]).resolve()),
('legacy-codex', (cwd / LEGACY_CONFIG_PATHS[1]).resolve()),
('project', (cwd / PROJECT_SETTINGS_PATH).resolve()),
('local', (cwd / LOCAL_SETTINGS_PATH).resolve()),
]
discovered: list[tuple[str, Path]] = []
seen: set[Path] = set()
for source_name, path in candidates:
if path in seen or not path.exists() or not path.is_file():
continue
seen.add(path)
discovered.append((source_name, path))
return tuple(discovered)
def _load_json_object(path: Path) -> dict[str, Any] | None:
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return None
return dict(payload) if isinstance(payload, dict) else None
def _flatten_keys(payload: dict[str, Any], *, prefix: str = '') -> tuple[str, ...]:
keys: list[str] = []
for key, value in payload.items():
if not isinstance(key, str):
continue
combined = f'{prefix}.{key}' if prefix else key
keys.append(combined)
if isinstance(value, dict):
keys.extend(_flatten_keys(value, prefix=combined))
return tuple(keys)
def _get_nested_value(payload: dict[str, Any], key_path: str) -> Any:
current: Any = payload
for segment in _split_key_path(key_path):
if not isinstance(current, dict) or segment not in current:
raise KeyError(key_path)
current = current[segment]
return current
def _set_nested_value(payload: dict[str, Any], key_path: str, value: Any) -> None:
current: dict[str, Any] = payload
segments = _split_key_path(key_path)
for segment in segments[:-1]:
child = current.get(segment)
if not isinstance(child, dict):
child = {}
current[segment] = child
current = child
current[segments[-1]] = value
def _split_key_path(key_path: str) -> tuple[str, ...]:
segments = tuple(
segment.strip()
for segment in key_path.split('.')
if segment.strip()
)
if not segments:
raise KeyError(key_path)
return segments
def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
merged = dict(base)
for key, value in overlay.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = _deep_merge(merged[key], value)
else:
merged[key] = value
return merged
def _normalize_source_name(source: str) -> str:
return source.strip().lower().replace('_', '-')
def _sha256(text: str) -> str:
return hashlib.sha256(text.encode('utf-8')).hexdigest()
def _sha256_or_none(text: str | None) -> str | None:
if text is None:
return None
return _sha256(text)
def _preview(text: str | None, limit: int = 220) -> str | None:
if text is None:
return None
stripped = text.strip()
if len(stripped) <= limit:
return stripped
return stripped[:limit] + '...'
+344 -53
View File
@@ -9,6 +9,7 @@ import json
from typing import Callable
from .background_runtime import BackgroundSessionRuntime, build_background_worker_command
from .account_runtime import AccountRuntime
from .agent_runtime import LocalCodingAgent
from .agent_types import (
AgentPermissions,
@@ -21,12 +22,21 @@ from .agent_types import (
from .bootstrap_graph import build_bootstrap_graph
from .command_graph import build_command_graph
from .commands import execute_command, get_command, get_commands, render_command_index
from .direct_modes import run_deep_link, run_direct_connect
from .config_runtime import ConfigRuntime
from .mcp_runtime import MCPRuntime
from .parity_audit import run_parity_audit
from .permissions import ToolPermissionContext
from .port_manifest import build_port_manifest
from .query_engine import QueryEnginePort
from .remote_runtime import run_remote_mode, run_ssh_mode, run_teleport_mode
from .remote_runtime import (
RemoteRuntime,
run_deep_link_mode,
run_direct_connect_mode,
run_remote_mode,
run_ssh_mode,
run_teleport_mode,
)
from .search_runtime import SearchRuntime
from .runtime import PortRuntime
from .session_store import (
StoredAgentSession,
@@ -248,6 +258,58 @@ def _add_agent_resume_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument('--scratchpad-root')
def _launch_background_agent(args: argparse.Namespace) -> int:
background_runtime = BackgroundSessionRuntime()
background_id = background_runtime.create_id()
forwarded_args: list[str] = []
_append_agent_forwarded_args(forwarded_args, args, include_backend=True)
forwarded_args.extend(['--background-root', str(background_runtime.root)])
command = build_background_worker_command(
background_id=background_id,
prompt=args.prompt,
forwarded_args=forwarded_args,
)
record = background_runtime.launch(
command,
prompt=args.prompt,
workspace_cwd=Path(args.cwd).resolve(),
model=args.model,
background_id=background_id,
process_cwd=Path(__file__).resolve().parent.parent,
)
print('# Background Session')
print(f'background_id={record.background_id}')
print(f'pid={record.pid}')
print(f'log_path={record.log_path}')
print(f'record_path={record.record_path}')
return 0
def _run_background_worker(args: argparse.Namespace) -> int:
background_runtime = BackgroundSessionRuntime(Path(args.background_root))
exit_code = 1
stop_reason = 'worker_failed'
session_id = None
session_path = None
try:
agent = _build_agent(args)
result = agent.run(args.prompt)
_print_agent_result(result, show_transcript=args.show_transcript)
exit_code = 0
stop_reason = result.stop_reason or 'completed'
session_id = result.session_id
session_path = result.session_path
return 0
finally:
background_runtime.mark_finished(
args.background_id,
exit_code=exit_code,
stop_reason=stop_reason,
session_id=session_id,
session_path=session_path,
)
def _build_resumed_agent(args: argparse.Namespace) -> tuple[LocalCodingAgent, StoredAgentSession]:
stored_session = load_agent_session(args.session_id)
model_config = deserialize_model_config(stored_session.model_config)
@@ -519,14 +581,86 @@ def build_parser() -> argparse.ArgumentParser:
remote_parser = subparsers.add_parser('remote-mode', help='simulate remote-control runtime branching')
remote_parser.add_argument('target')
remote_parser.add_argument('--cwd', default='.')
ssh_parser = subparsers.add_parser('ssh-mode', help='simulate SSH runtime branching')
ssh_parser.add_argument('target')
ssh_parser.add_argument('--cwd', default='.')
teleport_parser = subparsers.add_parser('teleport-mode', help='simulate teleport runtime branching')
teleport_parser.add_argument('target')
teleport_parser.add_argument('--cwd', default='.')
direct_parser = subparsers.add_parser('direct-connect-mode', help='simulate direct-connect runtime branching')
direct_parser.add_argument('target')
direct_parser.add_argument('--cwd', default='.')
deep_link_parser = subparsers.add_parser('deep-link-mode', help='simulate deep-link runtime branching')
deep_link_parser.add_argument('target')
deep_link_parser.add_argument('--cwd', default='.')
remote_status_parser = subparsers.add_parser('remote-status', help='show local remote runtime status')
remote_status_parser.add_argument('--cwd', default='.')
remote_profiles_parser = subparsers.add_parser('remote-profiles', help='list configured local remote profiles')
remote_profiles_parser.add_argument('--cwd', default='.')
remote_profiles_parser.add_argument('--query')
remote_disconnect_parser = subparsers.add_parser('remote-disconnect', help='disconnect the active local remote target')
remote_disconnect_parser.add_argument('--cwd', default='.')
account_status_parser = subparsers.add_parser('account-status', help='show local account runtime status')
account_status_parser.add_argument('--cwd', default='.')
account_profiles_parser = subparsers.add_parser('account-profiles', help='list configured local account profiles')
account_profiles_parser.add_argument('--cwd', default='.')
account_profiles_parser.add_argument('--query')
account_login_parser = subparsers.add_parser('account-login', help='activate a local account profile or ephemeral identity')
account_login_parser.add_argument('target')
account_login_parser.add_argument('--provider')
account_login_parser.add_argument('--auth-mode')
account_login_parser.add_argument('--cwd', default='.')
account_logout_parser = subparsers.add_parser('account-logout', help='clear the active local account session')
account_logout_parser.add_argument('--cwd', default='.')
search_status_parser = subparsers.add_parser('search-status', help='show local search runtime status')
search_status_parser.add_argument('--cwd', default='.')
search_status_parser.add_argument('--provider')
search_providers_parser = subparsers.add_parser('search-providers', help='list configured local search providers')
search_providers_parser.add_argument('--cwd', default='.')
search_providers_parser.add_argument('--query')
search_activate_parser = subparsers.add_parser('search-activate', help='set the active local search provider')
search_activate_parser.add_argument('provider')
search_activate_parser.add_argument('--cwd', default='.')
search_parser = subparsers.add_parser('search', help='run a real web search against the configured local search runtime')
search_parser.add_argument('query')
search_parser.add_argument('--cwd', default='.')
search_parser.add_argument('--provider')
search_parser.add_argument('--max-results', type=int, default=5)
search_parser.add_argument('--domain', action='append', default=[])
mcp_status_parser = subparsers.add_parser('mcp-status', help='show local MCP runtime status')
mcp_status_parser.add_argument('--cwd', default='.')
mcp_resources_parser = subparsers.add_parser('mcp-resources', help='list MCP resources discovered through local manifests and transport-backed servers')
mcp_resources_parser.add_argument('--cwd', default='.')
mcp_resources_parser.add_argument('--query')
mcp_resource_parser = subparsers.add_parser('mcp-resource', help='read an MCP resource by URI')
mcp_resource_parser.add_argument('uri')
mcp_resource_parser.add_argument('--cwd', default='.')
mcp_tools_parser = subparsers.add_parser('mcp-tools', help='list MCP tools exposed by configured MCP servers')
mcp_tools_parser.add_argument('--cwd', default='.')
mcp_tools_parser.add_argument('--query')
mcp_tools_parser.add_argument('--server')
mcp_call_tool_parser = subparsers.add_parser('mcp-call-tool', help='call an MCP tool exposed by a configured MCP server')
mcp_call_tool_parser.add_argument('tool_name')
mcp_call_tool_parser.add_argument('--arguments-json', default='{}')
mcp_call_tool_parser.add_argument('--server')
mcp_call_tool_parser.add_argument('--cwd', default='.')
config_status_parser = subparsers.add_parser('config-status', help='show local workspace config runtime summary')
config_status_parser.add_argument('--cwd', default='.')
config_effective_parser = subparsers.add_parser('config-effective', help='render the merged effective local workspace config')
config_effective_parser.add_argument('--cwd', default='.')
config_source_parser = subparsers.add_parser('config-source', help='render a specific local config source')
config_source_parser.add_argument('source')
config_source_parser.add_argument('--cwd', default='.')
config_get_parser = subparsers.add_parser('config-get', help='read a local config value by dotted key path')
config_get_parser.add_argument('key_path')
config_get_parser.add_argument('--source')
config_get_parser.add_argument('--cwd', default='.')
config_set_parser = subparsers.add_parser('config-set', help='write a local config value by dotted key path')
config_set_parser.add_argument('key_path')
config_set_parser.add_argument('value_json')
config_set_parser.add_argument('--source', default='local')
config_set_parser.add_argument('--cwd', default='.')
show_command = subparsers.add_parser('show-command', help='show one mirrored command entry by exact name')
show_command.add_argument('name')
@@ -575,6 +709,38 @@ def build_parser() -> argparse.ArgumentParser:
kill_parser = subparsers.add_parser('agent-kill', help='stop a local background agent session')
kill_parser.add_argument('background_id')
daemon_parser = subparsers.add_parser('daemon', help='manage local daemon-style background agent sessions')
daemon_subparsers = daemon_parser.add_subparsers(dest='daemon_command')
daemon_subparsers.required = True
daemon_start_parser = daemon_subparsers.add_parser('start', help='launch a local daemon-style background agent session')
daemon_start_parser.add_argument('prompt')
daemon_start_parser.add_argument('--max-turns', type=int, default=12)
daemon_start_parser.add_argument('--show-transcript', action='store_true')
_add_agent_common_args(daemon_start_parser, include_backend=True)
daemon_worker_parser = daemon_subparsers.add_parser('worker', help=argparse.SUPPRESS)
daemon_worker_parser.add_argument('background_id')
daemon_worker_parser.add_argument('prompt')
daemon_worker_parser.add_argument('--background-root', required=True)
daemon_worker_parser.add_argument('--max-turns', type=int, default=12)
daemon_worker_parser.add_argument('--show-transcript', action='store_true')
_add_agent_common_args(daemon_worker_parser, include_backend=True)
daemon_ps_parser = daemon_subparsers.add_parser('ps', help='list local daemon-style background sessions')
daemon_ps_parser.add_argument('--tail', type=int, default=None)
daemon_logs_parser = daemon_subparsers.add_parser('logs', help='show logs for a local daemon-style background session')
daemon_logs_parser.add_argument('background_id')
daemon_logs_parser.add_argument('--tail', type=int, default=None)
daemon_attach_parser = daemon_subparsers.add_parser('attach', help='show the current output snapshot for a local daemon-style background session')
daemon_attach_parser.add_argument('background_id')
daemon_attach_parser.add_argument('--tail', type=int, default=None)
daemon_kill_parser = daemon_subparsers.add_parser('kill', help='stop a local daemon-style background session')
daemon_kill_parser.add_argument('background_id')
chat_parser = subparsers.add_parser('agent-chat', help='run an interactive Python local-model chat loop')
chat_parser.add_argument('prompt', nargs='?')
chat_parser.add_argument('--resume-session-id')
@@ -687,19 +853,155 @@ def main(argv: list[str] | None = None) -> int:
print(f'{session.session_id}\n{len(session.messages)} messages\nin={session.input_tokens} out={session.output_tokens}')
return 0
if args.command == 'remote-mode':
print(run_remote_mode(args.target).as_text())
print(run_remote_mode(args.target, cwd=Path(args.cwd).resolve()).as_text())
return 0
if args.command == 'ssh-mode':
print(run_ssh_mode(args.target).as_text())
print(run_ssh_mode(args.target, cwd=Path(args.cwd).resolve()).as_text())
return 0
if args.command == 'teleport-mode':
print(run_teleport_mode(args.target).as_text())
print(run_teleport_mode(args.target, cwd=Path(args.cwd).resolve()).as_text())
return 0
if args.command == 'direct-connect-mode':
print(run_direct_connect(args.target).as_text())
print(run_direct_connect_mode(args.target, cwd=Path(args.cwd).resolve()).as_text())
return 0
if args.command == 'deep-link-mode':
print(run_deep_link(args.target).as_text())
print(run_deep_link_mode(args.target, cwd=Path(args.cwd).resolve()).as_text())
return 0
if args.command == 'remote-status':
runtime = RemoteRuntime.from_workspace(Path(args.cwd).resolve())
print('# Remote')
print()
print(runtime.render_summary())
return 0
if args.command == 'remote-profiles':
runtime = RemoteRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.render_profiles_index(query=args.query))
return 0
if args.command == 'remote-disconnect':
runtime = RemoteRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.disconnect().as_text())
return 0
if args.command == 'account-status':
runtime = AccountRuntime.from_workspace(Path(args.cwd).resolve())
print('# Account')
print()
print(runtime.render_summary())
return 0
if args.command == 'account-profiles':
runtime = AccountRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.render_profiles_index(query=args.query))
return 0
if args.command == 'account-login':
runtime = AccountRuntime.from_workspace(Path(args.cwd).resolve())
print(
runtime.login(
args.target,
provider=args.provider,
auth_mode=args.auth_mode,
).as_text()
)
return 0
if args.command == 'account-logout':
runtime = AccountRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.logout().as_text())
return 0
if args.command == 'search-status':
runtime = SearchRuntime.from_workspace(Path(args.cwd).resolve())
if args.provider:
print(runtime.render_provider(args.provider))
else:
print('# Search')
print()
print(runtime.render_summary())
return 0
if args.command == 'search-providers':
runtime = SearchRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.render_providers_index(query=args.query))
return 0
if args.command == 'search-activate':
runtime = SearchRuntime.from_workspace(Path(args.cwd).resolve())
try:
report = runtime.activate_provider(args.provider)
except KeyError:
print(f'Unknown search provider: {args.provider}')
return 1
print(report.as_text())
return 0
if args.command == 'search':
runtime = SearchRuntime.from_workspace(Path(args.cwd).resolve())
try:
output = runtime.render_search_results(
args.query,
provider_name=args.provider,
max_results=args.max_results,
domains=tuple(args.domain),
)
except (KeyError, LookupError, OSError, ValueError) as exc:
print(f'Search failed: {exc}')
return 1
print(output)
return 0
if args.command == 'mcp-status':
runtime = MCPRuntime.from_workspace(Path(args.cwd).resolve())
print('# MCP')
print()
print(runtime.render_summary())
return 0
if args.command == 'mcp-resources':
runtime = MCPRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.render_resource_index(query=args.query))
return 0
if args.command == 'mcp-resource':
runtime = MCPRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.render_resource(args.uri))
return 0
if args.command == 'mcp-tools':
runtime = MCPRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.render_tool_index(query=args.query, server_name=args.server))
return 0
if args.command == 'mcp-call-tool':
runtime = MCPRuntime.from_workspace(Path(args.cwd).resolve())
arguments = json.loads(args.arguments_json)
if not isinstance(arguments, dict):
print('arguments-json must decode to a JSON object')
return 1
print(
runtime.render_tool_call(
args.tool_name,
arguments=arguments,
server_name=args.server,
)
)
return 0
if args.command == 'config-status':
runtime = ConfigRuntime.from_workspace(Path(args.cwd).resolve())
print('# Config')
print()
print(runtime.render_summary())
return 0
if args.command == 'config-effective':
runtime = ConfigRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.render_effective_config())
return 0
if args.command == 'config-source':
runtime = ConfigRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.render_source(args.source))
return 0
if args.command == 'config-get':
runtime = ConfigRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.render_value(args.key_path, source=args.source))
return 0
if args.command == 'config-set':
runtime = ConfigRuntime.from_workspace(Path(args.cwd).resolve())
value = json.loads(args.value_json)
mutation = runtime.set_value(args.key_path, value, source=args.source)
print('# Config')
print()
print(f'source={mutation.source_name}')
print(f'key_path={mutation.key_path}')
print(f'store_path={mutation.store_path}')
print(f'effective_key_count={mutation.effective_key_count}')
print(runtime.render_value(args.key_path))
return 0
if args.command == 'show-command':
module = get_command(args.name)
@@ -729,53 +1031,9 @@ def main(argv: list[str] | None = None) -> int:
_print_agent_result(result, show_transcript=args.show_transcript)
return 0
if args.command == 'agent-bg':
background_runtime = BackgroundSessionRuntime()
background_id = background_runtime.create_id()
forwarded_args: list[str] = []
_append_agent_forwarded_args(forwarded_args, args, include_backend=True)
forwarded_args.extend(['--background-root', str(background_runtime.root)])
command = build_background_worker_command(
background_id=background_id,
prompt=args.prompt,
forwarded_args=forwarded_args,
)
record = background_runtime.launch(
command,
prompt=args.prompt,
workspace_cwd=Path(args.cwd).resolve(),
model=args.model,
background_id=background_id,
process_cwd=Path(__file__).resolve().parent.parent,
)
print('# Background Session')
print(f'background_id={record.background_id}')
print(f'pid={record.pid}')
print(f'log_path={record.log_path}')
print(f'record_path={record.record_path}')
return 0
return _launch_background_agent(args)
if args.command == 'agent-bg-worker':
background_runtime = BackgroundSessionRuntime(Path(args.background_root))
exit_code = 1
stop_reason = 'worker_failed'
session_id = None
session_path = None
try:
agent = _build_agent(args)
result = agent.run(args.prompt)
_print_agent_result(result, show_transcript=args.show_transcript)
exit_code = 0
stop_reason = result.stop_reason or 'completed'
session_id = result.session_id
session_path = result.session_path
return 0
finally:
background_runtime.mark_finished(
args.background_id,
exit_code=exit_code,
stop_reason=stop_reason,
session_id=session_id,
session_path=session_path,
)
return _run_background_worker(args)
if args.command == 'agent-ps':
print(BackgroundSessionRuntime().render_ps())
return 0
@@ -804,6 +1062,39 @@ def main(argv: list[str] | None = None) -> int:
if record.exit_code is not None:
print(f'exit_code={record.exit_code}')
return 0
if args.command == 'daemon':
if args.daemon_command == 'start':
return _launch_background_agent(args)
if args.daemon_command == 'worker':
return _run_background_worker(args)
if args.daemon_command == 'ps':
print(BackgroundSessionRuntime().render_ps())
return 0
if args.daemon_command == 'logs':
print(
BackgroundSessionRuntime().render_logs(
args.background_id,
tail=args.tail,
)
)
return 0
if args.daemon_command == 'attach':
print(
BackgroundSessionRuntime().render_attach(
args.background_id,
tail=args.tail,
)
)
return 0
if args.daemon_command == 'kill':
record = BackgroundSessionRuntime().kill(args.background_id)
print('# Background Session')
print(f'background_id={record.background_id}')
print(f'status={record.status}')
print(f'pid={record.pid}')
if record.exit_code is not None:
print(f'exit_code={record.exit_code}')
return 0
if args.command == 'agent-chat':
agent = _build_agent(args)
return _run_agent_chat_loop(
+655 -50
View File
@@ -1,11 +1,18 @@
from __future__ import annotations
import json
import os
import selectors
import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
MCP_PROTOCOL_VERSION = '2025-11-25'
@dataclass(frozen=True)
class MCPResource:
uri: str
@@ -19,9 +26,33 @@ class MCPResource:
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class MCPTool:
name: str
server_name: str
source_manifest: str
description: str | None = None
input_schema: dict[str, Any] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class MCPServerProfile:
name: str
source_manifest: str
transport: str
command: str | None = None
args: tuple[str, ...] = ()
env: dict[str, str] = field(default_factory=dict)
cwd: str | None = None
description: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass
class MCPRuntime:
resources: tuple[MCPResource, ...] = field(default_factory=tuple)
servers: tuple[MCPServerProfile, ...] = field(default_factory=tuple)
@classmethod
def from_workspace(
@@ -30,77 +61,165 @@ class MCPRuntime:
additional_working_directories: tuple[str, ...] = (),
) -> 'MCPRuntime':
resources: list[MCPResource] = []
servers: list[MCPServerProfile] = []
for path in _discover_manifest_paths(cwd, additional_working_directories):
resources.extend(_load_resources_from_manifest(path))
return cls(resources=tuple(resources))
manifest_resources, manifest_servers = _load_manifest(path)
resources.extend(manifest_resources)
servers.extend(manifest_servers)
return cls(
resources=tuple(resources),
servers=tuple(_dedupe_servers(servers)),
)
@property
def manifests(self) -> tuple[str, ...]:
seen: list[str] = []
for resource in self.resources:
if resource.source_manifest not in seen:
seen.append(resource.source_manifest)
for entry in [*self.resources, *self.servers]:
source_manifest = entry.source_manifest
if source_manifest not in seen:
seen.append(source_manifest)
return tuple(seen)
def has_transport_servers(self) -> bool:
return any(server.transport == 'stdio' for server in self.servers)
def list_resources(
self,
*,
query: str | None = None,
limit: int | None = None,
) -> tuple[MCPResource, ...]:
resources = self.resources
if query:
needle = query.lower()
resources = tuple(
resource
for resource in resources
if needle in resource.uri.lower()
or needle in resource.server_name.lower()
or needle in (resource.name or '').lower()
or needle in (resource.description or '').lower()
)
resources = list(self.resources)
resources.extend(self._list_remote_resources())
filtered = _filter_resources(tuple(resources), query=query)
if limit is not None and limit >= 0:
resources = resources[:limit]
return resources
filtered = filtered[:limit]
return filtered
def get_resource(self, uri: str) -> MCPResource | None:
for resource in self.resources:
if resource.uri == uri:
return resource
for resource in self._list_remote_resources():
if resource.uri == uri:
return resource
return None
def read_resource(self, uri: str, *, max_chars: int = 12000) -> str:
resource = self.get_resource(uri)
if resource is None:
raise FileNotFoundError(f'Unknown MCP resource: {uri}')
if resource.inline_text is not None:
return _truncate(resource.inline_text, max_chars)
if resource.resolved_path is None:
raise FileNotFoundError(f'MCP resource has no readable content: {uri}')
path = Path(resource.resolved_path)
if not path.exists() or not path.is_file():
raise FileNotFoundError(f'MCP resource file not found: {path}')
text = path.read_text(encoding='utf-8', errors='replace')
return _truncate(text, max_chars)
for resource in self.resources:
if resource.uri != uri:
continue
if resource.inline_text is not None:
return _truncate(resource.inline_text, max_chars)
if resource.resolved_path is not None:
path = Path(resource.resolved_path)
if not path.exists() or not path.is_file():
raise FileNotFoundError(f'MCP resource file not found: {path}')
text = path.read_text(encoding='utf-8', errors='replace')
return _truncate(text, max_chars)
last_error: Exception | None = None
candidate_servers: list[MCPServerProfile] = []
discovered = self.get_resource(uri)
if discovered is not None:
server = self.get_server(discovered.server_name)
if server is not None:
candidate_servers.append(server)
for server in self.servers:
if server.transport != 'stdio':
continue
if all(existing.name != server.name for existing in candidate_servers):
candidate_servers.append(server)
for server in candidate_servers:
try:
result = _request_stdio(server, 'resources/read', {'uri': uri})
except Exception as exc:
last_error = exc
continue
rendered = _render_resource_contents(result.get('contents'))
if rendered:
return _truncate(rendered, max_chars)
if last_error is not None:
raise FileNotFoundError(f'Unable to read MCP resource {uri}: {last_error}') from last_error
raise FileNotFoundError(f'Unknown MCP resource: {uri}')
def list_tools(
self,
*,
query: str | None = None,
server_name: str | None = None,
limit: int | None = None,
) -> tuple[MCPTool, ...]:
tools = self._list_remote_tools(server_name=server_name)
if query:
needle = query.lower()
tools = tuple(
tool
for tool in tools
if needle in tool.name.lower()
or needle in (tool.description or '').lower()
or needle in tool.server_name.lower()
)
if limit is not None and limit >= 0:
tools = tools[:limit]
return tools
def call_tool(
self,
tool_name: str,
*,
arguments: dict[str, Any] | None = None,
server_name: str | None = None,
max_chars: int = 12000,
) -> tuple[str, dict[str, Any]]:
tool = self._resolve_tool(tool_name, server_name=server_name)
server = self.get_server(tool.server_name)
if server is None:
raise FileNotFoundError(f'Unknown MCP server: {tool.server_name}')
payload = {
'name': tool.name,
'arguments': dict(arguments or {}),
}
result = _request_stdio(server, 'tools/call', payload)
rendered = _truncate(_render_tool_call_result(result), max_chars)
metadata = {
'server_name': tool.server_name,
'tool_name': tool.name,
'is_error': bool(result.get('isError')),
}
return rendered, metadata
def get_server(self, name: str) -> MCPServerProfile | None:
needle = name.strip().lower()
if not needle:
return None
for server in self.servers:
if server.name.lower() == needle:
return server
return None
def render_summary(self) -> str:
if not self.resources:
return 'No local MCP manifests or resources discovered.'
if not self.resources and not self.servers:
return 'No local MCP manifests, servers, or resources discovered.'
lines = [
f'Local MCP manifests: {len(self.manifests)}',
f'Local MCP resources: {len(self.resources)}',
f'Configured MCP servers: {len(self.servers)}',
]
transport_counts: dict[str, int] = {}
for server in self.servers:
transport_counts[server.transport] = transport_counts.get(server.transport, 0) + 1
for transport, count in sorted(transport_counts.items()):
lines.append(f'- {transport}: {count} server(s)')
by_server: dict[str, int] = {}
for resource in self.resources:
by_server[resource.server_name] = by_server.get(resource.server_name, 0) + 1
for server_name, count in sorted(by_server.items()):
lines.append(f'- {server_name}: {count} resource(s)')
for manifest in self.manifests[:10]:
manifest_name = Path(manifest).name
manifest_count = sum(
1 for resource in self.resources if resource.source_manifest == manifest
)
lines.append(f'- {manifest_name}: {manifest_count} resource(s)')
lines.append(f'- local resources for {server_name}: {count}')
for server in self.servers[:10]:
details = [server.name, server.transport]
if server.command:
details.append(server.command)
lines.append('- Server: ' + ' ; '.join(details))
return '\n'.join(lines)
def render_resource_index(
@@ -122,6 +241,10 @@ class MCPRuntime:
details.append(f'mime={resource.mime_type}')
if resource.resolved_path:
details.append(f'path={resource.resolved_path}')
elif resource.inline_text is not None:
details.append('source=inline')
else:
details.append('source=transport')
lines.append('- ' + '; '.join(details))
return '\n'.join(lines)
@@ -144,6 +267,93 @@ class MCPRuntime:
lines.extend(['', self.read_resource(uri, max_chars=max_chars)])
return '\n'.join(lines)
def render_tool_index(
self,
*,
query: str | None = None,
server_name: str | None = None,
limit: int = 50,
) -> str:
tools = self.list_tools(query=query, server_name=server_name, limit=limit)
if not tools:
return '# MCP Tools\n\nNo matching MCP tools discovered.'
lines = ['# MCP Tools', '']
for tool in tools:
details = [tool.name, f'server={tool.server_name}']
if tool.description:
details.append(tool.description)
lines.append('- ' + ' ; '.join(details))
return '\n'.join(lines)
def render_tool_call(
self,
tool_name: str,
*,
arguments: dict[str, Any] | None = None,
server_name: str | None = None,
max_chars: int = 12000,
) -> str:
content, metadata = self.call_tool(
tool_name,
arguments=arguments,
server_name=server_name,
max_chars=max_chars,
)
lines = [
'# MCP Tool Result',
'',
f'- Tool: {tool_name}',
f'- Server: {metadata["server_name"]}',
f'- is_error: {metadata["is_error"]}',
'',
content,
]
return '\n'.join(lines)
def _list_remote_resources(self) -> tuple[MCPResource, ...]:
discovered: list[MCPResource] = []
for server in self.servers:
if server.transport != 'stdio':
continue
try:
result = _request_stdio(server, 'resources/list', {})
except OSError:
continue
for item in _extract_remote_resources(server, result):
discovered.append(item)
return tuple(discovered)
def _list_remote_tools(self, *, server_name: str | None = None) -> tuple[MCPTool, ...]:
discovered: list[MCPTool] = []
candidate_servers = (
[self.get_server(server_name)] if server_name else list(self.servers)
)
for server in candidate_servers:
if server is None or server.transport != 'stdio':
continue
try:
result = _request_stdio(server, 'tools/list', {})
except OSError:
continue
for item in _extract_remote_tools(server, result):
discovered.append(item)
return tuple(discovered)
def _resolve_tool(self, tool_name: str, server_name: str | None = None) -> MCPTool:
tools = self.list_tools(server_name=server_name)
matches = [tool for tool in tools if tool.name == tool_name]
if server_name:
if not matches:
raise FileNotFoundError(f'Unknown MCP tool: {tool_name} on server {server_name}')
return matches[0]
if not matches:
raise FileNotFoundError(f'Unknown MCP tool: {tool_name}')
if len(matches) > 1:
raise FileNotFoundError(
f'MCP tool {tool_name} exists on multiple servers. Pass server_name to disambiguate.'
)
return matches[0]
def _discover_manifest_paths(
cwd: Path,
@@ -176,15 +386,17 @@ def _discover_manifest_paths(
return tuple(candidates)
def _load_resources_from_manifest(path: Path) -> list[MCPResource]:
def _load_manifest(path: Path) -> tuple[list[MCPResource], list[MCPServerProfile]]:
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return []
return [], []
if not isinstance(payload, dict):
return []
return [], []
resources: list[MCPResource] = []
servers: list[MCPServerProfile] = []
if isinstance(payload.get('resources'), list):
resources.extend(
_extract_resources(
@@ -193,21 +405,86 @@ def _load_resources_from_manifest(path: Path) -> list[MCPResource]:
manifest_path=path,
)
)
servers = payload.get('servers')
if isinstance(servers, list):
for item in servers:
raw_servers = payload.get('servers')
if isinstance(raw_servers, list):
for item in raw_servers:
if not isinstance(item, dict):
continue
name = item.get('name')
if not isinstance(name, str) or not name.strip():
continue
server_name = name.strip()
raw_resources = item.get('resources')
if not isinstance(raw_resources, list):
if isinstance(raw_resources, list):
resources.extend(
_extract_resources(server_name, raw_resources, manifest_path=path)
)
server = _extract_server_profile(server_name, item, manifest_path=path)
if server is not None:
servers.append(server)
raw_mcp_servers = payload.get('mcpServers')
if isinstance(raw_mcp_servers, dict):
for server_name, item in raw_mcp_servers.items():
if not isinstance(server_name, str) or not server_name.strip():
continue
resources.extend(
_extract_resources(name.strip(), raw_resources, manifest_path=path)
)
return resources
if not isinstance(item, dict):
continue
server = _extract_server_profile(server_name.strip(), item, manifest_path=path)
if server is not None:
servers.append(server)
return resources, servers
def _extract_server_profile(
server_name: str,
payload: dict[str, Any],
*,
manifest_path: Path,
) -> MCPServerProfile | None:
command = payload.get('command')
if not isinstance(command, str) or not command.strip():
return None
args = payload.get('args', ())
if not isinstance(args, list):
args = ()
normalized_args = tuple(
item for item in args if isinstance(item, str)
)
env = payload.get('env')
normalized_env = {
key: value
for key, value in (env.items() if isinstance(env, dict) else [])
if isinstance(key, str) and isinstance(value, str)
}
cwd = payload.get('cwd')
resolved_cwd: str | None = None
if isinstance(cwd, str) and cwd.strip():
candidate = Path(cwd).expanduser()
if not candidate.is_absolute():
candidate = manifest_path.parent / candidate
resolved_cwd = str(candidate.resolve())
description = payload.get('description') if isinstance(payload.get('description'), str) else None
transport = payload.get('transport')
if not isinstance(transport, str) or not transport.strip():
transport = 'stdio'
transport = transport.strip().lower()
if transport != 'stdio':
return None
metadata = payload.get('metadata')
return MCPServerProfile(
name=server_name,
source_manifest=str(manifest_path),
transport=transport,
command=command.strip(),
args=normalized_args,
env=normalized_env,
cwd=resolved_cwd,
description=description,
metadata=dict(metadata) if isinstance(metadata, dict) else {},
)
def _extract_resources(
@@ -267,6 +544,334 @@ def _extract_resources(
return resources
def _extract_remote_resources(
server: MCPServerProfile,
payload: dict[str, Any],
) -> tuple[MCPResource, ...]:
raw_resources = payload.get('resources')
if not isinstance(raw_resources, list):
return ()
resources: list[MCPResource] = []
for item in raw_resources:
if not isinstance(item, dict):
continue
uri = item.get('uri')
if not isinstance(uri, str) or not uri.strip():
continue
resources.append(
MCPResource(
uri=uri.strip(),
server_name=server.name,
source_manifest=server.source_manifest,
name=item.get('name') if isinstance(item.get('name'), str) else None,
description=(
item.get('description')
if isinstance(item.get('description'), str)
else None
),
mime_type=(
item.get('mimeType')
if isinstance(item.get('mimeType'), str)
else item.get('mime_type')
if isinstance(item.get('mime_type'), str)
else None
),
metadata={
'transport': server.transport,
'server_command': server.command,
},
)
)
return tuple(resources)
def _extract_remote_tools(
server: MCPServerProfile,
payload: dict[str, Any],
) -> tuple[MCPTool, ...]:
raw_tools = payload.get('tools')
if not isinstance(raw_tools, list):
return ()
tools: list[MCPTool] = []
for item in raw_tools:
if not isinstance(item, dict):
continue
name = item.get('name')
if not isinstance(name, str) or not name.strip():
continue
input_schema = item.get('inputSchema')
if not isinstance(input_schema, dict):
input_schema = item.get('input_schema')
tools.append(
MCPTool(
name=name.strip(),
server_name=server.name,
source_manifest=server.source_manifest,
description=(
item.get('description')
if isinstance(item.get('description'), str)
else None
),
input_schema=dict(input_schema) if isinstance(input_schema, dict) else {},
metadata={
'transport': server.transport,
'server_command': server.command,
},
)
)
return tuple(tools)
def _filter_resources(
resources: tuple[MCPResource, ...],
*,
query: str | None = None,
) -> tuple[MCPResource, ...]:
if not query:
return resources
needle = query.lower()
return tuple(
resource
for resource in resources
if needle in resource.uri.lower()
or needle in resource.server_name.lower()
or needle in (resource.name or '').lower()
or needle in (resource.description or '').lower()
)
def _dedupe_servers(servers: list[MCPServerProfile]) -> list[MCPServerProfile]:
seen: set[tuple[str, str, str | None, tuple[str, ...]]] = set()
deduped: list[MCPServerProfile] = []
for server in servers:
key = (server.name.lower(), server.transport, server.command, server.args)
if key in seen:
continue
seen.add(key)
deduped.append(server)
return deduped
def _request_stdio(
server: MCPServerProfile,
method: str,
params: dict[str, Any],
*,
timeout_seconds: float = 10.0,
) -> dict[str, Any]:
with _StdioMCPConnection(server, timeout_seconds=timeout_seconds) as connection:
return connection.request(method, params)
class _StdioMCPConnection:
def __init__(self, server: MCPServerProfile, *, timeout_seconds: float = 10.0) -> None:
self.server = server
self.timeout_seconds = timeout_seconds
self.process: subprocess.Popen[str] | None = None
self.selector: selectors.BaseSelector | None = None
self.stderr_lines: list[str] = []
self._request_id = 0
def __enter__(self) -> '_StdioMCPConnection':
try:
command = [self.server.command or '', *self.server.args]
if not command[0]:
raise OSError(f'MCP server {self.server.name} has no executable command')
env = os.environ.copy()
env.update(self.server.env)
self.process = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
cwd=self.server.cwd or None,
env=env,
)
self.selector = selectors.DefaultSelector()
assert self.process.stdout is not None
assert self.process.stderr is not None
self.selector.register(self.process.stdout, selectors.EVENT_READ, data='stdout')
self.selector.register(self.process.stderr, selectors.EVENT_READ, data='stderr')
self._initialize()
return self
except Exception:
self.close()
raise
def __exit__(self, exc_type, exc, tb) -> None:
self.close()
def close(self) -> None:
process = self.process
if self.selector is not None:
try:
self.selector.close()
except Exception:
pass
self.selector = None
if process is None:
return
try:
if process.stdin is not None:
process.stdin.close()
except Exception:
pass
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=1.0)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=1.0)
for stream_name in ('stdout', 'stderr'):
stream = getattr(process, stream_name, None)
if stream is not None:
try:
stream.close()
except Exception:
pass
self.process = None
def request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
self._request_id += 1
request_id = self._request_id
self._send(
{
'jsonrpc': '2.0',
'id': request_id,
'method': method,
'params': params,
}
)
response = self._await_response(request_id)
error = response.get('error')
if isinstance(error, dict):
message = error.get('message')
raise OSError(
f'MCP {method} failed for server {self.server.name}: {message or error}'
)
result = response.get('result')
if not isinstance(result, dict):
return {}
return result
def _initialize(self) -> None:
self._request_id += 1
request_id = self._request_id
self._send(
{
'jsonrpc': '2.0',
'id': request_id,
'method': 'initialize',
'params': {
'protocolVersion': MCP_PROTOCOL_VERSION,
'capabilities': {},
'clientInfo': {
'name': 'claw-code-agent',
'version': '0.1.0',
},
},
}
)
response = self._await_response(request_id)
error = response.get('error')
if isinstance(error, dict):
raise OSError(
f'MCP initialize failed for server {self.server.name}: {error.get("message") or error}'
)
self._send(
{
'jsonrpc': '2.0',
'method': 'notifications/initialized',
'params': {},
}
)
def _send(self, payload: dict[str, Any]) -> None:
if self.process is None or self.process.stdin is None:
raise OSError(f'MCP server {self.server.name} is not running')
self.process.stdin.write(json.dumps(payload, ensure_ascii=True) + '\n')
self.process.stdin.flush()
def _await_response(self, request_id: int) -> dict[str, Any]:
deadline = time.monotonic() + self.timeout_seconds
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
stderr = '\n'.join(self.stderr_lines[-5:])
raise TimeoutError(
f'Timed out waiting for MCP response from {self.server.name}'
+ (f' stderr={stderr}' if stderr else '')
)
if self.selector is None:
raise OSError(f'MCP selector is not available for {self.server.name}')
events = self.selector.select(timeout=remaining)
if not events:
continue
for key, _mask in events:
stream_name = key.data
line = key.fileobj.readline()
if not line:
continue
if stream_name == 'stderr':
self.stderr_lines.append(line.rstrip())
continue
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(payload, dict):
continue
if payload.get('id') == request_id:
return payload
def _render_resource_contents(contents: Any) -> str:
if not isinstance(contents, list):
return ''
parts: list[str] = []
for item in contents:
if not isinstance(item, dict):
continue
text = item.get('text')
if isinstance(text, str):
parts.append(text)
continue
blob = item.get('blob')
if isinstance(blob, str):
mime_type = item.get('mimeType') if isinstance(item.get('mimeType'), str) else 'application/octet-stream'
parts.append(f'[blob:{mime_type}] {blob}')
continue
parts.append(json.dumps(item, ensure_ascii=True, indent=2))
return '\n\n'.join(parts).strip()
def _render_tool_call_result(result: dict[str, Any]) -> str:
parts: list[str] = []
content = result.get('content')
if isinstance(content, list):
for item in content:
if not isinstance(item, dict):
continue
text = item.get('text')
if isinstance(text, str):
parts.append(text)
continue
structured = item.get('structuredContent')
if structured is not None:
parts.append(json.dumps(structured, ensure_ascii=True, indent=2))
continue
parts.append(json.dumps(item, ensure_ascii=True, indent=2))
structured_content = result.get('structuredContent')
if structured_content is not None:
parts.append(json.dumps(structured_content, ensure_ascii=True, indent=2))
if not parts:
parts.append(json.dumps(result, ensure_ascii=True, indent=2))
return '\n\n'.join(part for part in parts if part).strip()
def _truncate(text: str, limit: int) -> str:
if len(text) <= limit:
return text
+80 -3
View File
@@ -11,7 +11,13 @@ from .task_runtime import TaskRuntime
DEFAULT_PLAN_RUNTIME_PATH = Path('.port_sessions') / 'plan_runtime.json'
VALID_PLAN_STATUSES = ('pending', 'in_progress', 'completed')
VALID_PLAN_STATUSES = (
'pending',
'in_progress',
'completed',
'blocked',
'cancelled',
)
@dataclass(frozen=True)
@@ -21,6 +27,9 @@ class PlanStep:
task_id: str | None = None
description: str | None = None
priority: str | None = None
active_form: str | None = None
owner: str | None = None
depends_on: tuple[str, ...] = ()
def to_dict(self) -> dict[str, Any]:
return {
@@ -29,6 +38,9 @@ class PlanStep:
'task_id': self.task_id,
'description': self.description,
'priority': self.priority,
'active_form': self.active_form,
'owner': self.owner,
'depends_on': list(self.depends_on),
}
@classmethod
@@ -53,6 +65,19 @@ class PlanStep:
and payload.get('priority').strip()
else None
),
active_form=(
str(payload.get('active_form'))
if isinstance(payload.get('active_form'), str)
and payload.get('active_form').strip()
else None
),
owner=(
str(payload.get('owner'))
if isinstance(payload.get('owner'), str)
and payload.get('owner').strip()
else None
),
depends_on=_normalize_id_list(payload.get('depends_on')),
)
@@ -147,6 +172,19 @@ class PlanRuntime:
and item.get('priority').strip()
else None
),
active_form=(
item.get('active_form').strip()
if isinstance(item.get('active_form'), str)
and item.get('active_form').strip()
else None
),
owner=(
item.get('owner').strip()
if isinstance(item.get('owner'), str)
and item.get('owner').strip()
else None
),
depends_on=_normalize_id_list(item.get('depends_on')),
)
)
mutation = self._persist(
@@ -158,6 +196,10 @@ class PlanRuntime:
),
)
if sync_tasks and task_runtime is not None:
dependents: dict[str, list[str]] = {}
for step in self.steps:
for dependency in step.depends_on:
dependents.setdefault(dependency, []).append(step.task_id or '')
task_items = [
{
'task_id': step.task_id or f'plan_{index}',
@@ -165,6 +207,14 @@ class PlanRuntime:
'description': step.description,
'status': _plan_status_to_task_status(step.status),
'priority': step.priority,
'active_form': step.active_form,
'owner': step.owner,
'blocked_by': list(step.depends_on),
'blocks': sorted(
dependency
for dependency in dependents.get(step.task_id or f'plan_{index}', [])
if dependency
),
}
for index, step in enumerate(self.steps, start=1)
]
@@ -238,6 +288,12 @@ class PlanRuntime:
lines.append('- ' + '; '.join(details))
if step.description:
lines.append(f' description: {step.description}')
if step.active_form:
lines.append(f' active_form: {step.active_form}')
if step.owner:
lines.append(f' owner: {step.owner}')
if step.depends_on:
lines.append(f" depends_on: {', '.join(step.depends_on)}")
return '\n'.join(lines)
def _persist(
@@ -297,6 +353,7 @@ def _normalize_plan_status(value: Any) -> str:
'complete': 'completed',
'in-progress': 'in_progress',
'in progress': 'in_progress',
'blocked_on': 'blocked',
}
lowered = aliases.get(lowered, lowered)
if lowered in VALID_PLAN_STATUSES:
@@ -306,10 +363,30 @@ def _normalize_plan_status(value: Any) -> str:
def _plan_status_to_task_status(status: str) -> str:
if status == 'completed':
return 'done'
return 'completed'
if status == 'in_progress':
return 'in_progress'
return 'todo'
if status == 'blocked':
return 'blocked'
if status == 'cancelled':
return 'cancelled'
return 'pending'
def _normalize_id_list(value: Any) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
normalized: list[str] = []
seen: set[str] = set()
for item in value:
if not isinstance(item, str):
continue
text = item.strip()
if not text or text in seen:
continue
normalized.append(text)
seen.add(text)
return tuple(normalized)
def _snapshot_text(text: str, limit: int = 240) -> str:
+554 -8
View File
@@ -1,6 +1,47 @@
from __future__ import annotations
from dataclasses import dataclass
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
DEFAULT_REMOTE_STATE_DIR = Path('.port_sessions')
DEFAULT_REMOTE_STATE_FILE = DEFAULT_REMOTE_STATE_DIR / 'remote_runtime.json'
SUPPORTED_REMOTE_MODES = (
'remote',
'ssh',
'teleport',
'direct-connect',
'deep-link',
)
@dataclass(frozen=True)
class RemoteProfile:
name: str
mode: str
target: str
source_manifest: str
description: str | None = None
workspace_cwd: str | None = None
session_url: str | None = None
env: dict[str, str] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class RemoteConnectionState:
mode: str
target: str
connected: bool
connected_at: str
profile_name: str | None = None
workspace_cwd: str | None = None
session_url: str | None = None
source_manifest: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
@@ -8,18 +49,523 @@ class RuntimeModeReport:
mode: str
connected: bool
detail: str
target: str | None = None
profile_name: str | None = None
workspace_cwd: str | None = None
session_url: str | None = None
source_manifest: str | None = None
manifest_count: int = 0
profile_count: int = 0
metadata: dict[str, Any] = field(default_factory=dict)
def as_text(self) -> str:
return f'mode={self.mode}\nconnected={self.connected}\ndetail={self.detail}'
lines = [
f'mode={self.mode}',
f'connected={self.connected}',
f'detail={self.detail}',
]
if self.target:
lines.append(f'target={self.target}')
if self.profile_name:
lines.append(f'profile={self.profile_name}')
if self.workspace_cwd:
lines.append(f'workspace_cwd={self.workspace_cwd}')
if self.session_url:
lines.append(f'session_url={self.session_url}')
if self.source_manifest:
lines.append(f'source_manifest={self.source_manifest}')
lines.append(f'manifest_count={self.manifest_count}')
lines.append(f'profile_count={self.profile_count}')
if self.metadata:
for key, value in sorted(self.metadata.items()):
lines.append(f'metadata.{key}={value}')
return '\n'.join(lines)
def run_remote_mode(target: str) -> RuntimeModeReport:
return RuntimeModeReport('remote', True, f'Remote control placeholder prepared for {target}')
@dataclass
class RemoteRuntime:
cwd: Path
profiles: tuple[RemoteProfile, ...] = field(default_factory=tuple)
manifests: tuple[str, ...] = field(default_factory=tuple)
state_path: Path = field(default_factory=lambda: DEFAULT_REMOTE_STATE_FILE.resolve())
active_connection: RemoteConnectionState | None = None
history: tuple[dict[str, Any], ...] = field(default_factory=tuple)
@classmethod
def from_workspace(
cls,
cwd: Path,
additional_working_directories: tuple[str, ...] = (),
) -> 'RemoteRuntime':
manifest_paths = _discover_manifest_paths(cwd, additional_working_directories)
profiles: list[RemoteProfile] = []
for manifest_path in manifest_paths:
profiles.extend(_load_profiles_from_manifest(manifest_path))
state_path = cwd.resolve() / DEFAULT_REMOTE_STATE_FILE
payload = _load_state_payload(state_path)
active_connection = _connection_from_payload(payload.get('active_connection'))
history_payload = payload.get('history')
history = tuple(
item for item in history_payload if isinstance(item, dict)
) if isinstance(history_payload, list) else ()
return cls(
cwd=cwd.resolve(),
profiles=tuple(profiles),
manifests=tuple(str(path) for path in manifest_paths),
state_path=state_path,
active_connection=active_connection,
history=history,
)
def has_remote_config(self) -> bool:
return bool(self.profiles or self.active_connection is not None)
def list_profiles(
self,
*,
query: str | None = None,
mode: str | None = None,
limit: int | None = None,
) -> tuple[RemoteProfile, ...]:
profiles = self.profiles
if query:
needle = query.lower()
profiles = tuple(
profile
for profile in profiles
if needle in profile.name.lower()
or needle in profile.mode.lower()
or needle in profile.target.lower()
or needle in (profile.description or '').lower()
)
if mode:
profiles = tuple(profile for profile in profiles if profile.mode == _normalize_mode(mode))
if limit is not None and limit >= 0:
profiles = profiles[:limit]
return profiles
def get_profile(self, name_or_target: str) -> RemoteProfile | None:
needle = name_or_target.strip().lower()
if not needle:
return None
for profile in self.profiles:
if profile.name.lower() == needle or profile.target.lower() == needle:
return profile
return None
def connect(
self,
target: str,
*,
mode: str | None = None,
) -> RuntimeModeReport:
normalized_mode = _normalize_mode(mode or 'remote')
profile = self.get_profile(target)
if profile is not None:
normalized_mode = _normalize_mode(profile.mode or normalized_mode)
connection = RemoteConnectionState(
mode=normalized_mode,
target=profile.target,
connected=True,
connected_at=_utc_now(),
profile_name=profile.name,
workspace_cwd=profile.workspace_cwd,
session_url=profile.session_url,
source_manifest=profile.source_manifest,
metadata=dict(profile.metadata),
)
detail = f'Activated remote profile {profile.name}'
else:
connection = RemoteConnectionState(
mode=normalized_mode,
target=target.strip(),
connected=True,
connected_at=_utc_now(),
metadata={'ephemeral': True},
)
detail = f'Activated {normalized_mode} target {target.strip()}'
self.active_connection = connection
self._append_history(
{
'action': 'connect',
'mode': connection.mode,
'target': connection.target,
'profile_name': connection.profile_name,
'connected_at': connection.connected_at,
}
)
self._persist_state()
return self.current_report(detail=detail)
def disconnect(self, *, reason: str = 'manual_disconnect') -> RuntimeModeReport:
previous = self.active_connection
detail = (
f'Disconnected {previous.mode} target {previous.target}'
if previous is not None
else 'No active remote connection was present.'
)
if previous is not None:
self._append_history(
{
'action': 'disconnect',
'mode': previous.mode,
'target': previous.target,
'profile_name': previous.profile_name,
'reason': reason,
'disconnected_at': _utc_now(),
}
)
self.active_connection = None
self._persist_state()
return RuntimeModeReport(
mode=previous.mode if previous is not None else 'remote',
connected=False,
detail=detail,
manifest_count=len(self.manifests),
profile_count=len(self.profiles),
)
def current_report(self, *, detail: str | None = None) -> RuntimeModeReport:
if self.active_connection is None:
return RuntimeModeReport(
mode='remote',
connected=False,
detail=detail or 'No active remote connection.',
manifest_count=len(self.manifests),
profile_count=len(self.profiles),
)
connection = self.active_connection
return RuntimeModeReport(
mode=connection.mode,
connected=connection.connected,
detail=detail or f'Active {connection.mode} connection for {connection.target}',
target=connection.target,
profile_name=connection.profile_name,
workspace_cwd=connection.workspace_cwd,
session_url=connection.session_url,
source_manifest=connection.source_manifest,
manifest_count=len(self.manifests),
profile_count=len(self.profiles),
metadata=dict(connection.metadata),
)
def render_summary(self) -> str:
lines = [
f'Local remote manifests: {len(self.manifests)}',
f'Configured remote profiles: {len(self.profiles)}',
]
if self.active_connection is None:
lines.append('- Active remote connection: none')
else:
connection = self.active_connection
active = f'- Active remote connection: {connection.mode} -> {connection.target}'
if connection.profile_name:
active += f' (profile={connection.profile_name})'
lines.append(active)
if connection.workspace_cwd:
lines.append(f'- Active remote workspace: {connection.workspace_cwd}')
if connection.session_url:
lines.append(f'- Active remote session URL: {connection.session_url}')
for profile in self.profiles[:10]:
parts = [profile.name, f'mode={profile.mode}', f'target={profile.target}']
if profile.workspace_cwd:
parts.append(f'workspace={profile.workspace_cwd}')
if profile.session_url:
parts.append(f'session_url={profile.session_url}')
lines.append('- ' + '; '.join(parts))
if self.history:
lines.append(f'- Runtime history entries: {len(self.history)}')
return '\n'.join(lines)
def render_profiles_index(
self,
*,
query: str | None = None,
mode: str | None = None,
limit: int = 20,
) -> str:
profiles = self.list_profiles(query=query, mode=mode, limit=limit)
if not profiles:
return '# Remote Profiles\n\nNo matching remote profiles discovered.'
lines = ['# Remote Profiles', '']
for profile in profiles:
details = [profile.name, f'mode={profile.mode}', f'target={profile.target}']
if profile.workspace_cwd:
details.append(f'workspace={profile.workspace_cwd}')
if profile.session_url:
details.append(f'session_url={profile.session_url}')
if profile.description:
details.append(f'description={profile.description}')
lines.append('- ' + '; '.join(details))
return '\n'.join(lines)
def render_profile(self, name_or_target: str) -> str:
profile = self.get_profile(name_or_target)
if profile is None:
return f'# Remote Profile\n\nUnknown remote profile: {name_or_target}'
lines = [
'# Remote Profile',
'',
f'- Name: {profile.name}',
f'- Mode: {profile.mode}',
f'- Target: {profile.target}',
f'- Source manifest: {profile.source_manifest}',
]
if profile.description:
lines.append(f'- Description: {profile.description}')
if profile.workspace_cwd:
lines.append(f'- Workspace: {profile.workspace_cwd}')
if profile.session_url:
lines.append(f'- Session URL: {profile.session_url}')
if profile.env:
lines.append('- Environment values:')
lines.extend(f' - {key}={value}' for key, value in sorted(profile.env.items()))
if profile.metadata:
lines.append('- Metadata:')
lines.extend(f' - {key}={value}' for key, value in sorted(profile.metadata.items()))
return '\n'.join(lines)
def _persist_state(self) -> None:
payload = {
'active_connection': (
asdict(self.active_connection) if self.active_connection is not None else None
),
'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 _append_history(self, entry: dict[str, Any]) -> None:
merged = [*self.history, dict(entry)]
self.history = tuple(merged[-40:])
def run_ssh_mode(target: str) -> RuntimeModeReport:
return RuntimeModeReport('ssh', True, f'SSH proxy placeholder prepared for {target}')
def run_remote_mode(
target: str,
*,
cwd: Path | None = None,
additional_working_directories: tuple[str, ...] = (),
) -> RuntimeModeReport:
runtime = RemoteRuntime.from_workspace(
cwd or Path.cwd(),
additional_working_directories=additional_working_directories,
)
return runtime.connect(target, mode='remote')
def run_teleport_mode(target: str) -> RuntimeModeReport:
return RuntimeModeReport('teleport', True, f'Teleport resume/create placeholder prepared for {target}')
def run_ssh_mode(
target: str,
*,
cwd: Path | None = None,
additional_working_directories: tuple[str, ...] = (),
) -> RuntimeModeReport:
runtime = RemoteRuntime.from_workspace(
cwd or Path.cwd(),
additional_working_directories=additional_working_directories,
)
return runtime.connect(target, mode='ssh')
def run_teleport_mode(
target: str,
*,
cwd: Path | None = None,
additional_working_directories: tuple[str, ...] = (),
) -> RuntimeModeReport:
runtime = RemoteRuntime.from_workspace(
cwd or Path.cwd(),
additional_working_directories=additional_working_directories,
)
return runtime.connect(target, mode='teleport')
def run_direct_connect_mode(
target: str,
*,
cwd: Path | None = None,
additional_working_directories: tuple[str, ...] = (),
) -> RuntimeModeReport:
runtime = RemoteRuntime.from_workspace(
cwd or Path.cwd(),
additional_working_directories=additional_working_directories,
)
return runtime.connect(target, mode='direct-connect')
def run_deep_link_mode(
target: str,
*,
cwd: Path | None = None,
additional_working_directories: tuple[str, ...] = (),
) -> RuntimeModeReport:
runtime = RemoteRuntime.from_workspace(
cwd or Path.cwd(),
additional_working_directories=additional_working_directories,
)
return runtime.connect(target, mode='deep-link')
def _discover_manifest_paths(
cwd: Path,
additional_working_directories: tuple[str, ...],
) -> tuple[Path, ...]:
candidates: list[Path] = []
seen: set[Path] = set()
def remember(path: Path) -> None:
resolved = path.resolve()
if resolved in seen or not resolved.exists() or not resolved.is_file():
return
seen.add(resolved)
candidates.append(resolved)
roots: list[Path] = []
current = cwd.resolve()
while True:
roots.append(current)
if current.parent == current:
break
current = current.parent
roots.extend(Path(path).resolve() for path in additional_working_directories)
for root in roots:
remember(root / '.claw-remote.json')
remember(root / '.remote.json')
remember(root / '.codex-remote.json')
remember(root / 'remote.json')
return tuple(candidates)
def _load_profiles_from_manifest(path: Path) -> list[RemoteProfile]:
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return []
if not isinstance(payload, dict):
return []
profiles: list[RemoteProfile] = []
raw_profiles = payload.get('profiles')
if isinstance(raw_profiles, list):
profiles.extend(_extract_profiles(raw_profiles, manifest_path=path))
elif _looks_like_profile(payload):
profile = _profile_from_item(payload, manifest_path=path)
if profile is not None:
profiles.append(profile)
remotes = payload.get('remotes')
if isinstance(remotes, list):
profiles.extend(_extract_profiles(remotes, manifest_path=path))
return profiles
def _extract_profiles(raw_profiles: list[Any], *, manifest_path: Path) -> list[RemoteProfile]:
profiles: list[RemoteProfile] = []
seen_names: set[str] = set()
for item in raw_profiles:
profile = _profile_from_item(item, manifest_path=manifest_path)
if profile is None or profile.name.lower() in seen_names:
continue
seen_names.add(profile.name.lower())
profiles.append(profile)
return profiles
def _profile_from_item(item: Any, *, manifest_path: Path) -> RemoteProfile | None:
if not isinstance(item, dict):
return None
name = item.get('name')
target = item.get('target')
if not isinstance(name, str) or not name.strip():
return None
if not isinstance(target, str) or not target.strip():
return None
mode = _normalize_mode(str(item.get('mode', 'remote')))
workspace_cwd = _optional_string(
item.get('workspaceCwd')
if item.get('workspaceCwd') is not None
else item.get('workspace_cwd')
)
session_url = _optional_string(
item.get('sessionUrl')
if item.get('sessionUrl') is not None
else item.get('session_url')
)
description = _optional_string(item.get('description'))
env = item.get('env')
metadata = item.get('metadata')
return RemoteProfile(
name=name.strip(),
mode=mode,
target=target.strip(),
source_manifest=str(manifest_path),
description=description,
workspace_cwd=workspace_cwd,
session_url=session_url,
env=(
{
str(key): str(value)
for key, value in env.items()
if isinstance(key, str) and isinstance(value, (str, int, float, bool))
}
if isinstance(env, dict)
else {}
),
metadata=dict(metadata) if isinstance(metadata, dict) else {},
)
def _looks_like_profile(payload: dict[str, Any]) -> bool:
return isinstance(payload.get('name'), str) and isinstance(payload.get('target'), str)
def _load_state_payload(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return {}
return payload if isinstance(payload, dict) else {}
def _connection_from_payload(payload: Any) -> RemoteConnectionState | None:
if not isinstance(payload, dict):
return None
mode = _optional_string(payload.get('mode'))
target = _optional_string(payload.get('target'))
connected_at = _optional_string(payload.get('connected_at'))
if mode is None or target is None or connected_at is None:
return None
metadata = payload.get('metadata')
return RemoteConnectionState(
mode=_normalize_mode(mode),
target=target,
connected=bool(payload.get('connected', True)),
connected_at=connected_at,
profile_name=_optional_string(payload.get('profile_name')),
workspace_cwd=_optional_string(payload.get('workspace_cwd')),
session_url=_optional_string(payload.get('session_url')),
source_manifest=_optional_string(payload.get('source_manifest')),
metadata=dict(metadata) if isinstance(metadata, dict) else {},
)
def _normalize_mode(mode: str) -> str:
normalized = mode.strip().lower().replace('_', '-')
if normalized == 'direct':
normalized = 'direct-connect'
if normalized == 'deeplink':
normalized = 'deep-link'
if normalized not in SUPPORTED_REMOTE_MODES:
return 'remote'
return normalized
def _optional_string(value: Any) -> str | None:
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
+606
View File
@@ -0,0 +1,606 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib import parse, request
DEFAULT_SEARCH_STATE_DIR = Path('.port_sessions')
DEFAULT_SEARCH_STATE_FILE = DEFAULT_SEARCH_STATE_DIR / 'search_runtime.json'
SEARCH_MANIFEST_PATHS = (
Path('.claw-search.json'),
Path('.claude/search.json'),
)
DEFAULT_SEARXNG_BASE_URL = 'http://127.0.0.1:8080'
DEFAULT_BRAVE_BASE_URL = 'https://api.search.brave.com/res/v1/web/search'
DEFAULT_TAVILY_BASE_URL = 'https://api.tavily.com/search'
@dataclass(frozen=True)
class SearchProviderProfile:
name: str
provider: str
source_manifest: str
base_url: str
api_key_env: str | None = None
description: str | None = None
default_max_results: int = 5
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class SearchResult:
title: str
url: str
snippet: str
provider_name: str
rank: int
@dataclass(frozen=True)
class SearchStatusReport:
configured: bool
detail: str
provider_name: str | None = None
provider_kind: str | None = None
base_url: str | None = None
manifest_count: int = 0
provider_count: int = 0
api_key_env: str | None = None
def as_text(self) -> str:
lines = [
f'configured={self.configured}',
f'detail={self.detail}',
f'manifest_count={self.manifest_count}',
f'provider_count={self.provider_count}',
]
if self.provider_name:
lines.append(f'provider={self.provider_name}')
if self.provider_kind:
lines.append(f'provider_kind={self.provider_kind}')
if self.base_url:
lines.append(f'base_url={self.base_url}')
if self.api_key_env:
lines.append(f'api_key_env={self.api_key_env}')
return '\n'.join(lines)
@dataclass
class SearchRuntime:
cwd: Path
providers: tuple[SearchProviderProfile, ...] = field(default_factory=tuple)
manifests: tuple[str, ...] = field(default_factory=tuple)
state_path: Path = field(default_factory=lambda: DEFAULT_SEARCH_STATE_FILE.resolve())
active_provider_name: str | None = None
@classmethod
def from_workspace(
cls,
cwd: Path,
additional_working_directories: tuple[str, ...] = (),
) -> 'SearchRuntime':
manifest_paths = _discover_manifest_paths(cwd, additional_working_directories)
providers: list[SearchProviderProfile] = []
for manifest_path in manifest_paths:
providers.extend(_load_profiles_from_manifest(manifest_path))
providers.extend(_load_profiles_from_env())
providers = _dedupe_profiles(providers)
state_path = (cwd.resolve() / DEFAULT_SEARCH_STATE_FILE).resolve()
payload = _load_state_payload(state_path)
active_provider_name = payload.get('active_provider_name')
if not isinstance(active_provider_name, str):
active_provider_name = None
return cls(
cwd=cwd.resolve(),
providers=tuple(providers),
manifests=tuple(str(path) for path in manifest_paths),
state_path=state_path,
active_provider_name=active_provider_name,
)
def has_search_runtime(self) -> bool:
return bool(self.providers)
def list_providers(
self,
*,
query: str | None = None,
limit: int | None = None,
) -> tuple[SearchProviderProfile, ...]:
providers = self.providers
if query:
needle = query.lower()
providers = tuple(
provider
for provider in providers
if needle in provider.name.lower()
or needle in provider.provider.lower()
or needle in provider.base_url.lower()
or needle in (provider.description or '').lower()
)
if limit is not None and limit >= 0:
providers = providers[:limit]
return providers
def get_provider(self, name: str) -> SearchProviderProfile | None:
needle = name.strip().lower()
if not needle:
return None
for provider in self.providers:
if provider.name.lower() == needle:
return provider
return None
def current_provider(self) -> SearchProviderProfile | None:
if self.active_provider_name:
active = self.get_provider(self.active_provider_name)
if active is not None:
return active
env_default = os.environ.get('CLAW_SEARCH_PROVIDER')
if isinstance(env_default, str) and env_default.strip():
active = self.get_provider(env_default.strip())
if active is not None:
return active
return self.providers[0] if self.providers else None
def activate_provider(self, name: str) -> SearchStatusReport:
provider = self.get_provider(name)
if provider is None:
raise KeyError(name)
self.active_provider_name = provider.name
self._persist_state()
return SearchStatusReport(
configured=True,
detail=f'Activated search provider {provider.name}',
provider_name=provider.name,
provider_kind=provider.provider,
base_url=provider.base_url,
manifest_count=len(self.manifests),
provider_count=len(self.providers),
api_key_env=provider.api_key_env,
)
def render_summary(self) -> str:
lines = [
f'Local search manifests: {len(self.manifests)}',
f'Configured search providers: {len(self.providers)}',
]
current = self.current_provider()
if current is None:
lines.append('- Active search provider: none')
return '\n'.join(lines)
lines.append(f'- Active search provider: {current.name} ({current.provider})')
for provider in self.providers[:5]:
details = [provider.name, provider.provider, provider.base_url]
if provider.api_key_env:
details.append(f'api_key_env={provider.api_key_env}')
lines.append('- Provider: ' + ' ; '.join(details))
return '\n'.join(lines)
def render_providers_index(self, *, query: str | None = None) -> str:
providers = self.list_providers(query=query, limit=100)
lines = ['# Search Providers', '']
if not providers:
lines.append('No local search providers discovered.')
return '\n'.join(lines)
for provider in providers:
details = [provider.name, provider.provider, provider.base_url]
if provider.api_key_env:
details.append(f'api_key_env={provider.api_key_env}')
lines.append('- ' + ' ; '.join(details))
return '\n'.join(lines)
def render_provider(self, name: str) -> str:
provider = self.get_provider(name)
if provider is None:
return f'# Search Provider\n\nUnknown search provider: {name}'
lines = [
'# Search Provider',
'',
f'- Name: {provider.name}',
f'- Provider: {provider.provider}',
f'- Base URL: {provider.base_url}',
f'- Source manifest: {provider.source_manifest}',
]
if provider.api_key_env:
lines.append(f'- API key env: {provider.api_key_env}')
if provider.description:
lines.extend(['', provider.description])
return '\n'.join(lines)
def render_search_results(
self,
query: str,
*,
provider_name: str | None = None,
max_results: int = 5,
domains: tuple[str, ...] = (),
timeout_seconds: float = 20.0,
) -> str:
provider, results = self.search(
query,
provider_name=provider_name,
max_results=max_results,
domains=domains,
timeout_seconds=timeout_seconds,
)
lines = ['# Web Search', '']
lines.append(f'- Provider: {provider.name} ({provider.provider})')
lines.append(f'- Query: {query}')
lines.append(f'- Results: {len(results)}')
lines.append('')
if not results:
lines.append('No search results.')
return '\n'.join(lines)
for result in results:
lines.append(f'{result.rank}. {result.title}')
lines.append(f' {result.url}')
if result.snippet:
lines.append(f' {result.snippet}')
return '\n'.join(lines)
def search(
self,
query: str,
*,
provider_name: str | None = None,
max_results: int = 5,
domains: tuple[str, ...] = (),
timeout_seconds: float = 20.0,
) -> tuple[SearchProviderProfile, tuple[SearchResult, ...]]:
provider = self._resolve_provider(provider_name)
backend = provider.provider.lower()
if backend == 'searxng':
results = _search_searxng(provider, query, max_results=max_results, timeout_seconds=timeout_seconds)
elif backend == 'brave':
results = _search_brave(provider, query, max_results=max_results, timeout_seconds=timeout_seconds)
elif backend == 'tavily':
results = _search_tavily(provider, query, max_results=max_results, domains=domains, timeout_seconds=timeout_seconds)
else:
raise ValueError(f'Unsupported search provider: {provider.provider}')
if domains:
results = tuple(result for result in results if _matches_domains(result.url, domains))
return provider, tuple(results[:max_results])
def _resolve_provider(self, provider_name: str | None) -> SearchProviderProfile:
if provider_name:
provider = self.get_provider(provider_name)
if provider is None:
raise KeyError(provider_name)
return provider
provider = self.current_provider()
if provider is None:
raise LookupError('No local search provider is configured.')
return provider
def _persist_state(self) -> None:
payload = {'active_provider_name': self.active_provider_name}
self.state_path.parent.mkdir(parents=True, exist_ok=True)
self.state_path.write_text(
json.dumps(payload, ensure_ascii=True, indent=2, sort_keys=True) + '\n',
encoding='utf-8',
)
def _discover_manifest_paths(cwd: Path, additional_working_directories: tuple[str, ...]) -> tuple[Path, ...]:
candidate_roots = [cwd.resolve()]
for raw_path in additional_working_directories:
path = Path(raw_path).resolve()
if path not in candidate_roots:
candidate_roots.append(path)
discovered: list[Path] = []
seen: set[Path] = set()
for root in candidate_roots:
for relative_path in SEARCH_MANIFEST_PATHS:
path = (root / relative_path).resolve()
if path in seen or not path.exists() or not path.is_file():
continue
seen.add(path)
discovered.append(path)
return tuple(discovered)
def _load_profiles_from_manifest(path: Path) -> list[SearchProviderProfile]:
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return []
if isinstance(payload, dict):
providers_payload = payload.get('providers')
if isinstance(providers_payload, list):
return [
provider
for item in providers_payload
for provider in [_provider_from_payload(item, path)]
if provider is not None
]
single = _provider_from_payload(payload, path)
return [single] if single is not None else []
return []
def _provider_from_payload(payload: Any, path: Path) -> SearchProviderProfile | None:
if not isinstance(payload, dict):
return None
name = payload.get('name')
provider = payload.get('provider')
if not isinstance(name, str) or not name.strip():
return None
if not isinstance(provider, str) or not provider.strip():
return None
normalized_provider = provider.strip().lower()
base_url = _optional_str(payload.get('baseUrl') or payload.get('base_url')) or _default_base_url(normalized_provider)
if base_url is None:
return None
api_key_env = _optional_str(payload.get('apiKeyEnv') or payload.get('api_key_env')) or _default_api_env(normalized_provider)
description = _optional_str(payload.get('description'))
default_max_results = payload.get('defaultMaxResults') or payload.get('default_max_results') or 5
if isinstance(default_max_results, bool) or not isinstance(default_max_results, int):
default_max_results = 5
metadata = payload.get('metadata')
return SearchProviderProfile(
name=name.strip(),
provider=normalized_provider,
source_manifest=str(path),
base_url=base_url,
api_key_env=api_key_env,
description=description,
default_max_results=max(default_max_results, 1),
metadata=dict(metadata) if isinstance(metadata, dict) else {},
)
def _load_profiles_from_env() -> list[SearchProviderProfile]:
providers: list[SearchProviderProfile] = []
searxng_base = os.environ.get('SEARXNG_BASE_URL')
if isinstance(searxng_base, str) and searxng_base.strip():
providers.append(
SearchProviderProfile(
name='searxng',
provider='searxng',
source_manifest='env:SEARXNG_BASE_URL',
base_url=searxng_base.strip(),
)
)
brave_key = os.environ.get('BRAVE_SEARCH_API_KEY')
if isinstance(brave_key, str) and brave_key.strip():
providers.append(
SearchProviderProfile(
name='brave',
provider='brave',
source_manifest='env:BRAVE_SEARCH_API_KEY',
base_url=DEFAULT_BRAVE_BASE_URL,
api_key_env='BRAVE_SEARCH_API_KEY',
)
)
tavily_key = os.environ.get('TAVILY_API_KEY')
if isinstance(tavily_key, str) and tavily_key.strip():
providers.append(
SearchProviderProfile(
name='tavily',
provider='tavily',
source_manifest='env:TAVILY_API_KEY',
base_url=DEFAULT_TAVILY_BASE_URL,
api_key_env='TAVILY_API_KEY',
)
)
return providers
def _dedupe_profiles(providers: list[SearchProviderProfile]) -> list[SearchProviderProfile]:
seen: set[str] = set()
deduped: list[SearchProviderProfile] = []
for provider in providers:
key = provider.name.lower()
if key in seen:
continue
seen.add(key)
deduped.append(provider)
return deduped
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 {}
def _default_base_url(provider: str) -> str | None:
if provider == 'searxng':
return DEFAULT_SEARXNG_BASE_URL
if provider == 'brave':
return DEFAULT_BRAVE_BASE_URL
if provider == 'tavily':
return DEFAULT_TAVILY_BASE_URL
return None
def _default_api_env(provider: str) -> str | None:
if provider == 'brave':
return 'BRAVE_SEARCH_API_KEY'
if provider == 'tavily':
return 'TAVILY_API_KEY'
return None
def _optional_str(value: Any) -> str | None:
if not isinstance(value, str):
return None
stripped = value.strip()
return stripped or None
def _search_searxng(
provider: SearchProviderProfile,
query: str,
*,
max_results: int,
timeout_seconds: float,
) -> tuple[SearchResult, ...]:
endpoint = provider.base_url.rstrip('/')
if not endpoint.endswith('/search'):
endpoint += '/search'
url = endpoint + '?' + parse.urlencode(
{
'q': query,
'format': 'json',
}
)
req = request.Request(url, headers={'User-Agent': 'claw-code-agent/1.0'})
with request.urlopen(req, timeout=timeout_seconds) as response:
payload = json.loads(response.read().decode('utf-8', errors='replace'))
results = payload.get('results')
if not isinstance(results, list):
return ()
rendered: list[SearchResult] = []
for index, item in enumerate(results[:max_results], start=1):
if not isinstance(item, dict):
continue
url_value = item.get('url')
title = item.get('title')
snippet = item.get('content') or item.get('snippet') or ''
if not isinstance(url_value, str) or not url_value.strip():
continue
if not isinstance(title, str) or not title.strip():
title = url_value
rendered.append(
SearchResult(
title=title.strip(),
url=url_value.strip(),
snippet=str(snippet).strip(),
provider_name=provider.name,
rank=index,
)
)
return tuple(rendered)
def _search_brave(
provider: SearchProviderProfile,
query: str,
*,
max_results: int,
timeout_seconds: float,
) -> tuple[SearchResult, ...]:
api_key = _require_api_key(provider)
url = provider.base_url + '?' + parse.urlencode({'q': query, 'count': max_results})
req = request.Request(
url,
headers={
'User-Agent': 'claw-code-agent/1.0',
'X-Subscription-Token': api_key,
'Accept': 'application/json',
},
)
with request.urlopen(req, timeout=timeout_seconds) as response:
payload = json.loads(response.read().decode('utf-8', errors='replace'))
results = payload.get('web', {}).get('results')
if not isinstance(results, list):
return ()
rendered: list[SearchResult] = []
for index, item in enumerate(results[:max_results], start=1):
if not isinstance(item, dict):
continue
url_value = item.get('url')
title = item.get('title')
snippet = item.get('description') or ''
if not isinstance(url_value, str) or not url_value.strip():
continue
if not isinstance(title, str) or not title.strip():
title = url_value
rendered.append(
SearchResult(
title=title.strip(),
url=url_value.strip(),
snippet=str(snippet).strip(),
provider_name=provider.name,
rank=index,
)
)
return tuple(rendered)
def _search_tavily(
provider: SearchProviderProfile,
query: str,
*,
max_results: int,
domains: tuple[str, ...],
timeout_seconds: float,
) -> tuple[SearchResult, ...]:
api_key = _require_api_key(provider)
payload = {
'api_key': api_key,
'query': query,
'max_results': max_results,
}
if domains:
payload['include_domains'] = list(domains)
data = json.dumps(payload, ensure_ascii=True).encode('utf-8')
req = request.Request(
provider.base_url,
data=data,
headers={
'User-Agent': 'claw-code-agent/1.0',
'Content-Type': 'application/json',
'Accept': 'application/json',
},
method='POST',
)
with request.urlopen(req, timeout=timeout_seconds) as response:
body = json.loads(response.read().decode('utf-8', errors='replace'))
results = body.get('results')
if not isinstance(results, list):
return ()
rendered: list[SearchResult] = []
for index, item in enumerate(results[:max_results], start=1):
if not isinstance(item, dict):
continue
url_value = item.get('url')
title = item.get('title')
snippet = item.get('content') or ''
if not isinstance(url_value, str) or not url_value.strip():
continue
if not isinstance(title, str) or not title.strip():
title = url_value
rendered.append(
SearchResult(
title=title.strip(),
url=url_value.strip(),
snippet=str(snippet).strip(),
provider_name=provider.name,
rank=index,
)
)
return tuple(rendered)
def _require_api_key(provider: SearchProviderProfile) -> str:
if provider.api_key_env is None:
raise LookupError(f'Search provider {provider.name} does not define an API key env var.')
value = os.environ.get(provider.api_key_env)
if not isinstance(value, str) or not value.strip():
raise LookupError(
f'Search provider {provider.name} requires env var {provider.api_key_env}.'
)
return value.strip()
def _matches_domains(url: str, domains: tuple[str, ...]) -> bool:
hostname = parse.urlparse(url).hostname or ''
hostname = hostname.lower()
for domain in domains:
normalized = domain.strip().lower()
if not normalized:
continue
if hostname == normalized or hostname.endswith('.' + normalized):
return True
return False
+58 -6
View File
@@ -5,16 +5,27 @@ from datetime import datetime, timezone
from typing import Any
VALID_TASK_STATUSES = ('todo', 'in_progress', 'done', 'cancelled')
VALID_TASK_STATUSES = (
'pending',
'in_progress',
'completed',
'blocked',
'cancelled',
)
@dataclass(frozen=True)
class PortingTask:
task_id: str
title: str
status: str = 'todo'
status: str = 'pending'
description: str | None = None
priority: str | None = None
active_form: str | None = None
owner: str | None = None
blocks: tuple[str, ...] = ()
blocked_by: tuple[str, ...] = ()
metadata: dict[str, Any] = field(default_factory=dict)
created_at: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
@@ -29,6 +40,11 @@ class PortingTask:
'status': self.status,
'description': self.description,
'priority': self.priority,
'active_form': self.active_form,
'owner': self.owner,
'blocks': list(self.blocks),
'blocked_by': list(self.blocked_by),
'metadata': dict(self.metadata),
'created_at': self.created_at,
'updated_at': self.updated_at,
}
@@ -49,6 +65,25 @@ class PortingTask:
if isinstance(payload.get('priority'), str)
else None
),
active_form=(
str(payload.get('active_form'))
if isinstance(payload.get('active_form'), str)
and payload.get('active_form')
else None
),
owner=(
str(payload.get('owner'))
if isinstance(payload.get('owner'), str)
and payload.get('owner')
else None
),
blocks=_normalize_string_tuple(payload.get('blocks')),
blocked_by=_normalize_string_tuple(payload.get('blocked_by')),
metadata=(
dict(payload.get('metadata'))
if isinstance(payload.get('metadata'), dict)
else {}
),
created_at=(
str(payload.get('created_at'))
if isinstance(payload.get('created_at'), str)
@@ -68,11 +103,28 @@ def _normalize_task_status(value: Any) -> str:
aliases = {
'in-progress': 'in_progress',
'in progress': 'in_progress',
'complete': 'done',
'completed': 'done',
'open': 'todo',
'complete': 'completed',
'done': 'completed',
'todo': 'pending',
'open': 'pending',
}
lowered = aliases.get(lowered, lowered)
if lowered in VALID_TASK_STATUSES:
return lowered
return 'todo'
return 'pending'
def _normalize_string_tuple(value: Any) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
normalized: list[str] = []
seen: set[str] = set()
for item in value:
if not isinstance(item, str):
continue
text = item.strip()
if not text or text in seen:
continue
normalized.append(text)
seen.add(text)
return tuple(normalized)
+328 -11
View File
@@ -12,6 +12,8 @@ from .task import PortingTask, VALID_TASK_STATUSES
DEFAULT_TASK_RUNTIME_PATH = Path('.port_sessions') / 'task_runtime.json'
ACTIONABLE_TASK_STATUSES = ('pending', 'in_progress')
TERMINAL_TASK_STATUSES = ('completed', 'cancelled')
@dataclass(frozen=True)
@@ -57,16 +59,38 @@ class TaskRuntime:
self,
*,
status: str | None = None,
owner: str | None = None,
actionable_only: bool = False,
limit: int | None = None,
) -> tuple[PortingTask, ...]:
tasks = self.tasks
if status:
normalized = _normalize_status(status)
tasks = tuple(task for task in tasks if task.status == normalized)
if owner:
tasks = tuple(task for task in tasks if task.owner == owner)
if actionable_only:
actionable_ids = {task.task_id for task in self.next_tasks(limit=None)}
tasks = tuple(task for task in tasks if task.task_id in actionable_ids)
tasks = tuple(_sort_tasks(tasks))
if limit is not None and limit >= 0:
tasks = tasks[:limit]
return tasks
def next_tasks(self, *, limit: int | None = 10) -> tuple[PortingTask, ...]:
tasks_by_id = {task.task_id: task for task in self.tasks}
actionable: list[PortingTask] = []
for task in self.tasks:
if task.status not in ACTIONABLE_TASK_STATUSES:
continue
unresolved = _unresolved_dependencies(task, tasks_by_id)
if task.status == 'in_progress' or not unresolved:
actionable.append(task)
actionable = list(_sort_tasks(actionable))
if limit is not None and limit >= 0:
actionable = actionable[:limit]
return tuple(actionable)
def get_task(self, task_id: str) -> PortingTask | None:
for task in self.tasks:
if task.task_id == task_id:
@@ -78,9 +102,14 @@ class TaskRuntime:
*,
title: str,
description: str | None = None,
status: str = 'todo',
status: str = 'pending',
priority: str | None = None,
task_id: str | None = None,
active_form: str | None = None,
owner: str | None = None,
blocks: tuple[str, ...] | list[str] = (),
blocked_by: tuple[str, ...] | list[str] = (),
metadata: dict[str, Any] | None = None,
) -> TaskMutation:
task = PortingTask(
task_id=task_id or f'task_{uuid4().hex[:10]}',
@@ -88,6 +117,15 @@ class TaskRuntime:
description=description.strip() if isinstance(description, str) and description.strip() else None,
status=_normalize_status(status),
priority=priority.strip() if isinstance(priority, str) and priority.strip() else None,
active_form=(
active_form.strip()
if isinstance(active_form, str) and active_form.strip()
else None
),
owner=owner.strip() if isinstance(owner, str) and owner.strip() else None,
blocks=_normalize_id_list(blocks),
blocked_by=_normalize_id_list(blocked_by),
metadata=dict(metadata or {}),
)
return self._persist((*self.tasks, task), task=task)
@@ -99,10 +137,19 @@ class TaskRuntime:
description: str | None = None,
status: str | None = None,
priority: str | None = None,
active_form: str | None = None,
owner: str | None = None,
blocks: tuple[str, ...] | list[str] | None = None,
blocked_by: tuple[str, ...] | list[str] | None = None,
metadata: dict[str, Any] | None = None,
merge_metadata: bool = False,
) -> TaskMutation:
existing = self.get_task(task_id)
if existing is None:
raise KeyError(task_id)
updated_metadata = dict(existing.metadata)
if metadata is not None:
updated_metadata = {**updated_metadata, **metadata} if merge_metadata else dict(metadata)
updated = replace(
existing,
title=title.strip() if isinstance(title, str) and title.strip() else existing.title,
@@ -119,11 +166,133 @@ class TaskRuntime:
else None if priority == ''
else existing.priority
),
active_form=(
active_form.strip()
if isinstance(active_form, str) and active_form.strip()
else None if active_form == ''
else existing.active_form
),
owner=(
owner.strip()
if isinstance(owner, str) and owner.strip()
else None if owner == ''
else existing.owner
),
blocks=(
_normalize_id_list(blocks)
if blocks is not None
else existing.blocks
),
blocked_by=(
_normalize_id_list(blocked_by)
if blocked_by is not None
else existing.blocked_by
),
metadata=updated_metadata,
updated_at=datetime.now(timezone.utc).isoformat(),
)
tasks = tuple(updated if task.task_id == task_id else task for task in self.tasks)
return self._persist(tasks, task=updated)
def start_task(
self,
task_id: str,
*,
owner: str | None = None,
active_form: str | None = None,
) -> TaskMutation:
existing = self.get_task(task_id)
if existing is None:
raise KeyError(task_id)
unresolved = _unresolved_dependencies(existing, {task.task_id: task for task in self.tasks})
metadata = dict(existing.metadata)
if unresolved:
metadata['blocked_reason'] = f'waiting_on:{",".join(unresolved)}'
return self.update_task(
task_id,
status='blocked',
owner=owner if owner is not None else existing.owner,
active_form=active_form if active_form is not None else existing.active_form,
metadata=metadata,
)
if 'blocked_reason' in metadata:
metadata.pop('blocked_reason')
return self.update_task(
task_id,
status='in_progress',
owner=owner if owner is not None else existing.owner,
active_form=active_form if active_form is not None else existing.active_form,
metadata=metadata,
)
def complete_task(self, task_id: str) -> TaskMutation:
mutation = self.update_task(task_id, status='completed')
completed_ids = {task.task_id for task in self.tasks if task.status in TERMINAL_TASK_STATUSES}
updated_tasks: list[PortingTask] = []
changed = False
for task in self.tasks:
if task.status != 'blocked':
updated_tasks.append(task)
continue
unresolved = tuple(
dependency
for dependency in task.blocked_by
if dependency not in completed_ids
)
if unresolved:
updated_tasks.append(task)
continue
metadata = dict(task.metadata)
metadata.pop('blocked_reason', None)
updated_tasks.append(
replace(
task,
status='pending',
metadata=metadata,
updated_at=datetime.now(timezone.utc).isoformat(),
)
)
changed = True
if changed:
return self._persist(tuple(updated_tasks), task=self.get_task(task_id))
return mutation
def block_task(
self,
task_id: str,
*,
blocked_by: tuple[str, ...] | list[str] | None = None,
reason: str | None = None,
) -> TaskMutation:
existing = self.get_task(task_id)
if existing is None:
raise KeyError(task_id)
merged_blocked_by = tuple(existing.blocked_by)
if blocked_by is not None:
merged_blocked_by = _merge_ids(existing.blocked_by, _normalize_id_list(blocked_by))
metadata = dict(existing.metadata)
if isinstance(reason, str) and reason.strip():
metadata['blocked_reason'] = reason.strip()
return self.update_task(
task_id,
status='blocked',
blocked_by=merged_blocked_by,
metadata=metadata,
)
def cancel_task(self, task_id: str, *, reason: str | None = None) -> TaskMutation:
existing = self.get_task(task_id)
if existing is None:
raise KeyError(task_id)
metadata = dict(existing.metadata)
if isinstance(reason, str) and reason.strip():
metadata['cancel_reason'] = reason.strip()
return self.update_task(
task_id,
status='cancelled',
metadata=metadata,
)
def replace_tasks(self, items: list[dict[str, Any]]) -> TaskMutation:
tasks: list[PortingTask] = []
now = datetime.now(timezone.utc).isoformat()
@@ -157,6 +326,25 @@ class TaskRuntime:
and item.get('priority').strip()
else None
),
active_form=(
item.get('active_form').strip()
if isinstance(item.get('active_form'), str)
and item.get('active_form').strip()
else None
),
owner=(
item.get('owner').strip()
if isinstance(item.get('owner'), str)
and item.get('owner').strip()
else None
),
blocks=_normalize_id_list(item.get('blocks', [])),
blocked_by=_normalize_id_list(item.get('blocked_by', [])),
metadata=(
dict(item.get('metadata'))
if isinstance(item.get('metadata'), dict)
else {}
),
created_at=(
str(item.get('created_at'))
if isinstance(item.get('created_at'), str)
@@ -181,26 +369,55 @@ class TaskRuntime:
'- Status counts: '
+ ', '.join(f'{name}={count}' for name, count in sorted(counts.items()))
)
actionable = self.next_tasks(limit=None)
if actionable:
lines.append(f'- Actionable tasks: {len(actionable)}')
blocked = [task for task in self.tasks if task.status == 'blocked']
if blocked:
lines.append(f'- Blocked tasks: {len(blocked)}')
if self.tasks:
preview = ', '.join(task.title for task in self.tasks[:4])
preview = ', '.join(task.title for task in _sort_tasks(self.tasks)[:4])
if len(self.tasks) > 4:
preview += f', ... (+{len(self.tasks) - 4} more)'
lines.append(f'- Task preview: {preview}')
return '\n'.join(lines)
def render_tasks(self, *, status: str | None = None, limit: int = 50) -> str:
tasks = self.list_tasks(status=status, limit=limit)
def render_tasks(
self,
*,
status: str | None = None,
owner: str | None = None,
actionable_only: bool = False,
limit: int = 50,
) -> str:
tasks = self.list_tasks(
status=status,
owner=owner,
actionable_only=actionable_only,
limit=limit,
)
if not tasks:
return '# Tasks\n\nNo tasks are currently stored.'
lines = ['# Tasks', '']
if actionable_only:
lines.append('Showing actionable tasks only.')
lines.append('')
for task in tasks:
details = [task.task_id, f'status={task.status}']
if task.priority:
details.append(f'priority={task.priority}')
if task.owner:
details.append(f'owner={task.owner}')
details.append(f'title={task.title}')
lines.append('- ' + '; '.join(details))
if task.description:
lines.append(f' description: {task.description}')
if task.active_form:
lines.append(f' active_form: {task.active_form}')
if task.blocked_by:
lines.append(f" blocked_by: {', '.join(task.blocked_by)}")
if task.blocks:
lines.append(f" blocks: {', '.join(task.blocks)}")
return '\n'.join(lines)
def render_task(self, task_id: str) -> str:
@@ -216,22 +433,52 @@ class TaskRuntime:
]
if task.priority:
lines.append(f'- Priority: {task.priority}')
if task.owner:
lines.append(f'- Owner: {task.owner}')
if task.active_form:
lines.append(f'- Active Form: {task.active_form}')
if task.description:
lines.append(f'- Description: {task.description}')
if task.blocked_by:
lines.append(f"- Blocked By: {', '.join(task.blocked_by)}")
if task.blocks:
lines.append(f"- Blocks: {', '.join(task.blocks)}")
if task.metadata:
lines.append('- Metadata:')
for key, value in sorted(task.metadata.items()):
lines.append(f' - {key}={value}')
lines.append(f'- Updated: {task.updated_at}')
return '\n'.join(lines)
def render_next_tasks(self, *, limit: int = 10) -> str:
tasks = self.next_tasks(limit=limit)
if not tasks:
return '# Next Tasks\n\nNo actionable tasks are currently available.'
lines = ['# Next Tasks', '']
for task in tasks:
details = [task.task_id, f'status={task.status}', f'title={task.title}']
if task.owner:
details.append(f'owner={task.owner}')
lines.append('- ' + '; '.join(details))
unresolved = _unresolved_dependencies(task, {item.task_id: item for item in self.tasks})
if unresolved:
lines.append(f" unresolved_dependencies: {', '.join(unresolved)}")
if task.active_form:
lines.append(f' active_form: {task.active_form}')
return '\n'.join(lines)
def _persist(
self,
tasks: tuple[PortingTask, ...],
*,
task: PortingTask | None,
) -> TaskMutation:
before_text = self._serialize_payload(self.tasks)
before_tasks = self.tasks
before_text = self._serialize_payload(before_tasks)
before_preview = _snapshot_text(before_text)
before_sha256 = (
hashlib.sha256(before_text.encode('utf-8')).hexdigest()
if self.storage_path.exists() or self.tasks
if self.storage_path.exists() or before_tasks
else None
)
payload_text = self._serialize_payload(tasks)
@@ -246,7 +493,7 @@ class TaskRuntime:
after_sha256=after_sha256,
before_preview=before_preview if before_text.strip() else None,
after_preview=_snapshot_text(payload_text),
before_count=len(json.loads(before_text).get('tasks', [])) if before_text.strip() else 0,
before_count=len(before_tasks),
after_count=len(tasks),
)
@@ -268,11 +515,81 @@ def _normalize_status(value: Any) -> str:
if isinstance(value, str):
lowered = value.strip().lower().replace('-', '_').replace(' ', '_')
aliases = {
'complete': 'done',
'completed': 'done',
'open': 'todo',
'complete': 'completed',
'done': 'completed',
'todo': 'pending',
'open': 'pending',
}
lowered = aliases.get(lowered, lowered)
if lowered in VALID_TASK_STATUSES:
return lowered
return 'todo'
return 'pending'
def _normalize_id_list(value: Any) -> tuple[str, ...]:
if value is None:
return ()
if isinstance(value, tuple):
items = list(value)
elif isinstance(value, list):
items = value
else:
return ()
normalized: list[str] = []
seen: set[str] = set()
for item in items:
if not isinstance(item, str):
continue
text = item.strip()
if not text or text in seen:
continue
normalized.append(text)
seen.add(text)
return tuple(normalized)
def _merge_ids(existing: tuple[str, ...], additions: tuple[str, ...]) -> tuple[str, ...]:
merged = list(existing)
seen = set(existing)
for item in additions:
if item in seen:
continue
merged.append(item)
seen.add(item)
return tuple(merged)
def _unresolved_dependencies(
task: PortingTask,
tasks_by_id: dict[str, PortingTask],
) -> tuple[str, ...]:
unresolved: list[str] = []
for dependency_id in task.blocked_by:
dependency = tasks_by_id.get(dependency_id)
if dependency is None:
unresolved.append(dependency_id)
continue
if dependency.status not in TERMINAL_TASK_STATUSES:
unresolved.append(dependency_id)
return tuple(unresolved)
def _task_sort_key(task: PortingTask) -> tuple[int, int, str, str]:
status_rank = {
'in_progress': 0,
'pending': 1,
'blocked': 2,
'completed': 3,
'cancelled': 4,
}.get(task.status, 9)
priority_rank = {
'critical': 0,
'high': 1,
'medium': 2,
'low': 3,
}.get((task.priority or '').lower(), 9)
return (status_rank, priority_rank, task.title.lower(), task.task_id)
def _sort_tasks(tasks: tuple[PortingTask, ...] | list[PortingTask]) -> tuple[PortingTask, ...]:
return tuple(sorted(tasks, key=_task_sort_key))
+202
View File
@@ -0,0 +1,202 @@
from __future__ import annotations
import math
import os
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Callable
@dataclass(frozen=True)
class TokenCounterInfo:
backend: str
source: str
accurate: bool
@dataclass(frozen=True)
class ResolvedTokenCounter:
info: TokenCounterInfo
count_text: Callable[[str], int]
def count_tokens(text: str, model: str | None = None) -> int:
counter = resolve_token_counter(model)
return counter.count_text(text)
def describe_token_counter(model: str | None = None) -> TokenCounterInfo:
return resolve_token_counter(model).info
def resolve_token_counter(model: str | None = None) -> ResolvedTokenCounter:
return _resolve_token_counter(
_normalize_model(model),
_normalize_env('CLAW_CODE_TOKENIZER_PATH'),
_normalize_env('CLAW_CODE_TOKENIZER_MODEL'),
_normalize_env('CLAW_CODE_TOKENIZER_TRUST_REMOTE_CODE'),
)
def clear_token_counter_cache() -> None:
_resolve_token_counter.cache_clear()
@lru_cache(maxsize=64)
def _resolve_token_counter(
normalized_model: str | None,
explicit_path: str | None,
explicit_model: str | None,
trust_remote_code: str | None,
) -> ResolvedTokenCounter:
transformer_ref = explicit_path or explicit_model or normalized_model
if _prefer_tiktoken(normalized_model):
counter = _try_build_tiktoken_counter(normalized_model)
if counter is not None:
return counter
counter = _try_build_transformers_counter(transformer_ref, trust_remote_code)
if counter is not None:
return counter
else:
counter = _try_build_transformers_counter(transformer_ref, trust_remote_code)
if counter is not None:
return counter
counter = _try_build_tiktoken_counter(normalized_model)
if counter is not None:
return counter
return ResolvedTokenCounter(
info=TokenCounterInfo(
backend='heuristic',
source='len(text)/4 fallback',
accurate=False,
),
count_text=_heuristic_count,
)
def _normalize_model(model: str | None) -> str | None:
if not isinstance(model, str):
return None
normalized = model.strip()
return normalized or None
def _normalize_env(name: str) -> str | None:
value = os.environ.get(name)
if not isinstance(value, str):
return None
stripped = value.strip()
return stripped or None
def _prefer_tiktoken(model: str | None) -> bool:
if model is None:
return False
lowered = model.lower()
return (
lowered.startswith('gpt')
or lowered.startswith('o1')
or lowered.startswith('o3')
or lowered.startswith('o4')
or 'gpt-4' in lowered
or 'gpt-5' in lowered
or 'openai' in lowered
)
def _try_build_tiktoken_counter(model: str | None) -> ResolvedTokenCounter | None:
if model is None:
return None
try:
import tiktoken
except ImportError:
return None
encoding = None
encoding_name = None
try:
encoding = tiktoken.encoding_for_model(model)
encoding_name = getattr(encoding, 'name', model)
except KeyError:
fallback_name = _tiktoken_fallback_encoding(model)
if fallback_name is None:
return None
encoding = tiktoken.get_encoding(fallback_name)
encoding_name = fallback_name
except Exception:
return None
def _count(text: str) -> int:
if not text:
return 0
return len(encoding.encode_ordinary(text))
return ResolvedTokenCounter(
info=TokenCounterInfo(
backend='tiktoken',
source=encoding_name or 'unknown',
accurate=True,
),
count_text=_count,
)
def _tiktoken_fallback_encoding(model: str) -> str | None:
lowered = model.lower()
if lowered.startswith('gpt') or lowered.startswith('o1') or lowered.startswith('o3') or lowered.startswith('o4'):
return 'o200k_base'
if 'gpt-3.5' in lowered or 'gpt-4' in lowered:
return 'cl100k_base'
return None
def _try_build_transformers_counter(
model_ref: str | None,
trust_remote_code: str | None,
) -> ResolvedTokenCounter | None:
if model_ref is None:
return None
try:
from transformers import AutoTokenizer
except ImportError:
return None
trust_remote_code_enabled = isinstance(trust_remote_code, str) and trust_remote_code.lower() in {
'1',
'true',
'yes',
'on',
}
tokenizer = None
try:
tokenizer = AutoTokenizer.from_pretrained(
model_ref,
local_files_only=True,
use_fast=True,
trust_remote_code=trust_remote_code_enabled,
)
except Exception:
return None
def _count(text: str) -> int:
if not text:
return 0
encoded: Any = tokenizer.encode(text, add_special_tokens=False)
return len(encoded)
return ResolvedTokenCounter(
info=TokenCounterInfo(
backend='transformers',
source=f'{model_ref} (local_files_only)',
accurate=True,
),
count_text=_count,
)
def _heuristic_count(text: str) -> int:
if not text:
return 0
return max(1, math.ceil(len(text) / 4))
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from src.account_runtime import AccountRuntime
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
from src.agent_types import AgentRuntimeConfig
class AccountRuntimeTests(unittest.TestCase):
def test_account_runtime_discovers_profiles_and_persists_login(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / '.claw-account.json').write_text(
(
'{"profiles":['
'{"name":"local","provider":"openai","identity":"dev@example.com","authMode":"api_key"},'
'{"name":"team","provider":"anthropic","identity":"team@example.com","org":"Harness"}'
']}'
),
encoding='utf-8',
)
with patch.dict('os.environ', {'OPENAI_API_KEY': 'local-token'}, clear=False):
runtime = AccountRuntime.from_workspace(workspace)
report = runtime.login('local')
restored = AccountRuntime.from_workspace(workspace)
self.assertEqual(len(runtime.profiles), 2)
self.assertTrue(report.logged_in)
self.assertEqual(report.profile_name, 'local')
self.assertIsNotNone(restored.active_session)
self.assertEqual(restored.active_session.profile_name, 'local')
self.assertIn('Credential env vars: OPENAI_API_KEY', restored.render_summary())
def test_account_runtime_logout_clears_active_session(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / '.claw-account.json').write_text(
'{"profiles":[{"name":"local","provider":"openai","identity":"dev@example.com"}]}',
encoding='utf-8',
)
runtime = AccountRuntime.from_workspace(workspace)
runtime.login('local')
report = runtime.logout()
self.assertFalse(report.logged_in)
self.assertIn('Logged out dev@example.com', report.detail)
def test_account_tools_execute_against_runtime(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / '.claw-account.json').write_text(
'{"profiles":[{"name":"local","provider":"openai","identity":"dev@example.com"}]}',
encoding='utf-8',
)
runtime = AccountRuntime.from_workspace(workspace)
context = build_tool_context(
AgentRuntimeConfig(cwd=workspace),
account_runtime=runtime,
)
list_result = execute_tool(
default_tool_registry(),
'account_list_profiles',
{},
context,
)
login_result = execute_tool(
default_tool_registry(),
'account_login',
{'target': 'local'},
context,
)
status_result = execute_tool(
default_tool_registry(),
'account_status',
{},
context,
)
self.assertTrue(list_result.ok)
self.assertIn('dev@example.com', list_result.content)
self.assertTrue(login_result.ok)
self.assertIn('profile=local', login_result.content)
self.assertTrue(status_result.ok)
self.assertIn('Configured account profiles: 1', status_result.content)
+65
View File
@@ -103,6 +103,71 @@ class AgentContextTests(unittest.TestCase):
self.assertIn('mcpRuntime', snapshot.user_context)
self.assertIn('Local MCP resources: 1', snapshot.user_context['mcpRuntime'])
def test_user_context_loads_search_runtime_summary(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir) / 'repo'
workspace.mkdir(parents=True)
(workspace / '.claw-search.json').write_text(
'{"providers":[{"name":"local-search","provider":"searxng","baseUrl":"http://127.0.0.1:8080"}]}',
encoding='utf-8',
)
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace))
self.assertIn('searchRuntime', snapshot.user_context)
self.assertIn('Configured search providers: 1', snapshot.user_context['searchRuntime'])
self.assertIn('local-search', snapshot.user_context['searchRuntime'])
def test_user_context_loads_remote_runtime_summary(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir) / 'repo'
workspace.mkdir(parents=True)
(workspace / '.claw-remote.json').write_text(
(
'{"profiles":[{"name":"staging","mode":"ssh","target":"dev@staging",'
'"workspaceCwd":"/srv/app","sessionUrl":"wss://remote/session"}]}'
),
encoding='utf-8',
)
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace))
self.assertIn('remoteRuntime', snapshot.user_context)
self.assertIn('Configured remote profiles: 1', snapshot.user_context['remoteRuntime'])
self.assertIn('staging', snapshot.user_context['remoteRuntime'])
def test_user_context_loads_account_runtime_summary(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir) / 'repo'
workspace.mkdir(parents=True)
(workspace / '.claw-account.json').write_text(
'{"profiles":[{"name":"local","provider":"openai","identity":"dev@example.com"}]}',
encoding='utf-8',
)
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace))
self.assertIn('accountRuntime', snapshot.user_context)
self.assertIn('Configured account profiles: 1', snapshot.user_context['accountRuntime'])
self.assertIn('dev@example.com', snapshot.user_context['accountRuntime'])
def test_user_context_loads_config_runtime_summary(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir) / 'repo'
workspace.mkdir(parents=True)
claude_dir = workspace / '.claude'
claude_dir.mkdir()
(claude_dir / 'settings.json').write_text(
'{"review":{"mode":"strict"}}',
encoding='utf-8',
)
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace))
self.assertIn('configRuntime', snapshot.user_context)
self.assertIn('Config sources: 1', snapshot.user_context['configRuntime'])
self.assertIn('Effective keys: 2', snapshot.user_context['configRuntime'])
def test_user_context_loads_task_runtime_summary(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir) / 'repo'
+1
View File
@@ -35,6 +35,7 @@ class AgentContextUsageTests(unittest.TestCase):
self.assertGreater(report.total_tokens, 0)
self.assertIn('## Context Usage', rendered)
self.assertIn('**Token counter:**', rendered)
self.assertIn('### System Prompt Sections', rendered)
self.assertIn('### Message Breakdown', rendered)
self.assertIn('#### Top Tools', rendered)
+82
View File
@@ -125,6 +125,88 @@ class AgentPromptingTests(unittest.TestCase):
prompt = render_system_prompt(parts)
self.assertIn('# MCP', prompt)
def test_prompt_builder_mentions_search_when_runtime_is_loaded(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / '.claw-search.json').write_text(
'{"providers":[{"name":"local-search","provider":"searxng","baseUrl":"http://127.0.0.1:8080"}]}',
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('# Search', prompt)
self.assertIn('web_search', prompt)
def test_prompt_builder_mentions_remote_when_manifest_is_loaded(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / '.claw-remote.json').write_text(
(
'{"profiles":[{"name":"staging","mode":"ssh","target":"dev@staging",'
'"workspaceCwd":"/srv/app"}]}'
),
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('# Remote', prompt)
def test_prompt_builder_mentions_account_when_runtime_is_loaded(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / '.claw-account.json').write_text(
'{"profiles":[{"name":"local","provider":"openai","identity":"dev@example.com"}]}',
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('# Account', prompt)
def test_prompt_builder_mentions_config_when_runtime_is_loaded(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
claude_dir = workspace / '.claude'
claude_dir.mkdir()
(claude_dir / 'settings.json').write_text(
'{"review":{"mode":"strict"}}',
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('# Config', prompt)
def test_prompt_builder_mentions_tasks_when_runtime_is_loaded(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
+191
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
import tempfile
import unittest
import json
import sys
from pathlib import Path
from unittest.mock import patch
@@ -12,6 +14,55 @@ from src.plan_runtime import PlanRuntime
from src.task_runtime import TaskRuntime
class _FakeHTTPResponse:
def __init__(self, payload: str) -> None:
self.payload = payload
def read(self) -> bytes:
return self.payload.encode('utf-8')
def __enter__(self) -> '_FakeHTTPResponse':
return self
def __exit__(self, exc_type, exc, tb) -> None:
return None
def _write_fake_mcp_server(workspace: Path) -> Path:
server_path = workspace / 'fake_mcp_server.py'
server_path.write_text(
(
'import json, sys\n'
'TOOLS = [{"name": "echo", "description": "Echo text", "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}}}]\n'
'for raw in sys.stdin:\n'
' raw = raw.strip()\n'
' if not raw:\n'
' continue\n'
' message = json.loads(raw)\n'
' method = message.get("method")\n'
' if method == "initialize":\n'
' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"protocolVersion": "2025-11-25", "capabilities": {"resources": {}, "tools": {}}, "serverInfo": {"name": "fake-remote", "version": "1.0.0"}}}\n'
' print(json.dumps(response), flush=True)\n'
' continue\n'
' if method == "notifications/initialized":\n'
' continue\n'
' if method == "tools/list":\n'
' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"tools": TOOLS}}\n'
' print(json.dumps(response), flush=True)\n'
' continue\n'
' if method == "tools/call":\n'
' text = message.get("params", {}).get("arguments", {}).get("text", "")\n'
' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"content": [{"type": "text", "text": "echo:" + text}], "isError": False}}\n'
' print(json.dumps(response), flush=True)\n'
' continue\n'
' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"resources": []}}\n'
' print(json.dumps(response), flush=True)\n'
),
encoding='utf-8',
)
return server_path
class AgentSlashCommandTests(unittest.TestCase):
def test_parse_slash_command(self) -> None:
parsed = parse_slash_command('/context extra args')
@@ -84,6 +135,142 @@ class AgentSlashCommandTests(unittest.TestCase):
self.assertIn('mcp notes', resource_result.final_output)
self.assertIn('# MCP', legacy_mcp_result.final_output)
def test_mcp_tools_command_renders_transport_backed_tools(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
server_path = _write_fake_mcp_server(workspace)
(workspace / '.claw-mcp.json').write_text(
json.dumps(
{
'mcpServers': {
'remote': {
'command': sys.executable,
'args': ['-u', str(server_path)],
}
}
}
),
encoding='utf-8',
)
agent = LocalCodingAgent(
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
runtime_config=AgentRuntimeConfig(cwd=workspace),
)
tools_result = agent.run('/mcp tools')
tool_result = agent.run('/mcp tool echo')
self.assertIn('# MCP Tools', tools_result.final_output)
self.assertIn('echo', tools_result.final_output)
self.assertIn('# MCP Tool Result', tool_result.final_output)
self.assertIn('echo:', tool_result.final_output)
def test_search_commands_render_and_update_local_search_runtime(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / '.claw-search.json').write_text(
(
'{"providers":['
'{"name":"local-search","provider":"searxng","baseUrl":"http://127.0.0.1:8080"},'
'{"name":"backup-search","provider":"searxng","baseUrl":"http://127.0.0.2:8080"}'
']}'
),
encoding='utf-8',
)
agent = LocalCodingAgent(
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
runtime_config=AgentRuntimeConfig(cwd=workspace),
)
with patch(
'src.search_runtime.request.urlopen',
return_value=_FakeHTTPResponse(
'{"results":[{"title":"Alpha","url":"https://example.com/alpha","content":"Search snippet"}]}'
),
):
search_result = agent.run('/search alpha query')
providers_result = agent.run('/search providers')
activate_result = agent.run('/search use backup-search')
provider_result = agent.run('/search provider backup-search')
self.assertIn('# Web Search', search_result.final_output)
self.assertIn('Alpha', search_result.final_output)
self.assertIn('# Search Providers', providers_result.final_output)
self.assertIn('local-search', providers_result.final_output)
self.assertIn('backup-search', providers_result.final_output)
self.assertIn('provider=backup-search', activate_result.final_output)
self.assertIn('# Search Provider', provider_result.final_output)
self.assertIn('backup-search', provider_result.final_output)
def test_remote_commands_render_and_update_local_remote_runtime(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / '.claw-remote.json').write_text(
(
'{"profiles":[{"name":"staging","mode":"ssh","target":"dev@staging",'
'"workspaceCwd":"/srv/app","sessionUrl":"wss://remote/session"}]}'
),
encoding='utf-8',
)
agent = LocalCodingAgent(
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
runtime_config=AgentRuntimeConfig(cwd=workspace),
)
remotes_result = agent.run('/remotes')
remote_result = agent.run('/remote')
ssh_result = agent.run('/ssh staging')
disconnect_result = agent.run('/disconnect')
self.assertIn('# Remote Profiles', remotes_result.final_output)
self.assertIn('staging', remotes_result.final_output)
self.assertIn('# Remote', remote_result.final_output)
self.assertIn('Configured remote profiles: 1', remote_result.final_output)
self.assertIn('mode=ssh', ssh_result.final_output)
self.assertIn('profile=staging', ssh_result.final_output)
self.assertIn('connected=False', disconnect_result.final_output)
def test_account_commands_render_and_update_local_account_runtime(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / '.claw-account.json').write_text(
'{"profiles":[{"name":"local","provider":"openai","identity":"dev@example.com"}]}',
encoding='utf-8',
)
agent = LocalCodingAgent(
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
runtime_config=AgentRuntimeConfig(cwd=workspace),
)
account_result = agent.run('/account')
profiles_result = agent.run('/account profiles')
login_result = agent.run('/login local')
logout_result = agent.run('/logout')
self.assertIn('# Account', account_result.final_output)
self.assertIn('Configured account profiles: 1', account_result.final_output)
self.assertIn('# Account Profiles', profiles_result.final_output)
self.assertIn('dev@example.com', profiles_result.final_output)
self.assertIn('profile=local', login_result.final_output)
self.assertIn('logged_in=False', logout_result.final_output)
def test_config_commands_render_local_reports(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
claude_dir = workspace / '.claude'
claude_dir.mkdir()
(claude_dir / 'settings.json').write_text(
'{"review":{"mode":"strict"}}',
encoding='utf-8',
)
agent = LocalCodingAgent(
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
runtime_config=AgentRuntimeConfig(cwd=workspace),
)
config_result = agent.run('/config')
effective_result = agent.run('/config effective')
value_result = agent.run('/config get review.mode')
source_result = agent.run('/settings source project')
self.assertIn('# Config', config_result.final_output)
self.assertIn('Config sources: 1', config_result.final_output)
self.assertIn('# Config Effective', effective_result.final_output)
self.assertIn('"review"', effective_result.final_output)
self.assertIn('# Config Value', value_result.final_output)
self.assertIn('"strict"', value_result.final_output)
self.assertIn('# Config Source', source_result.final_output)
def test_tasks_and_task_commands_render_local_reports(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
@@ -100,11 +287,14 @@ class AgentSlashCommandTests(unittest.TestCase):
tasks_result = agent.run('/tasks')
task_result = agent.run(f'/task {task_id}')
todo_result = agent.run('/todo in_progress')
next_result = agent.run('/task-next')
self.assertIn('# Tasks', tasks_result.final_output)
self.assertIn(task_id, tasks_result.final_output)
self.assertIn('# Task', task_result.final_output)
self.assertIn('in_progress', task_result.final_output)
self.assertIn('# Tasks', todo_result.final_output)
self.assertIn('# Next Tasks', next_result.final_output)
self.assertIn(task_id, next_result.final_output)
def test_plan_command_renders_local_report(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
@@ -133,6 +323,7 @@ class AgentSlashCommandTests(unittest.TestCase):
self.assertIn('# Tools', tools_result.final_output)
self.assertIn('`read_file`', tools_result.final_output)
self.assertIn('# Status', status_result.final_output)
self.assertIn('Token counter:', status_result.final_output)
self.assertIn('Last run: none', status_result.final_output)
def test_hooks_and_trust_commands_render_local_reports(self) -> None:
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
import json
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.config_runtime import ConfigRuntime
class ConfigRuntimeTests(unittest.TestCase):
def test_config_runtime_loads_and_merges_sources(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
claude_dir = workspace / '.claude'
claude_dir.mkdir()
(claude_dir / 'settings.json').write_text(
'{"model":{"name":"project-model","temperature":0.1},"review":{"strict":false}}',
encoding='utf-8',
)
(claude_dir / 'settings.local.json').write_text(
'{"model":{"temperature":0.0},"review":{"strict":true}}',
encoding='utf-8',
)
runtime = ConfigRuntime.from_workspace(workspace)
self.assertTrue(runtime.has_config())
self.assertEqual(runtime.get_value('model.name'), 'project-model')
self.assertEqual(runtime.get_value('model.temperature'), 0.0)
self.assertEqual(runtime.get_value('review.strict'), True)
self.assertIn('Config sources: 2', runtime.render_summary())
def test_config_runtime_persists_set_value(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
runtime = ConfigRuntime.from_workspace(workspace)
mutation = runtime.set_value('review.mode', 'strict', source='local')
restored = ConfigRuntime.from_workspace(workspace)
self.assertEqual(mutation.source_name, 'local')
self.assertEqual(restored.get_value('review.mode'), 'strict')
self.assertIn('review.mode', restored.render_keys())
self.assertEqual(json.loads(restored.render_value('review.mode')), 'strict')
def test_config_tools_execute_against_runtime(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
runtime = ConfigRuntime.from_workspace(workspace)
context = build_tool_context(
AgentRuntimeConfig(
cwd=workspace,
permissions=AgentPermissions(allow_file_write=True),
),
config_runtime=runtime,
)
set_result = execute_tool(
default_tool_registry(),
'config_set',
{'key_path': 'review.mode', 'value': 'strict'},
context,
)
list_result = execute_tool(
default_tool_registry(),
'config_list',
{'prefix': 'review'},
context,
)
get_result = execute_tool(
default_tool_registry(),
'config_get',
{'key_path': 'review.mode'},
context,
)
self.assertTrue(set_result.ok)
self.assertEqual(set_result.metadata.get('source_name'), 'local')
self.assertIn('# Config Keys', list_result.content)
self.assertIn('review.mode', list_result.content)
self.assertIn('# Config Value', get_result.content)
self.assertIn('"strict"', get_result.content)
+67
View File
@@ -0,0 +1,67 @@
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 AgentRuntimeConfig
class ExtendedToolTests(unittest.TestCase):
def test_web_fetch_reads_text_from_file_url(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
target = workspace / 'page.txt'
target.write_text('hello from web fetch\n', encoding='utf-8')
context = build_tool_context(
AgentRuntimeConfig(cwd=workspace),
tool_registry=default_tool_registry(),
)
result = execute_tool(
default_tool_registry(),
'web_fetch',
{'url': target.resolve().as_uri()},
context,
)
self.assertTrue(result.ok)
self.assertIn('hello from web fetch', result.content)
self.assertEqual(result.metadata.get('action'), 'web_fetch')
def test_tool_search_lists_matching_tools(self) -> None:
registry = default_tool_registry()
with tempfile.TemporaryDirectory() as tmp_dir:
context = build_tool_context(
AgentRuntimeConfig(cwd=Path(tmp_dir)),
tool_registry=registry,
)
result = execute_tool(
registry,
'tool_search',
{'query': 'file'},
context,
)
self.assertTrue(result.ok)
self.assertIn('# Tool Search', result.content)
self.assertIn('read_file', result.content)
self.assertIn('write_file', result.content)
def test_sleep_tool_waits_briefly_and_returns_metadata(self) -> None:
registry = default_tool_registry()
with tempfile.TemporaryDirectory() as tmp_dir:
context = build_tool_context(
AgentRuntimeConfig(cwd=Path(tmp_dir)),
tool_registry=registry,
)
result = execute_tool(
registry,
'sleep',
{'seconds': 0.01},
context,
)
self.assertTrue(result.ok)
self.assertIn('slept for', result.content)
self.assertEqual(result.metadata.get('action'), 'sleep')
+40
View File
@@ -127,3 +127,43 @@ class MainCliTests(unittest.TestCase):
self.assertEqual(recorded_results, ['First chat reply.', 'Second chat reply.'])
self.assertIn('# Agent Chat', recorded_lines)
self.assertIn('chat_ended=user_exit', recorded_lines)
def test_parser_accepts_remote_runtime_commands(self) -> None:
parser = build_parser()
args = parser.parse_args(['remote-profiles', '--cwd', '.'])
self.assertEqual(args.command, 'remote-profiles')
self.assertEqual(args.cwd, '.')
def test_parser_accepts_account_runtime_commands(self) -> None:
parser = build_parser()
args = parser.parse_args(['account-profiles', '--cwd', '.'])
self.assertEqual(args.command, 'account-profiles')
self.assertEqual(args.cwd, '.')
def test_parser_accepts_search_runtime_commands(self) -> None:
parser = build_parser()
args = parser.parse_args(['search', 'repo query', '--cwd', '.', '--provider', 'local-search'])
self.assertEqual(args.command, 'search')
self.assertEqual(args.query, 'repo query')
self.assertEqual(args.provider, 'local-search')
self.assertEqual(args.cwd, '.')
def test_parser_accepts_mcp_runtime_commands(self) -> None:
parser = build_parser()
args = parser.parse_args(['mcp-tools', '--cwd', '.', '--server', 'remote'])
self.assertEqual(args.command, 'mcp-tools')
self.assertEqual(args.server, 'remote')
self.assertEqual(args.cwd, '.')
def test_parser_accepts_daemon_subcommands(self) -> None:
parser = build_parser()
args = parser.parse_args(['daemon', 'ps'])
self.assertEqual(args.command, 'daemon')
self.assertEqual(args.daemon_command, 'ps')
def test_parser_accepts_config_runtime_commands(self) -> None:
parser = build_parser()
args = parser.parse_args(['config-get', 'review.mode', '--cwd', '.'])
self.assertEqual(args.command, 'config-get')
self.assertEqual(args.key_path, 'review.mode')
self.assertEqual(args.cwd, '.')
+192
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import sys
import tempfile
import unittest
from pathlib import Path
@@ -36,6 +37,52 @@ def make_urlopen_side_effect(responses: list[dict[str, object]]):
class MCPRuntimeTests(unittest.TestCase):
def _write_fake_stdio_server(self, workspace: Path) -> Path:
server_path = workspace / 'fake_mcp_server.py'
server_path.write_text(
(
'import json, sys\n'
'RESOURCES = [{"uri": "mcp://remote/notes", "name": "Remote Notes", "mimeType": "text/plain"}]\n'
'TOOLS = [{"name": "echo", "description": "Echo text", "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}}}]\n'
'for raw in sys.stdin:\n'
' raw = raw.strip()\n'
' if not raw:\n'
' continue\n'
' message = json.loads(raw)\n'
' method = message.get("method")\n'
' if method == "initialize":\n'
' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"protocolVersion": "2025-11-25", "capabilities": {"resources": {}, "tools": {}}, "serverInfo": {"name": "fake-remote", "version": "1.0.0"}}}\n'
' print(json.dumps(response), flush=True)\n'
' continue\n'
' if method == "notifications/initialized":\n'
' continue\n'
' if method == "resources/list":\n'
' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"resources": RESOURCES}}\n'
' print(json.dumps(response), flush=True)\n'
' continue\n'
' if method == "resources/read":\n'
' uri = message.get("params", {}).get("uri")\n'
' text = "remote notes via stdio" if uri == "mcp://remote/notes" else "unknown resource"\n'
' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"contents": [{"uri": uri, "mimeType": "text/plain", "text": text}]}}\n'
' print(json.dumps(response), flush=True)\n'
' continue\n'
' if method == "tools/list":\n'
' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"tools": TOOLS}}\n'
' print(json.dumps(response), flush=True)\n'
' continue\n'
' if method == "tools/call":\n'
' params = message.get("params", {})\n'
' text = params.get("arguments", {}).get("text", "")\n'
' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"content": [{"type": "text", "text": "echo:" + text}], "isError": False}}\n'
' print(json.dumps(response), flush=True)\n'
' continue\n'
' response = {"jsonrpc": "2.0", "id": message.get("id"), "error": {"code": -32601, "message": "Method not found"}}\n'
' print(json.dumps(response), flush=True)\n'
),
encoding='utf-8',
)
return server_path
def test_runtime_discovers_and_reads_local_resources(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
@@ -55,6 +102,38 @@ class MCPRuntimeTests(unittest.TestCase):
self.assertEqual(runtime.read_resource('mcp://workspace/inline'), 'inline body')
self.assertIn('mcp notes', runtime.read_resource('mcp://workspace/notes'))
def test_runtime_discovers_stdio_server_and_remote_resources_and_tools(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
server_path = self._write_fake_stdio_server(workspace)
(workspace / '.claw-mcp.json').write_text(
json.dumps(
{
'mcpServers': {
'remote': {
'command': sys.executable,
'args': ['-u', str(server_path)],
}
}
}
),
encoding='utf-8',
)
runtime = MCPRuntime.from_workspace(workspace)
resources = runtime.list_resources()
tools = runtime.list_tools()
self.assertEqual(len(runtime.servers), 1)
self.assertTrue(runtime.has_transport_servers())
self.assertIn('Configured MCP servers: 1', runtime.render_summary())
self.assertEqual(len(resources), 1)
self.assertEqual(resources[0].uri, 'mcp://remote/notes')
self.assertIn('remote notes via stdio', runtime.read_resource('mcp://remote/notes'))
self.assertEqual(len(tools), 1)
self.assertEqual(tools[0].name, 'echo')
rendered, metadata = runtime.call_tool('echo', arguments={'text': 'hello'})
self.assertIn('echo:hello', rendered)
self.assertEqual(metadata.get('server_name'), 'remote')
def test_mcp_tools_execute_against_runtime(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
@@ -90,6 +169,47 @@ class MCPRuntimeTests(unittest.TestCase):
self.assertTrue(read_result.ok)
self.assertIn('mcp notes', read_result.content)
def test_mcp_transport_tools_execute_against_runtime(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
server_path = self._write_fake_stdio_server(workspace)
(workspace / '.claw-mcp.json').write_text(
json.dumps(
{
'mcpServers': {
'remote': {
'command': sys.executable,
'args': ['-u', str(server_path)],
}
}
}
),
encoding='utf-8',
)
runtime = MCPRuntime.from_workspace(workspace)
context = build_tool_context(
AgentRuntimeConfig(cwd=workspace),
mcp_runtime=runtime,
)
list_result = execute_tool(
default_tool_registry(),
'mcp_list_tools',
{},
context,
)
call_result = execute_tool(
default_tool_registry(),
'mcp_call_tool',
{'tool_name': 'echo', 'arguments': {'text': 'tool-run'}},
context,
)
self.assertTrue(list_result.ok)
self.assertIn('echo', list_result.content)
self.assertTrue(call_result.ok)
self.assertIn('echo:tool-run', call_result.content)
self.assertEqual(call_result.metadata.get('action'), 'mcp_call_tool')
def test_agent_can_use_mcp_tools_in_model_loop(self) -> None:
responses = [
{
@@ -156,3 +276,75 @@ class MCPRuntimeTests(unittest.TestCase):
if message.get('role') == 'tool'
)
self.assertIn('mcp notes', tool_message.get('content', ''))
def test_agent_can_use_transport_backed_mcp_call_tool_in_model_loop(self) -> None:
responses = [
{
'choices': [
{
'message': {
'role': 'assistant',
'content': 'I will call the remote MCP tool.',
'tool_calls': [
{
'id': 'call_1',
'type': 'function',
'function': {
'name': 'mcp_call_tool',
'arguments': '{"tool_name": "echo", "server": "remote", "arguments": {"text": "agent-call"}}',
},
}
],
},
'finish_reason': 'tool_calls',
}
],
'usage': {'prompt_tokens': 8, 'completion_tokens': 3},
},
{
'choices': [
{
'message': {
'role': 'assistant',
'content': 'The remote MCP tool replied with echo:agent-call.',
},
'finish_reason': 'stop',
}
],
'usage': {'prompt_tokens': 6, 'completion_tokens': 3},
},
]
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
server_path = self._write_fake_stdio_server(workspace)
(workspace / '.claw-mcp.json').write_text(
json.dumps(
{
'mcpServers': {
'remote': {
'command': sys.executable,
'args': ['-u', str(server_path)],
}
}
}
),
encoding='utf-8',
)
with patch('src.openai_compat.request.urlopen', side_effect=make_urlopen_side_effect(responses)):
agent = LocalCodingAgent(
model_config=ModelConfig(
model='Qwen/Qwen3-Coder-30B-A3B-Instruct',
base_url='http://127.0.0.1:8000/v1',
),
runtime_config=AgentRuntimeConfig(cwd=workspace),
)
result = agent.run('Call the remote MCP echo tool')
self.assertEqual(result.final_output, 'The remote MCP tool replied with echo:agent-call.')
self.assertEqual(result.tool_calls, 1)
tool_message = next(
message
for message in result.transcript
if message.get('role') == 'tool'
)
self.assertIn('echo:agent-call', tool_message.get('content', ''))
+5 -1
View File
@@ -51,7 +51,8 @@ class PlanRuntimeTests(unittest.TestCase):
},
{
'step': 'Patch the tool registry',
'status': 'pending',
'status': 'blocked',
'depends_on': ['plan_1'],
},
],
explanation='Work through the runtime in two phases.',
@@ -59,12 +60,15 @@ class PlanRuntimeTests(unittest.TestCase):
)
rendered_plan = plan_runtime.render_plan()
rendered_tasks = task_runtime.render_tasks()
rendered_task = task_runtime.render_task('plan_2')
self.assertEqual(mutation.after_count, 2)
self.assertEqual(mutation.synced_tasks, 2)
self.assertIn('Inspect the runtime loop', rendered_plan)
self.assertIn('Work through the runtime in two phases.', rendered_plan)
self.assertIn('depends_on: plan_1', rendered_plan)
self.assertIn('Inspect the runtime loop', rendered_tasks)
self.assertIn('Blocked By: plan_1', rendered_task)
def test_plan_tools_execute_against_runtime(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
+123
View File
@@ -0,0 +1,123 @@
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 AgentRuntimeConfig
from src.remote_runtime import (
RemoteRuntime,
run_deep_link_mode,
run_direct_connect_mode,
run_remote_mode,
run_ssh_mode,
run_teleport_mode,
)
class RemoteRuntimeTests(unittest.TestCase):
def test_remote_runtime_discovers_profiles_and_persists_connection(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / '.claw-remote.json').write_text(
(
'{"profiles":['
'{"name":"staging","mode":"ssh","target":"dev@staging","workspaceCwd":"/srv/app"},'
'{"name":"preview","mode":"deep-link","target":"preview://session"}'
']}'
),
encoding='utf-8',
)
runtime = RemoteRuntime.from_workspace(workspace)
report = runtime.connect('staging')
restored = RemoteRuntime.from_workspace(workspace)
self.assertEqual(len(runtime.profiles), 2)
self.assertTrue(report.connected)
self.assertEqual(report.profile_name, 'staging')
self.assertIsNotNone(restored.active_connection)
self.assertEqual(restored.active_connection.profile_name, 'staging')
self.assertIn('Configured remote profiles: 2', restored.render_summary())
def test_remote_runtime_disconnect_clears_active_connection(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / '.claw-remote.json').write_text(
'{"profiles":[{"name":"staging","mode":"ssh","target":"dev@staging"}]}',
encoding='utf-8',
)
runtime = RemoteRuntime.from_workspace(workspace)
runtime.connect('staging')
report = runtime.disconnect()
self.assertFalse(report.connected)
self.assertIn('Disconnected ssh target dev@staging', report.detail)
def test_remote_mode_helpers_use_manifest_backed_profiles(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / '.claw-remote.json').write_text(
(
'{"profiles":['
'{"name":"workspace","mode":"remote","target":"remote://workspace"},'
'{"name":"sshbox","mode":"ssh","target":"dev@sshbox"},'
'{"name":"tele","mode":"teleport","target":"teleport://workspace"},'
'{"name":"direct","mode":"direct-connect","target":"direct://workspace"},'
'{"name":"link","mode":"deep-link","target":"deep://workspace"}'
']}'
),
encoding='utf-8',
)
remote_report = run_remote_mode('workspace', cwd=workspace)
ssh_report = run_ssh_mode('sshbox', cwd=workspace)
teleport_report = run_teleport_mode('tele', cwd=workspace)
direct_report = run_direct_connect_mode('direct', cwd=workspace)
deep_link_report = run_deep_link_mode('link', cwd=workspace)
self.assertEqual(remote_report.profile_name, 'workspace')
self.assertEqual(ssh_report.mode, 'ssh')
self.assertEqual(teleport_report.mode, 'teleport')
self.assertEqual(direct_report.mode, 'direct-connect')
self.assertEqual(deep_link_report.mode, 'deep-link')
def test_remote_tools_execute_against_runtime(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / '.claw-remote.json').write_text(
(
'{"profiles":[{"name":"staging","mode":"ssh","target":"dev@staging",'
'"workspaceCwd":"/srv/app","sessionUrl":"wss://remote/session"}]}'
),
encoding='utf-8',
)
runtime = RemoteRuntime.from_workspace(workspace)
context = build_tool_context(
AgentRuntimeConfig(cwd=workspace),
remote_runtime=runtime,
)
list_result = execute_tool(
default_tool_registry(),
'remote_list_profiles',
{},
context,
)
connect_result = execute_tool(
default_tool_registry(),
'remote_connect',
{'target': 'staging'},
context,
)
status_result = execute_tool(
default_tool_registry(),
'remote_status',
{},
context,
)
self.assertTrue(list_result.ok)
self.assertIn('staging', list_result.content)
self.assertTrue(connect_result.ok)
self.assertIn('profile=staging', connect_result.content)
self.assertTrue(status_result.ok)
self.assertIn('Configured remote profiles: 1', status_result.content)
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
from src.agent_types import AgentRuntimeConfig
from src.search_runtime import SearchRuntime
class FakeHTTPResponse:
def __init__(self, payload: str) -> None:
self.payload = payload
def read(self) -> bytes:
return self.payload.encode('utf-8')
def __enter__(self) -> 'FakeHTTPResponse':
return self
def __exit__(self, exc_type, exc, tb) -> None:
return None
class SearchRuntimeTests(unittest.TestCase):
def test_provider_activation_persists_across_reload(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / '.claw-search.json').write_text(
(
'{"providers":['
'{"name":"primary","provider":"searxng","baseUrl":"http://127.0.0.1:8080"},'
'{"name":"backup","provider":"searxng","baseUrl":"http://127.0.0.2:8080"}'
']}'
),
encoding='utf-8',
)
runtime = SearchRuntime.from_workspace(workspace)
report = runtime.activate_provider('backup')
reloaded = SearchRuntime.from_workspace(workspace)
self.assertEqual(report.provider_name, 'backup')
self.assertIsNotNone(reloaded.current_provider())
self.assertEqual(reloaded.current_provider().name, 'backup')
def test_search_runtime_loads_searxng_provider_from_env(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
with patch.dict('os.environ', {'SEARXNG_BASE_URL': 'http://127.0.0.1:8888'}, clear=False):
runtime = SearchRuntime.from_workspace(workspace)
provider = runtime.current_provider()
self.assertIsNotNone(provider)
self.assertEqual(provider.name, 'searxng')
self.assertEqual(provider.base_url, 'http://127.0.0.1:8888')
def test_search_runtime_parses_searxng_results(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / '.claw-search.json').write_text(
'{"providers":[{"name":"local-search","provider":"searxng","baseUrl":"http://127.0.0.1:8080"}]}',
encoding='utf-8',
)
runtime = SearchRuntime.from_workspace(workspace)
with patch(
'src.search_runtime.request.urlopen',
return_value=FakeHTTPResponse(
'{"results":[{"title":"Alpha","url":"https://example.com/alpha","content":"Snippet"}]}'
),
):
provider, results = runtime.search('alpha')
self.assertEqual(provider.name, 'local-search')
self.assertEqual(len(results), 1)
self.assertEqual(results[0].title, 'Alpha')
self.assertEqual(results[0].url, 'https://example.com/alpha')
def test_search_runtime_parses_brave_results(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / '.claw-search.json').write_text(
'{"providers":[{"name":"brave-local","provider":"brave","baseUrl":"https://api.search.brave.com/res/v1/web/search","apiKeyEnv":"BRAVE_SEARCH_API_KEY"}]}',
encoding='utf-8',
)
with patch.dict('os.environ', {'BRAVE_SEARCH_API_KEY': 'demo-key'}, clear=False):
runtime = SearchRuntime.from_workspace(workspace)
with patch(
'src.search_runtime.request.urlopen',
return_value=FakeHTTPResponse(
'{"web":{"results":[{"title":"Alpha","url":"https://example.com/alpha","description":"Snippet"}]}}'
),
):
provider, results = runtime.search('alpha', provider_name='brave-local')
self.assertEqual(provider.provider, 'brave')
self.assertEqual(len(results), 1)
self.assertEqual(results[0].snippet, 'Snippet')
def test_web_search_tool_uses_search_runtime(self) -> None:
registry = default_tool_registry()
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / '.claw-search.json').write_text(
'{"providers":[{"name":"local-search","provider":"searxng","baseUrl":"http://127.0.0.1:8080"}]}',
encoding='utf-8',
)
runtime = SearchRuntime.from_workspace(workspace)
context = build_tool_context(
AgentRuntimeConfig(cwd=workspace),
tool_registry=registry,
search_runtime=runtime,
)
with patch(
'src.search_runtime.request.urlopen',
return_value=FakeHTTPResponse(
'{"results":[{"title":"Alpha","url":"https://example.com/alpha","content":"Snippet"}]}'
),
):
result = execute_tool(
registry,
'web_search',
{'query': 'alpha'},
context,
)
self.assertTrue(result.ok)
self.assertIn('# Web Search', result.content)
self.assertEqual(result.metadata.get('action'), 'web_search')
self.assertEqual(result.metadata.get('provider'), 'local-search')
self.assertEqual(result.metadata.get('result_count'), 1)
+84 -6
View File
@@ -46,12 +46,12 @@ class TaskRuntimeTests(unittest.TestCase):
status='in_progress',
)
assert created.task is not None
runtime.update_task(created.task.task_id, status='done')
runtime.update_task(created.task.task_id, status='completed')
rendered_tasks = runtime.render_tasks()
rendered_task = runtime.render_task(created.task.task_id)
self.assertIn('Implement task runtime', rendered_tasks)
self.assertIn('done', rendered_task)
self.assertIn('completed', rendered_task)
def test_task_tools_execute_against_runtime(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
@@ -67,11 +67,17 @@ class TaskRuntimeTests(unittest.TestCase):
create_result = execute_tool(
default_tool_registry(),
'task_create',
{'title': 'Review task tools', 'status': 'todo'},
{'title': 'Review task tools', 'status': 'pending'},
context,
)
self.assertTrue(create_result.ok)
task_id = str(create_result.metadata.get('task_id'))
next_result = execute_tool(
default_tool_registry(),
'task_next',
{},
context,
)
list_result = execute_tool(
default_tool_registry(),
'task_list',
@@ -87,7 +93,7 @@ class TaskRuntimeTests(unittest.TestCase):
update_result = execute_tool(
default_tool_registry(),
'task_update',
{'task_id': task_id, 'status': 'done'},
{'task_id': task_id, 'status': 'completed'},
context,
)
todo_result = execute_tool(
@@ -97,13 +103,85 @@ class TaskRuntimeTests(unittest.TestCase):
context,
)
self.assertIn('Review task tools', next_result.content)
self.assertIn(task_id, list_result.content)
self.assertIn('Review task tools', get_result.content)
self.assertTrue(update_result.ok)
self.assertEqual(update_result.metadata.get('task_status'), 'done')
self.assertEqual(update_result.metadata.get('task_status'), 'completed')
self.assertTrue(todo_result.ok)
self.assertEqual(todo_result.metadata.get('total_tasks'), 1)
def test_next_tasks_respects_dependencies_and_completion(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
runtime = TaskRuntime.from_workspace(workspace)
runtime.replace_tasks(
[
{'task_id': 'scan', 'title': 'Scan workspace', 'status': 'pending'},
{
'task_id': 'patch',
'title': 'Patch files',
'status': 'blocked',
'blocked_by': ['scan'],
},
]
)
first_next = runtime.render_next_tasks()
runtime.complete_task('scan')
second_next = runtime.render_next_tasks()
self.assertIn('Scan workspace', first_next)
self.assertNotIn('Patch files', first_next)
self.assertIn('Patch files', second_next)
def test_task_execution_tools_handle_block_start_and_complete(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
runtime = TaskRuntime.from_workspace(workspace)
runtime.replace_tasks(
[
{'task_id': 'scan', 'title': 'Scan workspace', 'status': 'pending'},
{
'task_id': 'patch',
'title': 'Patch files',
'status': 'blocked',
'blocked_by': ['scan'],
},
]
)
context = build_tool_context(
AgentRuntimeConfig(
cwd=workspace,
permissions=AgentPermissions(allow_file_write=True),
),
task_runtime=runtime,
)
blocked_start = execute_tool(
default_tool_registry(),
'task_start',
{'task_id': 'patch'},
context,
)
complete_scan = execute_tool(
default_tool_registry(),
'task_complete',
{'task_id': 'scan'},
context,
)
start_patch = execute_tool(
default_tool_registry(),
'task_start',
{'task_id': 'patch', 'owner': 'agent_1', 'active_form': 'Patching files'},
context,
)
self.assertTrue(blocked_start.ok)
self.assertIn('[blocked]', blocked_start.content)
self.assertTrue(complete_scan.ok)
self.assertTrue(start_patch.ok)
self.assertIn('[in_progress]', start_patch.content)
self.assertEqual(runtime.get_task('patch').owner, 'agent_1')
def test_agent_can_use_task_tools_in_model_loop(self) -> None:
responses = [
{
@@ -118,7 +196,7 @@ class TaskRuntimeTests(unittest.TestCase):
'type': 'function',
'function': {
'name': 'task_create',
'arguments': '{"title": "Review runtime tasks", "status": "todo"}',
'arguments': '{"title": "Review runtime tasks", "status": "pending"}',
},
}
],
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from src.tokenizer_runtime import (
ResolvedTokenCounter,
TokenCounterInfo,
clear_token_counter_cache,
count_tokens,
describe_token_counter,
resolve_token_counter,
)
class TokenizerRuntimeTests(unittest.TestCase):
def tearDown(self) -> None:
clear_token_counter_cache()
def test_gpt_models_prefer_tiktoken_backend_when_available(self) -> None:
fake_counter = ResolvedTokenCounter(
info=TokenCounterInfo(
backend='tiktoken',
source='o200k_base',
accurate=True,
),
count_text=lambda text: len(text.split()),
)
with patch('src.tokenizer_runtime._try_build_tiktoken_counter', return_value=fake_counter):
with patch('src.tokenizer_runtime._try_build_transformers_counter', return_value=None):
info = describe_token_counter('gpt-4o-mini')
token_count = count_tokens('hello world from claw code', 'gpt-4o-mini')
self.assertEqual(info.backend, 'tiktoken')
self.assertTrue(info.accurate)
self.assertEqual(token_count, 5)
def test_transformers_backend_can_be_selected_with_env_override(self) -> None:
fake_counter = ResolvedTokenCounter(
info=TokenCounterInfo(
backend='transformers',
source='/tmp/fake-tokenizer (local_files_only)',
accurate=True,
),
count_text=lambda text: len(text.split()),
)
with patch.dict(
'os.environ',
{'CLAW_CODE_TOKENIZER_PATH': '/tmp/fake-tokenizer'},
clear=False,
):
with patch('src.tokenizer_runtime._try_build_transformers_counter', return_value=fake_counter):
info = describe_token_counter('Qwen/Qwen3-Coder-30B-A3B-Instruct')
token_count = count_tokens('one two three', 'Qwen/Qwen3-Coder-30B-A3B-Instruct')
self.assertEqual(info.backend, 'transformers')
self.assertTrue(info.accurate)
self.assertEqual(token_count, 3)
def test_fallback_backend_is_used_when_all_tokenizers_fail(self) -> None:
with patch('src.tokenizer_runtime._try_build_tiktoken_counter', return_value=None):
with patch('src.tokenizer_runtime._try_build_transformers_counter', return_value=None):
counter = resolve_token_counter('unknown-model')
token_count = count_tokens('abcd' * 5, 'unknown-model')
self.assertEqual(counter.info.backend, 'heuristic')
self.assertFalse(counter.info.accurate)
self.assertGreater(token_count, 0)