add mcp and online search
This commit is contained in:
+96
-17
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+550
-57
@@ -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
|
||||
@@ -241,7 +409,7 @@ python3 -m src.main agent-chat \
|
||||
### 6.1 Read files
|
||||
|
||||
```bash
|
||||
python3 -m src.main agent \
|
||||
python3 -m src.main agent \
|
||||
"Read claw-code-agent/src/agent_tools.py and summarize each tool." \
|
||||
--cwd ..
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Claw Code Agent local benchmark suite."""
|
||||
@@ -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()
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
+571
-25
@@ -1,25 +1,571 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeModeReport:
|
||||
mode: str
|
||||
connected: bool
|
||||
detail: str
|
||||
|
||||
def as_text(self) -> str:
|
||||
return f'mode={self.mode}\nconnected={self.connected}\ndetail={self.detail}'
|
||||
|
||||
|
||||
def run_remote_mode(target: str) -> RuntimeModeReport:
|
||||
return RuntimeModeReport('remote', True, f'Remote control placeholder prepared for {target}')
|
||||
|
||||
|
||||
def run_ssh_mode(target: str) -> RuntimeModeReport:
|
||||
return RuntimeModeReport('ssh', True, f'SSH proxy placeholder prepared for {target}')
|
||||
|
||||
|
||||
def run_teleport_mode(target: str) -> RuntimeModeReport:
|
||||
return RuntimeModeReport('teleport', True, f'Teleport resume/create placeholder prepared for {target}')
|
||||
from __future__ import annotations
|
||||
|
||||
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)
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
@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_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_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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
@@ -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, '.')
|
||||
|
||||
@@ -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', ''))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user