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 manifest-backed MCP resource discovery
|
||||||
- [x] Local MCP resource listing and reading
|
- [x] Local MCP resource listing and reading
|
||||||
- [x] MCP-backed runtime tools for local resource access
|
- [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 persistent task runtime discovery
|
||||||
- [x] Local task create/get/list/update runtime flows
|
- [x] Local task create/get/list/update runtime flows
|
||||||
- [x] Local todo-list replacement runtime flow
|
- [x] Local todo-list replacement runtime flow
|
||||||
- [x] Local persistent plan runtime discovery
|
- [x] Local persistent plan runtime discovery
|
||||||
- [x] Local plan get/update/clear runtime flows
|
- [x] Local plan get/update/clear runtime flows
|
||||||
- [x] Local plan-to-task sync flow
|
- [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 compacted message ids
|
||||||
- [x] Compaction metadata with preserved-tail ids and compaction depth
|
- [x] Compaction metadata with preserved-tail ids and compaction depth
|
||||||
- [x] Compaction metadata with compacted/preserved lineage ids and revision summaries
|
- [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 session logs (`agent-logs`)
|
||||||
- [x] Local background attach snapshot (`agent-attach`)
|
- [x] Local background attach snapshot (`agent-attach`)
|
||||||
- [x] Local background kill flow (`agent-kill`)
|
- [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`
|
- [x] Inventory/helper commands such as `summary`, `manifest`, `commands`, and `tools`
|
||||||
|
|
||||||
Missing:
|
Missing:
|
||||||
|
|
||||||
- [ ] Daemon worker mode
|
- [ ] Full daemon supervisor parity beyond the current local daemon wrapper and worker flow
|
||||||
- [ ] Remote-control / bridge runtime mode
|
- [ ] Remote-control / bridge runtime mode
|
||||||
- [ ] Browser/native-host runtime mode
|
- [ ] Browser/native-host runtime mode
|
||||||
- [ ] Computer-use MCP mode
|
- [ ] Computer-use MCP mode
|
||||||
@@ -152,6 +169,10 @@ Done:
|
|||||||
- [x] Custom system prompt override and append support
|
- [x] Custom system prompt override and append support
|
||||||
- [x] Local hook/policy guidance section in the Python system prompt
|
- [x] Local hook/policy guidance section in the Python system prompt
|
||||||
- [x] Local MCP 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 planning guidance section in the Python system prompt
|
||||||
- [x] Local task 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] `CLAUDE.md` discovery
|
||||||
- [x] Extra directory injection through `--add-dir`
|
- [x] Extra directory injection through `--add-dir`
|
||||||
- [x] Session context usage report
|
- [x] Session context usage report
|
||||||
|
- [x] Tokenizer-aware context accounting with cached model-specific backends and heuristic fallback
|
||||||
- [x] Raw context inspection command
|
- [x] Raw context inspection command
|
||||||
- [x] Plugin cache snapshot injection
|
- [x] Plugin cache snapshot injection
|
||||||
- [x] Manifest-based plugin runtime summary injection
|
- [x] Manifest-based plugin runtime summary injection
|
||||||
- [x] Manifest-based hook/policy summary injection
|
- [x] Manifest-based hook/policy summary injection
|
||||||
- [x] Trust-mode, managed-settings, and safe-env context injection
|
- [x] Trust-mode, managed-settings, and safe-env context injection
|
||||||
- [x] Manifest-based MCP runtime summary 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 plan runtime summary injection
|
||||||
- [x] Manifest-based task runtime summary injection
|
- [x] Manifest-based task runtime summary injection
|
||||||
|
|
||||||
Missing:
|
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`
|
- [ ] Full parity with `utils/queryContext.ts`
|
||||||
- [ ] Rich memory prompt loading
|
- [ ] Rich memory prompt loading
|
||||||
- [ ] Internal permission-aware memory handling
|
- [ ] Internal permission-aware memory handling
|
||||||
@@ -209,6 +235,19 @@ Done:
|
|||||||
- [x] `/context-raw`
|
- [x] `/context-raw`
|
||||||
- [x] `/env`
|
- [x] `/env`
|
||||||
- [x] `/mcp`
|
- [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] `/resources`
|
||||||
- [x] `/resource`
|
- [x] `/resource`
|
||||||
- [x] `/plan`
|
- [x] `/plan`
|
||||||
@@ -216,6 +255,7 @@ Done:
|
|||||||
- [x] `/tasks`
|
- [x] `/tasks`
|
||||||
- [x] `/todo`
|
- [x] `/todo`
|
||||||
- [x] `/task`
|
- [x] `/task`
|
||||||
|
- [x] `/task-next`
|
||||||
- [x] `/prompt`
|
- [x] `/prompt`
|
||||||
- [x] `/system-prompt`
|
- [x] `/system-prompt`
|
||||||
- [x] `/permissions`
|
- [x] `/permissions`
|
||||||
@@ -228,16 +268,18 @@ Done:
|
|||||||
- [x] `/status`
|
- [x] `/status`
|
||||||
- [x] `/session`
|
- [x] `/session`
|
||||||
- [x] `/clear`
|
- [x] `/clear`
|
||||||
|
- [x] `/config`
|
||||||
|
- [x] `/settings`
|
||||||
|
|
||||||
Missing:
|
Missing:
|
||||||
|
|
||||||
- [ ] Full npm slash-command surface
|
- [ ] 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 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 with richer interactive behavior
|
||||||
- [ ] Slash commands tied to plugins and bundled skills
|
- [ ] 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
|
## 6. Built-in Tools
|
||||||
|
|
||||||
@@ -250,16 +292,41 @@ Done:
|
|||||||
- [x] `glob_search`
|
- [x] `glob_search`
|
||||||
- [x] `grep_search`
|
- [x] `grep_search`
|
||||||
- [x] `bash`
|
- [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_list_resources`
|
||||||
- [x] `mcp_read_resource`
|
- [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] `plan_get`
|
||||||
- [x] `update_plan`
|
- [x] `update_plan`
|
||||||
- [x] `plan_clear`
|
- [x] `plan_clear`
|
||||||
- [x] `delegate_agent`
|
- [x] `delegate_agent`
|
||||||
|
- [x] `task_next`
|
||||||
- [x] `task_list`
|
- [x] `task_list`
|
||||||
- [x] `task_get`
|
- [x] `task_get`
|
||||||
- [x] `task_create`
|
- [x] `task_create`
|
||||||
- [x] `task_update`
|
- [x] `task_update`
|
||||||
|
- [x] `task_start`
|
||||||
|
- [x] `task_complete`
|
||||||
|
- [x] `task_block`
|
||||||
|
- [x] `task_cancel`
|
||||||
- [x] `todo_write`
|
- [x] `todo_write`
|
||||||
|
|
||||||
Missing:
|
Missing:
|
||||||
@@ -267,11 +334,11 @@ Missing:
|
|||||||
- [ ] Agent spawning tool parity beyond the current `delegate_agent` runtime tool
|
- [ ] Agent spawning tool parity beyond the current `delegate_agent` runtime tool
|
||||||
- [ ] Skill tool
|
- [ ] Skill tool
|
||||||
- [ ] Notebook edit tool
|
- [ ] Notebook edit tool
|
||||||
- [ ] Web fetch tool
|
- [ ] Web fetch parity beyond the current local text-fetch implementation
|
||||||
- [ ] Web search tool
|
- [ ] Web search parity beyond the current provider-backed implementation
|
||||||
- [ ] Ask-user-question tool
|
- [ ] Ask-user-question tool
|
||||||
- [ ] LSP tool
|
- [ ] LSP tool
|
||||||
- [ ] Tool search tool
|
- [ ] Tool search parity beyond the current local registry search
|
||||||
- [ ] Config tool
|
- [ ] Config tool
|
||||||
- [ ] Team create/delete tools
|
- [ ] Team create/delete tools
|
||||||
- [ ] Send-message tool
|
- [ ] Send-message tool
|
||||||
@@ -279,7 +346,7 @@ Missing:
|
|||||||
- [ ] Browser tool
|
- [ ] Browser tool
|
||||||
- [ ] Workflow tool
|
- [ ] Workflow tool
|
||||||
- [ ] Remote trigger tool
|
- [ ] Remote trigger tool
|
||||||
- [ ] Sleep / cron tools
|
- [ ] Sleep / cron tools beyond the current local `sleep` tool
|
||||||
- [ ] PowerShell tool parity
|
- [ ] PowerShell tool parity
|
||||||
- [ ] Worktree enter/exit tools
|
- [ ] Worktree enter/exit tools
|
||||||
- [ ] Full `tools.ts` parity
|
- [ ] Full `tools.ts` parity
|
||||||
@@ -294,13 +361,15 @@ Done:
|
|||||||
- [x] Local todo-list replacement flow
|
- [x] Local todo-list replacement flow
|
||||||
- [x] Local persistent plan runtime with get/update/clear flows
|
- [x] Local persistent plan runtime with get/update/clear flows
|
||||||
- [x] Local plan-to-task sync flow
|
- [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
|
- [x] Local background task management for agent worker sessions
|
||||||
|
|
||||||
Missing:
|
Missing:
|
||||||
|
|
||||||
- [ ] Real implementation of the larger upstream command tree
|
- [ ] Real implementation of the larger upstream command tree
|
||||||
- [ ] Task orchestration system beyond the current local plan/task sync runtime
|
- [ ] Task orchestration system beyond the current local dependency-aware task runtime
|
||||||
- [ ] Planner / task execution parity beyond the current local plan persistence and sync flow
|
- [ ] Planner / task execution parity beyond the current local plan persistence, sync, and next-task flow
|
||||||
- [ ] Team / collaboration command flows
|
- [ ] Team / collaboration command flows
|
||||||
- [ ] Command-specific session behaviors
|
- [ ] Command-specific session behaviors
|
||||||
- [ ] Full `src/commands/*` parity
|
- [ ] Full `src/commands/*` parity
|
||||||
@@ -338,12 +407,13 @@ Done:
|
|||||||
- [x] Local manifest-backed MCP discovery
|
- [x] Local manifest-backed MCP discovery
|
||||||
- [x] Local MCP resource listing and reading
|
- [x] Local MCP resource listing and reading
|
||||||
- [x] MCP-backed runtime tools for local resource access
|
- [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:
|
Missing:
|
||||||
|
|
||||||
- [ ] Real MCP client support
|
- [ ] Full MCP-backed tool parity beyond the current stdio resource/tool list/read/call support
|
||||||
- [ ] MCP server integration
|
|
||||||
- [ ] Full MCP-backed tool parity beyond the current local resource list/read tools
|
|
||||||
- [ ] Plugin discovery and loading
|
- [ ] Plugin discovery and loading
|
||||||
- [ ] Bundled plugin support
|
- [ ] Bundled plugin support
|
||||||
- [ ] Plugin lifecycle management
|
- [ ] Plugin lifecycle management
|
||||||
@@ -376,12 +446,15 @@ Missing:
|
|||||||
Done:
|
Done:
|
||||||
|
|
||||||
- [x] Session save/resume on local disk
|
- [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 agent processes
|
||||||
- [x] Local background attach/log/kill workflows
|
- [x] Local background attach/log/kill workflows
|
||||||
|
- [x] Local daemon-style wrapper over background agent sessions
|
||||||
|
|
||||||
Missing:
|
Missing:
|
||||||
|
|
||||||
- [ ] Remote execution modes
|
- [ ] Real remote execution modes beyond the current local manifest-backed remote runtime and CLI/profile flows
|
||||||
- [ ] Team runtime features
|
- [ ] Team runtime features
|
||||||
- [ ] Team messaging features
|
- [ ] Team messaging features
|
||||||
- [ ] Shared remote state
|
- [ ] Shared remote state
|
||||||
@@ -410,6 +483,7 @@ Missing:
|
|||||||
Done:
|
Done:
|
||||||
|
|
||||||
- [x] Minimal internal service layer required by the current Python runtime
|
- [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:
|
Missing:
|
||||||
|
|
||||||
@@ -435,14 +509,19 @@ Working Python runtime today:
|
|||||||
- [x] `src/agent_context_usage.py`
|
- [x] `src/agent_context_usage.py`
|
||||||
- [x] `src/agent_session.py`
|
- [x] `src/agent_session.py`
|
||||||
- [x] `src/agent_slash_commands.py`
|
- [x] `src/agent_slash_commands.py`
|
||||||
|
- [x] `src/account_runtime.py`
|
||||||
|
- [x] `src/config_runtime.py`
|
||||||
- [x] `src/agent_types.py`
|
- [x] `src/agent_types.py`
|
||||||
- [x] `src/mcp_runtime.py`
|
- [x] `src/mcp_runtime.py`
|
||||||
- [x] `src/plan_runtime.py`
|
- [x] `src/plan_runtime.py`
|
||||||
- [x] `src/plugin_runtime.py`
|
- [x] `src/plugin_runtime.py`
|
||||||
|
- [x] `src/remote_runtime.py`
|
||||||
|
- [x] `src/search_runtime.py`
|
||||||
- [x] `src/hook_policy.py`
|
- [x] `src/hook_policy.py`
|
||||||
- [x] `src/background_runtime.py`
|
- [x] `src/background_runtime.py`
|
||||||
- [x] `src/task.py`
|
- [x] `src/task.py`
|
||||||
- [x] `src/task_runtime.py`
|
- [x] `src/task_runtime.py`
|
||||||
|
- [x] `src/tokenizer_runtime.py`
|
||||||
- [x] `src/openai_compat.py`
|
- [x] `src/openai_compat.py`
|
||||||
- [x] `src/session_store.py`
|
- [x] `src/session_store.py`
|
||||||
- [x] `src/permissions.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`
|
- [ ] Expand the real Python tool registry toward upstream `tools.ts`
|
||||||
- [ ] Replace more snapshot-backed mirrored modules with working runtime code
|
- [ ] 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
|
- [ ] Expand hooks and policy parity beyond the current manifest/runtime implementation
|
||||||
- [ ] Build a real interactive REPL / TUI
|
- [ ] Build a real interactive REPL / TUI
|
||||||
- [ ] Add tokenizer-accurate context accounting
|
- [ ] Add tokenizer-accurate context accounting
|
||||||
- [ ] Expand background session parity beyond the current local worker/log/attach model
|
- [ ] Expand background session parity beyond the current local worker/log/attach model
|
||||||
- [ ] Add real remote session modes
|
- [ ] Add real remote session transport and shared remote state beyond the current local remote-profile runtime
|
||||||
- [ ] Port more of the command/task system
|
- [ ] Port more of the command/task system
|
||||||
- [ ] Close the gap between the mirrored workspace and the working runtime
|
- [ ] 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 |
|
| 🆕 | **LiteLLM Proxy Support** | Route through LiteLLM Proxy to any provider |
|
||||||
| 🆕 | **OpenRouter Support** | Cloud API gateway — access OpenAI, Anthropic, Google models via one endpoint |
|
| 🆕 | **OpenRouter Support** | Cloud API gateway — access OpenAI, Anthropic, Google models via one endpoint |
|
||||||
| 🆕 | **Query Engine** | Runtime event counters, transcript summaries, orchestration reports |
|
| 🆕 | **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 |
|
| 🆕 | **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 |
|
| 🆕 | **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 |
|
| 🪆 **Nested Delegation** | Delegate subtasks to child agents with dependency-aware topological batching |
|
||||||
| 📡 **Streaming** | Token-by-token streaming output with `--stream` |
|
| 📡 **Streaming** | Token-by-token streaming output with `--stream` |
|
||||||
| 💬 **Slash Commands** | Local commands: `/help`, `/context`, `/tools`, `/memory`, `/status`, `/model`, and more |
|
| 💬 **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 |
|
| 🧠 **Context Engine** | Automatic context building with CLAUDE.md discovery, compaction, and snipping |
|
||||||
| 🔄 **Session Persistence** | Save and resume agent sessions with file-history replay |
|
| 🔄 **Session Persistence** | Save and resume agent sessions with file-history replay |
|
||||||
| 💰 **Cost & Budget Control** | Token budgets, cost limits, tool-call caps, model-call caps |
|
| 💰 **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] File history journaling with snapshot IDs and replay summaries
|
||||||
- [x] Nested agent delegation with dependency-aware topological batching
|
- [x] Nested agent delegation with dependency-aware topological batching
|
||||||
- [x] Agent manager with lineage tracking and group membership
|
- [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 runtime: manifest discovery, hooks, aliases, virtual tools, tool blocking
|
||||||
- [x] Plugin lifecycle hooks: resume, persist, delegate phases
|
- [x] Plugin lifecycle hooks: resume, persist, delegate phases
|
||||||
- [x] Plugin session-state persistence and resume restoration
|
- [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
|
- [ ] Full interactive REPL / TUI behavior
|
||||||
- [ ] Exact tokenizer-accurate context accounting
|
- [ ] Exact tokenizer-accurate context accounting
|
||||||
- [ ] Hooks system parity
|
- [ ] 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
|
- [ ] Voice and VIM modes
|
||||||
- [ ] Editor and platform integrations
|
- [ ] Editor and platform integrations
|
||||||
- [ ] Background and team features
|
- [ ] Background and team features
|
||||||
@@ -164,6 +169,9 @@ claw-code/
|
|||||||
│ ├── session_store.py # Session serialization & persistence
|
│ ├── session_store.py # Session serialization & persistence
|
||||||
│ ├── transcript.py # Transcript block export & mutation tracking
|
│ ├── transcript.py # Transcript block export & mutation tracking
|
||||||
│ ├── query_engine.py # Query engine facade & runtime orchestration
|
│ ├── 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
|
│ ├── permissions.py # Tool permission filtering
|
||||||
│ ├── cost_tracker.py # Cost & budget enforcement
|
│ ├── cost_tracker.py # Cost & budget enforcement
|
||||||
│ ├── tools.py # Mirrored tool inventory
|
│ ├── 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 "/commands"
|
||||||
python3 -m src.main agent "/context" --cwd ..
|
python3 -m src.main agent "/context" --cwd ..
|
||||||
python3 -m src.main agent "/context-raw" --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 "/plan" --cwd ..
|
||||||
python3 -m src.main agent "/prompt" --cwd ..
|
python3 -m src.main agent "/prompt" --cwd ..
|
||||||
python3 -m src.main agent "/permissions" --cwd ..
|
python3 -m src.main agent "/permissions" --cwd ..
|
||||||
python3 -m src.main agent "/hooks" --cwd ..
|
python3 -m src.main agent "/hooks" --cwd ..
|
||||||
python3 -m src.main agent "/policy" --cwd ..
|
python3 -m src.main agent "/policy" --cwd ..
|
||||||
python3 -m src.main agent "/trust" --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 "/tools" --cwd ..
|
||||||
python3 -m src.main agent "/memory" --cwd ..
|
python3 -m src.main agent "/memory" --cwd ..
|
||||||
python3 -m src.main agent "/status" --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
|
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. Core Agent Loop
|
||||||
|
|
||||||
### 5.1 Read-only run
|
### 5.1 Read-only run
|
||||||
@@ -223,6 +310,87 @@ python3 -m src.main agent-chat \
|
|||||||
--cwd ..
|
--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:
|
Inside chat mode:
|
||||||
|
|
||||||
- type normal prompts to continue the same session
|
- type normal prompts to continue the same session
|
||||||
@@ -241,7 +409,7 @@ python3 -m src.main agent-chat \
|
|||||||
### 6.1 Read files
|
### 6.1 Read files
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
"Read claw-code-agent/src/agent_tools.py and summarize each tool." \
|
"Read claw-code-agent/src/agent_tools.py and summarize each tool." \
|
||||||
--cwd ..
|
--cwd ..
|
||||||
```
|
```
|
||||||
@@ -340,6 +508,15 @@ python3 -m src.main agent "/plan" --cwd ./test_cases
|
|||||||
python3 -m src.main agent "/tasks" --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. Session Persistence And Resume
|
||||||
|
|
||||||
### 7.1 Create a saved session
|
### 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>
|
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:
|
Create a schema file:
|
||||||
|
|
||||||
@@ -446,9 +654,9 @@ python3 -m src.main agent \
|
|||||||
--response-schema-strict
|
--response-schema-strict
|
||||||
```
|
```
|
||||||
|
|
||||||
## 10. Budgets And Limits
|
## 11. Budgets And Limits
|
||||||
|
|
||||||
### 10.1 Total token budget
|
### 11.1 Total token budget
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -457,7 +665,7 @@ python3 -m src.main agent \
|
|||||||
--max-total-tokens 50
|
--max-total-tokens 50
|
||||||
```
|
```
|
||||||
|
|
||||||
### 10.2 Input / output token budgets
|
### 11.2 Input / output token budgets
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -467,7 +675,7 @@ python3 -m src.main agent \
|
|||||||
--max-output-tokens 80
|
--max-output-tokens 80
|
||||||
```
|
```
|
||||||
|
|
||||||
### 10.3 Reasoning-token budget
|
### 11.3 Reasoning-token budget
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -476,7 +684,7 @@ python3 -m src.main agent \
|
|||||||
--max-reasoning-tokens 10
|
--max-reasoning-tokens 10
|
||||||
```
|
```
|
||||||
|
|
||||||
### 10.4 Tool-call budget
|
### 11.4 Tool-call budget
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -485,7 +693,7 @@ python3 -m src.main agent \
|
|||||||
--max-tool-calls 1
|
--max-tool-calls 1
|
||||||
```
|
```
|
||||||
|
|
||||||
### 10.5 Delegated-task budget
|
### 11.5 Delegated-task budget
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -494,7 +702,7 @@ python3 -m src.main agent \
|
|||||||
--max-delegated-tasks 1
|
--max-delegated-tasks 1
|
||||||
```
|
```
|
||||||
|
|
||||||
### 10.6 Cost budget
|
### 11.6 Cost budget
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -505,7 +713,7 @@ python3 -m src.main agent \
|
|||||||
--max-budget-usd 0.000001
|
--max-budget-usd 0.000001
|
||||||
```
|
```
|
||||||
|
|
||||||
### 10.7 Model-call budget
|
### 11.7 Model-call budget
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -514,7 +722,7 @@ python3 -m src.main agent \
|
|||||||
--max-model-calls 1
|
--max-model-calls 1
|
||||||
```
|
```
|
||||||
|
|
||||||
### 10.8 Session-turn budget
|
### 11.8 Session-turn budget
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -523,7 +731,7 @@ python3 -m src.main agent \
|
|||||||
--max-session-turns 1
|
--max-session-turns 1
|
||||||
```
|
```
|
||||||
|
|
||||||
### 10.9 Budget overrides from local policy
|
### 11.9 Budget overrides from local policy
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat > ./test_cases/.claw-policy.json <<'EOF'
|
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.
|
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
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -553,7 +761,7 @@ python3 -m src.main agent \
|
|||||||
--show-transcript
|
--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:
|
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
|
--show-transcript
|
||||||
```
|
```
|
||||||
|
|
||||||
### 11.3 Snipping older context
|
### 12.3 Snipping older context
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -576,7 +784,7 @@ python3 -m src.main agent \
|
|||||||
--show-transcript
|
--show-transcript
|
||||||
```
|
```
|
||||||
|
|
||||||
### 11.4 Compaction boundaries
|
### 12.4 Compaction boundaries
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -587,9 +795,9 @@ python3 -m src.main agent \
|
|||||||
--show-transcript
|
--show-transcript
|
||||||
```
|
```
|
||||||
|
|
||||||
## 12. File History Replay
|
## 13. File History Replay
|
||||||
|
|
||||||
### 12.1 Create file history
|
### 13.1 Create file history
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -598,7 +806,7 @@ python3 -m src.main agent \
|
|||||||
--allow-write
|
--allow-write
|
||||||
```
|
```
|
||||||
|
|
||||||
### 12.2 Resume and inspect replay
|
### 13.2 Resume and inspect replay
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent-resume \
|
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.
|
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
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -621,7 +829,7 @@ python3 -m src.main agent \
|
|||||||
--show-transcript
|
--show-transcript
|
||||||
```
|
```
|
||||||
|
|
||||||
### 13.2 Multiple delegated subtasks
|
### 14.2 Multiple delegated subtasks
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -630,7 +838,7 @@ python3 -m src.main agent \
|
|||||||
--show-transcript
|
--show-transcript
|
||||||
```
|
```
|
||||||
|
|
||||||
### 13.3 Resume a delegated child session
|
### 14.3 Resume a delegated child session
|
||||||
|
|
||||||
1. Seed a normal saved session:
|
1. Seed a normal saved session:
|
||||||
|
|
||||||
@@ -649,7 +857,7 @@ python3 -m src.main agent \
|
|||||||
--show-transcript
|
--show-transcript
|
||||||
```
|
```
|
||||||
|
|
||||||
### 13.4 Topological dependency batches
|
### 14.4 Topological dependency batches
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -664,7 +872,7 @@ Look for:
|
|||||||
- `delegate_group_result`
|
- `delegate_group_result`
|
||||||
- `batch_index=...`
|
- `batch_index=...`
|
||||||
|
|
||||||
## 14. Plugin Runtime
|
## 15. Plugin Runtime
|
||||||
|
|
||||||
Create a local plugin manifest:
|
Create a local plugin manifest:
|
||||||
|
|
||||||
@@ -705,14 +913,14 @@ cat > ./test_cases/plugins/demo/plugin.json <<'EOF'
|
|||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
### 14.1 Plugin prompt/context discovery
|
### 15.1 Plugin prompt/context discovery
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent-prompt --cwd ./test_cases
|
python3 -m src.main agent-prompt --cwd ./test_cases
|
||||||
python3 -m src.main agent-context-raw --cwd ./test_cases
|
python3 -m src.main agent-context-raw --cwd ./test_cases
|
||||||
```
|
```
|
||||||
|
|
||||||
### 14.2 Plugin alias tool
|
### 15.2 Plugin alias tool
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
echo "hello plugin" > ./test_cases/hello.txt
|
echo "hello plugin" > ./test_cases/hello.txt
|
||||||
@@ -722,7 +930,7 @@ python3 -m src.main agent \
|
|||||||
--show-transcript
|
--show-transcript
|
||||||
```
|
```
|
||||||
|
|
||||||
### 14.3 Plugin virtual tool
|
### 15.3 Plugin virtual tool
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -731,7 +939,7 @@ python3 -m src.main agent \
|
|||||||
--show-transcript
|
--show-transcript
|
||||||
```
|
```
|
||||||
|
|
||||||
### 14.4 Plugin before/after tool guidance
|
### 15.4 Plugin before/after tool guidance
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -740,7 +948,7 @@ python3 -m src.main agent \
|
|||||||
--show-transcript
|
--show-transcript
|
||||||
```
|
```
|
||||||
|
|
||||||
### 14.5 Plugin lifecycle with resume/persist
|
### 15.5 Plugin lifecycle with resume/persist
|
||||||
|
|
||||||
1. Start a session:
|
1. Start a session:
|
||||||
|
|
||||||
@@ -765,7 +973,7 @@ Look for:
|
|||||||
- `Plugin resume hooks:`
|
- `Plugin resume hooks:`
|
||||||
- `Plugin runtime state:`
|
- `Plugin runtime state:`
|
||||||
|
|
||||||
## 15. MCP Runtime
|
## 16. MCP Runtime
|
||||||
|
|
||||||
Create a local MCP manifest:
|
Create a local MCP manifest:
|
||||||
|
|
||||||
@@ -796,7 +1004,7 @@ cat > ./test_cases_mcp/.claw-mcp.json <<'EOF'
|
|||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
### 15.1 MCP context and slash commands
|
### 16.1 MCP context and slash commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent "/mcp" --cwd ./test_cases_mcp
|
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
|
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
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -816,7 +1024,7 @@ python3 -m src.main agent \
|
|||||||
--show-transcript
|
--show-transcript
|
||||||
```
|
```
|
||||||
|
|
||||||
### 15.3 Read inline MCP resources
|
### 16.3 Read inline MCP resources
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -825,7 +1033,161 @@ python3 -m src.main agent \
|
|||||||
--show-transcript
|
--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:
|
Create a clean task workspace:
|
||||||
|
|
||||||
@@ -834,7 +1196,7 @@ mkdir -p ./test_cases_tasks
|
|||||||
rm -rf ./test_cases_tasks/.port_sessions
|
rm -rf ./test_cases_tasks/.port_sessions
|
||||||
```
|
```
|
||||||
|
|
||||||
### 16.1 Task slash commands
|
### 17.1 Task slash commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent "/tasks" --cwd ./test_cases_tasks
|
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
|
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
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -860,7 +1222,7 @@ Then inspect the stored task file:
|
|||||||
cat ./test_cases_tasks/.port_sessions/task_runtime.json
|
cat ./test_cases_tasks/.port_sessions/task_runtime.json
|
||||||
```
|
```
|
||||||
|
|
||||||
### 16.3 Replace the todo list
|
### 17.3 Replace the todo list
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -870,17 +1232,18 @@ python3 -m src.main agent \
|
|||||||
--show-transcript
|
--show-transcript
|
||||||
```
|
```
|
||||||
|
|
||||||
### 16.4 Read back task state
|
### 17.4 Read back task state
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent "/tasks" --cwd ./test_cases_tasks
|
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 \
|
python3 -m src.main agent \
|
||||||
"List the current tasks and show me the id of each one." \
|
"List the current tasks and show me the id of each one." \
|
||||||
--cwd ./test_cases_tasks \
|
--cwd ./test_cases_tasks \
|
||||||
--show-transcript
|
--show-transcript
|
||||||
```
|
```
|
||||||
|
|
||||||
### 16.5 Plan runtime and task sync
|
### 17.5 Plan runtime and task sync
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
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
|
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
|
```bash
|
||||||
python3 -m src.main summary
|
python3 -m src.main summary
|
||||||
@@ -906,7 +1309,7 @@ python3 -m src.main commands --limit 20
|
|||||||
python3 -m src.main tools --limit 20
|
python3 -m src.main tools --limit 20
|
||||||
```
|
```
|
||||||
|
|
||||||
### 17.2 Query routing and bootstrap reports
|
### 18.2 Query routing and bootstrap reports
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main route "inspect the runtime and tools" --limit 10
|
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
|
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
|
```bash
|
||||||
python3 -m src.main flush-transcript "store a temporary transcript"
|
python3 -m src.main flush-transcript "store a temporary transcript"
|
||||||
python3 -m src.main load-session <session-id>
|
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
|
## 19. Parity Tracking Workflow
|
||||||
|
|
||||||
Use this every time a new feature lands:
|
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 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`
|
- 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."""
|
"""Python porting workspace for the Claude Code rewrite effort."""
|
||||||
|
|
||||||
|
from .account_runtime import AccountRuntime, AccountProfile, AccountSessionState, AccountStatusReport
|
||||||
from .agent_context import (
|
from .agent_context import (
|
||||||
AgentContextSnapshot,
|
AgentContextSnapshot,
|
||||||
build_context_snapshot,
|
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 .agent_types import AgentPermissions, AgentRunResult, AgentRuntimeConfig, ModelConfig
|
||||||
from .background_runtime import BackgroundSessionRuntime
|
from .background_runtime import BackgroundSessionRuntime
|
||||||
from .commands import PORTED_COMMANDS, build_command_backlog
|
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 .parity_audit import ParityAuditResult, run_parity_audit
|
||||||
from .plan_runtime import PlanRuntime, PlanStep
|
from .plan_runtime import PlanRuntime, PlanStep
|
||||||
from .plugin_runtime import PluginRuntime
|
from .plugin_runtime import PluginRuntime
|
||||||
from .port_manifest import PortManifest, build_port_manifest
|
from .port_manifest import PortManifest, build_port_manifest
|
||||||
from .query_engine import QueryEnginePort, TurnResult
|
from .query_engine import QueryEnginePort, TurnResult
|
||||||
from .runtime import PortRuntime, RuntimeSession
|
from .runtime import PortRuntime, RuntimeSession
|
||||||
|
from .search_runtime import SearchProviderProfile, SearchResult, SearchRuntime, SearchStatusReport
|
||||||
from .session_store import StoredSession, load_session, save_session
|
from .session_store import StoredSession, load_session, save_session
|
||||||
from .system_init import build_system_init_message
|
from .system_init import build_system_init_message
|
||||||
from .task import PortingTask
|
from .task import PortingTask
|
||||||
from .task_runtime import TaskRuntime
|
from .task_runtime import TaskRuntime
|
||||||
|
from .tokenizer_runtime import TokenCounterInfo, clear_token_counter_cache, count_tokens, describe_token_counter
|
||||||
from .tools import PORTED_TOOLS, build_tool_backlog
|
from .tools import PORTED_TOOLS, build_tool_backlog
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -34,11 +38,20 @@ __all__ = [
|
|||||||
'AgentPermissions',
|
'AgentPermissions',
|
||||||
'AgentRunResult',
|
'AgentRunResult',
|
||||||
'AgentRuntimeConfig',
|
'AgentRuntimeConfig',
|
||||||
|
'AccountProfile',
|
||||||
|
'AccountRuntime',
|
||||||
|
'AccountSessionState',
|
||||||
|
'AccountStatusReport',
|
||||||
'AgentMessage',
|
'AgentMessage',
|
||||||
'AgentSessionState',
|
'AgentSessionState',
|
||||||
'BackgroundSessionRuntime',
|
'BackgroundSessionRuntime',
|
||||||
|
'ConfigMutation',
|
||||||
|
'ConfigRuntime',
|
||||||
'LocalCodingAgent',
|
'LocalCodingAgent',
|
||||||
|
'MCPResource',
|
||||||
'MCPRuntime',
|
'MCPRuntime',
|
||||||
|
'MCPServerProfile',
|
||||||
|
'MCPTool',
|
||||||
'ModelConfig',
|
'ModelConfig',
|
||||||
'ParityAuditResult',
|
'ParityAuditResult',
|
||||||
'PlanRuntime',
|
'PlanRuntime',
|
||||||
@@ -49,8 +62,13 @@ __all__ = [
|
|||||||
'PortingTask',
|
'PortingTask',
|
||||||
'QueryEnginePort',
|
'QueryEnginePort',
|
||||||
'RuntimeSession',
|
'RuntimeSession',
|
||||||
|
'SearchProviderProfile',
|
||||||
|
'SearchResult',
|
||||||
|
'SearchRuntime',
|
||||||
|
'SearchStatusReport',
|
||||||
'StoredSession',
|
'StoredSession',
|
||||||
'TaskRuntime',
|
'TaskRuntime',
|
||||||
|
'TokenCounterInfo',
|
||||||
'TurnResult',
|
'TurnResult',
|
||||||
'PORTED_COMMANDS',
|
'PORTED_COMMANDS',
|
||||||
'PORTED_TOOLS',
|
'PORTED_TOOLS',
|
||||||
@@ -61,7 +79,10 @@ __all__ = [
|
|||||||
'build_tool_backlog',
|
'build_tool_backlog',
|
||||||
'build_tool_context',
|
'build_tool_context',
|
||||||
'clear_context_caches',
|
'clear_context_caches',
|
||||||
|
'clear_token_counter_cache',
|
||||||
|
'count_tokens',
|
||||||
'default_tool_registry',
|
'default_tool_registry',
|
||||||
|
'describe_token_counter',
|
||||||
'execute_tool',
|
'execute_tool',
|
||||||
'get_system_context',
|
'get_system_context',
|
||||||
'get_user_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 pathlib import Path
|
||||||
|
|
||||||
from .agent_plugin_cache import load_plugin_cache_summary
|
from .agent_plugin_cache import load_plugin_cache_summary
|
||||||
|
from .account_runtime import AccountRuntime
|
||||||
|
from .config_runtime import ConfigRuntime
|
||||||
from .hook_policy import HookPolicyRuntime
|
from .hook_policy import HookPolicyRuntime
|
||||||
from .mcp_runtime import MCPRuntime
|
from .mcp_runtime import MCPRuntime
|
||||||
from .plan_runtime import PlanRuntime
|
from .plan_runtime import PlanRuntime
|
||||||
from .plugin_runtime import PluginRuntime
|
from .plugin_runtime import PluginRuntime
|
||||||
|
from .remote_runtime import RemoteRuntime
|
||||||
|
from .search_runtime import SearchRuntime
|
||||||
from .task_runtime import TaskRuntime
|
from .task_runtime import TaskRuntime
|
||||||
from .agent_types import AgentRuntimeConfig
|
from .agent_types import AgentRuntimeConfig
|
||||||
|
|
||||||
@@ -224,6 +228,18 @@ def _get_user_context_cached(
|
|||||||
mcp_runtime = MCPRuntime.from_workspace(Path(cwd), additional_working_directories)
|
mcp_runtime = MCPRuntime.from_workspace(Path(cwd), additional_working_directories)
|
||||||
if mcp_runtime.resources:
|
if mcp_runtime.resources:
|
||||||
context['mcpRuntime'] = mcp_runtime.render_summary()
|
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))
|
plan_runtime = PlanRuntime.from_workspace(Path(cwd))
|
||||||
if plan_runtime.steps:
|
if plan_runtime.steps:
|
||||||
context['planRuntime'] = plan_runtime.render_summary()
|
context['planRuntime'] = plan_runtime.render_summary()
|
||||||
|
|||||||
+22
-15
@@ -1,12 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import math
|
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from .agent_prompting import SYSTEM_PROMPT_DYNAMIC_BOUNDARY
|
from .agent_prompting import SYSTEM_PROMPT_DYNAMIC_BOUNDARY
|
||||||
from .agent_session import AgentMessage, AgentSessionState
|
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)
|
_PATH_HEADER_RE = re.compile(r'^## ((?:/|[A-Za-z]:[\\/]).+)$', re.MULTILINE)
|
||||||
|
|
||||||
@@ -48,12 +48,13 @@ class ContextUsageReport:
|
|||||||
system_context_entries: tuple[UsageEntry, ...]
|
system_context_entries: tuple[UsageEntry, ...]
|
||||||
memory_files: tuple[UsageEntry, ...]
|
memory_files: tuple[UsageEntry, ...]
|
||||||
message_breakdown: MessageBreakdown
|
message_breakdown: MessageBreakdown
|
||||||
|
token_counter_backend: str
|
||||||
|
token_counter_source: str
|
||||||
|
token_counter_accurate: bool
|
||||||
|
|
||||||
|
|
||||||
def estimate_tokens(text: str) -> int:
|
def estimate_tokens(text: str, model: str | None = None) -> int:
|
||||||
if not text:
|
return count_tokens(text, model)
|
||||||
return 0
|
|
||||||
return max(1, math.ceil(len(text) / 4))
|
|
||||||
|
|
||||||
|
|
||||||
def infer_context_window(model: str) -> int:
|
def infer_context_window(model: str) -> int:
|
||||||
@@ -78,21 +79,23 @@ def collect_context_usage(
|
|||||||
strategy: str,
|
strategy: str,
|
||||||
) -> ContextUsageReport:
|
) -> ContextUsageReport:
|
||||||
raw_max_tokens = infer_context_window(model)
|
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(
|
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)
|
for idx, part in enumerate(session.system_prompt_parts, start=1)
|
||||||
)
|
)
|
||||||
system_context_entries = tuple(
|
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()
|
for key, value in session.system_context.items()
|
||||||
if value
|
if value
|
||||||
)
|
)
|
||||||
user_context_entries = tuple(
|
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()
|
for key, value in session.user_context.items()
|
||||||
if value
|
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)
|
user_context_tokens = sum(entry.tokens for entry in user_context_entries)
|
||||||
system_prompt_tokens = (
|
system_prompt_tokens = (
|
||||||
@@ -112,20 +115,20 @@ def collect_context_usage(
|
|||||||
if _is_user_context_message(session, index, message):
|
if _is_user_context_message(session, index, message):
|
||||||
continue
|
continue
|
||||||
if message.role == 'user':
|
if message.role == 'user':
|
||||||
conversation_user_tokens += estimate_tokens(message.content)
|
conversation_user_tokens += count(message.content)
|
||||||
continue
|
continue
|
||||||
if message.role == 'assistant':
|
if message.role == 'assistant':
|
||||||
assistant_tokens += estimate_tokens(message.content)
|
assistant_tokens += count(message.content)
|
||||||
for tool_call in message.tool_calls:
|
for tool_call in message.tool_calls:
|
||||||
serialized = json.dumps(tool_call, ensure_ascii=True)
|
serialized = json.dumps(tool_call, ensure_ascii=True)
|
||||||
tokens = estimate_tokens(serialized)
|
tokens = count(serialized)
|
||||||
tool_call_tokens += tokens
|
tool_call_tokens += tokens
|
||||||
tool_name = _extract_tool_call_name(tool_call)
|
tool_name = _extract_tool_call_name(tool_call)
|
||||||
call_totals = tool_usage.setdefault(tool_name, [0, 0])
|
call_totals = tool_usage.setdefault(tool_name, [0, 0])
|
||||||
call_totals[0] += tokens
|
call_totals[0] += tokens
|
||||||
continue
|
continue
|
||||||
if message.role == 'tool':
|
if message.role == 'tool':
|
||||||
tokens = estimate_tokens(message.content)
|
tokens = count(message.content)
|
||||||
tool_result_tokens += tokens
|
tool_result_tokens += tokens
|
||||||
result_totals = tool_usage.setdefault(message.name or 'tool', [0, 0])
|
result_totals = tool_usage.setdefault(message.name or 'tool', [0, 0])
|
||||||
result_totals[1] += tokens
|
result_totals[1] += tokens
|
||||||
@@ -176,6 +179,9 @@ def collect_context_usage(
|
|||||||
user_context_tokens=user_context_tokens,
|
user_context_tokens=user_context_tokens,
|
||||||
tool_calls_by_type=tool_calls_by_type,
|
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'**Model:** {report.model} ',
|
||||||
f'**Estimated tokens:** {_format_tokens(report.total_tokens)} / {_format_tokens(report.raw_max_tokens)} ({report.percentage:.1f}%) ',
|
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'**Context strategy:** {report.strategy} ',
|
||||||
f'**Messages in session:** {report.message_count}',
|
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:
|
if not claude_md:
|
||||||
return []
|
return []
|
||||||
matches = list(_PATH_HEADER_RE.finditer(claude_md))
|
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()
|
start = match.end()
|
||||||
end = matches[idx + 1].start() if idx + 1 < len(matches) else len(claude_md)
|
end = matches[idx + 1].start() if idx + 1 < len(matches) else len(claude_md)
|
||||||
content = claude_md[start:end].strip()
|
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
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+56
-2
@@ -92,6 +92,10 @@ def build_system_prompt_parts(
|
|||||||
get_using_your_tools_section(enabled_tool_names),
|
get_using_your_tools_section(enabled_tool_names),
|
||||||
get_plugin_guidance_section(prompt_context),
|
get_plugin_guidance_section(prompt_context),
|
||||||
get_mcp_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_plan_guidance_section(prompt_context),
|
||||||
get_task_guidance_section(prompt_context),
|
get_task_guidance_section(prompt_context),
|
||||||
get_hook_policy_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:
|
if not mcp_runtime:
|
||||||
return ''
|
return ''
|
||||||
items = [
|
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.',
|
'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)])
|
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:
|
def get_task_guidance_section(prompt_context: PromptContext) -> str:
|
||||||
task_runtime = prompt_context.user_context.get('taskRuntime')
|
task_runtime = prompt_context.user_context.get('taskRuntime')
|
||||||
if not task_runtime:
|
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.',
|
'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.',
|
'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.',
|
'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)])
|
return '\n'.join(['# Tasks', *prepend_bullets(items)])
|
||||||
|
|
||||||
|
|||||||
+299
-7
@@ -7,9 +7,12 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from .account_runtime import AccountRuntime
|
||||||
from .agent_manager import AgentManager
|
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 import render_context_report as render_agent_context_report
|
||||||
from .agent_context_usage import collect_context_usage, estimate_tokens, format_context_usage
|
from .agent_context_usage import collect_context_usage, estimate_tokens, format_context_usage
|
||||||
|
from .config_runtime import ConfigRuntime
|
||||||
from .hook_policy import HookPolicyRuntime
|
from .hook_policy import HookPolicyRuntime
|
||||||
from .mcp_runtime import MCPRuntime
|
from .mcp_runtime import MCPRuntime
|
||||||
from .agent_prompting import (
|
from .agent_prompting import (
|
||||||
@@ -42,7 +45,10 @@ from .agent_types import (
|
|||||||
from .openai_compat import OpenAICompatClient, OpenAICompatError
|
from .openai_compat import OpenAICompatClient, OpenAICompatError
|
||||||
from .plan_runtime import PlanRuntime
|
from .plan_runtime import PlanRuntime
|
||||||
from .plugin_runtime import PluginRuntime
|
from .plugin_runtime import PluginRuntime
|
||||||
|
from .remote_runtime import RemoteRuntime
|
||||||
|
from .search_runtime import SearchRuntime
|
||||||
from .task_runtime import TaskRuntime
|
from .task_runtime import TaskRuntime
|
||||||
|
from .tokenizer_runtime import describe_token_counter
|
||||||
from .session_store import (
|
from .session_store import (
|
||||||
StoredAgentSession,
|
StoredAgentSession,
|
||||||
load_agent_session,
|
load_agent_session,
|
||||||
@@ -75,6 +81,10 @@ class LocalCodingAgent:
|
|||||||
plugin_runtime: PluginRuntime | None = None
|
plugin_runtime: PluginRuntime | None = None
|
||||||
hook_policy_runtime: HookPolicyRuntime | None = None
|
hook_policy_runtime: HookPolicyRuntime | None = None
|
||||||
mcp_runtime: MCPRuntime | 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
|
plan_runtime: PlanRuntime | None = None
|
||||||
task_runtime: TaskRuntime | None = None
|
task_runtime: TaskRuntime | None = None
|
||||||
last_session: AgentSessionState | None = field(default=None, init=False, repr=False)
|
last_session: AgentSessionState | None = field(default=None, init=False, repr=False)
|
||||||
@@ -104,6 +114,23 @@ class LocalCodingAgent:
|
|||||||
self.runtime_config.cwd,
|
self.runtime_config.cwd,
|
||||||
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
||||||
)
|
)
|
||||||
|
if self.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:
|
if self.plan_runtime is None:
|
||||||
self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd)
|
self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd)
|
||||||
if self.task_runtime is None:
|
if self.task_runtime is None:
|
||||||
@@ -120,12 +147,17 @@ class LocalCodingAgent:
|
|||||||
self.client = OpenAICompatClient(self.model_config)
|
self.client = OpenAICompatClient(self.model_config)
|
||||||
self.tool_context = build_tool_context(
|
self.tool_context = build_tool_context(
|
||||||
self.runtime_config,
|
self.runtime_config,
|
||||||
|
tool_registry=self.tool_registry,
|
||||||
extra_env=(
|
extra_env=(
|
||||||
self.hook_policy_runtime.safe_env()
|
self.hook_policy_runtime.safe_env()
|
||||||
if self.hook_policy_runtime is not None
|
if self.hook_policy_runtime is not None
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
search_runtime=self.search_runtime,
|
||||||
|
account_runtime=self.account_runtime,
|
||||||
|
config_runtime=self.config_runtime,
|
||||||
mcp_runtime=self.mcp_runtime,
|
mcp_runtime=self.mcp_runtime,
|
||||||
|
remote_runtime=self.remote_runtime,
|
||||||
plan_runtime=self.plan_runtime,
|
plan_runtime=self.plan_runtime,
|
||||||
task_runtime=self.task_runtime,
|
task_runtime=self.task_runtime,
|
||||||
)
|
)
|
||||||
@@ -919,6 +951,7 @@ class LocalCodingAgent:
|
|||||||
'preflight_count': len(plugin_preflight_messages),
|
'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(
|
history_entry = self._build_file_history_entry(
|
||||||
tool_call=tool_call,
|
tool_call=tool_call,
|
||||||
tool_result=tool_result,
|
tool_result=tool_result,
|
||||||
@@ -1270,9 +1303,9 @@ class LocalCodingAgent:
|
|||||||
if current_total <= target_tokens and not reactive:
|
if current_total <= target_tokens and not reactive:
|
||||||
break
|
break
|
||||||
message = session.messages[index]
|
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 = 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:
|
if replacement_tokens >= original_tokens:
|
||||||
continue
|
continue
|
||||||
session.tombstone_message(
|
session.tombstone_message(
|
||||||
@@ -2832,28 +2865,194 @@ class LocalCodingAgent:
|
|||||||
return '# Memory\n\nNo CLAUDE.md memory files are currently loaded.'
|
return '# Memory\n\nNo CLAUDE.md memory files are currently loaded.'
|
||||||
return '\n'.join(['# Memory', '', claude_md])
|
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:
|
def render_mcp_report(self, query: str | None = None) -> str:
|
||||||
if self.mcp_runtime is None:
|
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:
|
if query:
|
||||||
return self.mcp_runtime.render_resource_index(query=query)
|
return self.mcp_runtime.render_resource_index(query=query)
|
||||||
return '\n'.join(['# MCP', '', self.mcp_runtime.render_summary()])
|
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:
|
def render_mcp_resources_report(self, query: str | None = None) -> str:
|
||||||
if self.mcp_runtime is None:
|
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)
|
return self.mcp_runtime.render_resource_index(query=query)
|
||||||
|
|
||||||
def render_mcp_resource_report(self, uri: str) -> str:
|
def render_mcp_resource_report(self, uri: str) -> str:
|
||||||
if self.mcp_runtime is None:
|
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)
|
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:
|
def render_tasks_report(self, status: str | None = None) -> str:
|
||||||
if self.task_runtime is None:
|
if self.task_runtime is None:
|
||||||
return '# Tasks\n\nNo local task runtime is available.'
|
return '# Tasks\n\nNo local task runtime is available.'
|
||||||
return self.task_runtime.render_tasks(status=status)
|
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:
|
def render_plan_report(self) -> str:
|
||||||
if self.plan_runtime is None:
|
if self.plan_runtime is None:
|
||||||
return '# Plan\n\nNo local plan runtime is available.'
|
return '# Plan\n\nNo local plan runtime is available.'
|
||||||
@@ -2891,10 +3090,12 @@ class LocalCodingAgent:
|
|||||||
return '\n'.join(lines)
|
return '\n'.join(lines)
|
||||||
|
|
||||||
def render_status_report(self) -> str:
|
def render_status_report(self) -> str:
|
||||||
|
token_counter = describe_token_counter(self.model_config.model)
|
||||||
lines = [
|
lines = [
|
||||||
'# Status',
|
'# Status',
|
||||||
'',
|
'',
|
||||||
f'- Model: {self.model_config.model}',
|
f'- Model: {self.model_config.model}',
|
||||||
|
f'- Token counter: {token_counter.backend} ({token_counter.source})',
|
||||||
f'- Registered tools: {len(self.tool_registry)}',
|
f'- Registered tools: {len(self.tool_registry)}',
|
||||||
f'- Streaming model responses: {self.runtime_config.stream_model_responses}',
|
f'- Streaming model responses: {self.runtime_config.stream_model_responses}',
|
||||||
f'- Session ID: {self.active_session_id or "none"}',
|
f'- Session ID: {self.active_session_id or "none"}',
|
||||||
@@ -2904,8 +3105,37 @@ class LocalCodingAgent:
|
|||||||
lines.append(
|
lines.append(
|
||||||
f'- Workspace trust mode: {"trusted" if self.hook_policy_runtime.is_trusted() else "untrusted"}'
|
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:
|
if self.mcp_runtime is not None:
|
||||||
lines.append(f'- MCP resources: {len(self.mcp_runtime.resources)}')
|
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:
|
if self.plan_runtime is not None and self.plan_runtime.steps:
|
||||||
lines.append(f'- Local plan steps: {len(self.plan_runtime.steps)}')
|
lines.append(f'- Local plan steps: {len(self.plan_runtime.steps)}')
|
||||||
if self.task_runtime is not None and self.task_runtime.tasks:
|
if self.task_runtime is not None and self.task_runtime.tasks:
|
||||||
@@ -2945,6 +3175,68 @@ class LocalCodingAgent:
|
|||||||
)
|
)
|
||||||
self.resume_source_session_id = None
|
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:
|
def _apply_plugin_before_prompt_hooks(self, prompt: str) -> str:
|
||||||
if self.plugin_runtime is None:
|
if self.plugin_runtime is None:
|
||||||
return prompt
|
return prompt
|
||||||
|
|||||||
+193
-1
@@ -114,6 +114,66 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]:
|
|||||||
description='Show discovered local MCP manifests and resource counts.',
|
description='Show discovered local MCP manifests and resource counts.',
|
||||||
handler=_handle_mcp,
|
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(
|
SlashCommandSpec(
|
||||||
names=('resources',),
|
names=('resources',),
|
||||||
description='List local MCP resources, optionally filtered by a query string.',
|
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.',
|
description='Show the local runtime task list, optionally filtered by status.',
|
||||||
handler=_handle_tasks,
|
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(
|
SlashCommandSpec(
|
||||||
names=('plan', 'planner'),
|
names=('plan', 'planner'),
|
||||||
description='Show the current local runtime plan.',
|
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:
|
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
|
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:
|
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))
|
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:
|
def _handle_plan(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
|
||||||
return _local_result(input_text, agent.render_plan_report())
|
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 typing import Callable
|
||||||
|
|
||||||
from .background_runtime import BackgroundSessionRuntime, build_background_worker_command
|
from .background_runtime import BackgroundSessionRuntime, build_background_worker_command
|
||||||
|
from .account_runtime import AccountRuntime
|
||||||
from .agent_runtime import LocalCodingAgent
|
from .agent_runtime import LocalCodingAgent
|
||||||
from .agent_types import (
|
from .agent_types import (
|
||||||
AgentPermissions,
|
AgentPermissions,
|
||||||
@@ -21,12 +22,21 @@ from .agent_types import (
|
|||||||
from .bootstrap_graph import build_bootstrap_graph
|
from .bootstrap_graph import build_bootstrap_graph
|
||||||
from .command_graph import build_command_graph
|
from .command_graph import build_command_graph
|
||||||
from .commands import execute_command, get_command, get_commands, render_command_index
|
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 .parity_audit import run_parity_audit
|
||||||
from .permissions import ToolPermissionContext
|
from .permissions import ToolPermissionContext
|
||||||
from .port_manifest import build_port_manifest
|
from .port_manifest import build_port_manifest
|
||||||
from .query_engine import QueryEnginePort
|
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 .runtime import PortRuntime
|
||||||
from .session_store import (
|
from .session_store import (
|
||||||
StoredAgentSession,
|
StoredAgentSession,
|
||||||
@@ -248,6 +258,58 @@ def _add_agent_resume_args(parser: argparse.ArgumentParser) -> None:
|
|||||||
parser.add_argument('--scratchpad-root')
|
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]:
|
def _build_resumed_agent(args: argparse.Namespace) -> tuple[LocalCodingAgent, StoredAgentSession]:
|
||||||
stored_session = load_agent_session(args.session_id)
|
stored_session = load_agent_session(args.session_id)
|
||||||
model_config = deserialize_model_config(stored_session.model_config)
|
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 = subparsers.add_parser('remote-mode', help='simulate remote-control runtime branching')
|
||||||
remote_parser.add_argument('target')
|
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 = subparsers.add_parser('ssh-mode', help='simulate SSH runtime branching')
|
||||||
ssh_parser.add_argument('target')
|
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 = subparsers.add_parser('teleport-mode', help='simulate teleport runtime branching')
|
||||||
teleport_parser.add_argument('target')
|
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 = subparsers.add_parser('direct-connect-mode', help='simulate direct-connect runtime branching')
|
||||||
direct_parser.add_argument('target')
|
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 = subparsers.add_parser('deep-link-mode', help='simulate deep-link runtime branching')
|
||||||
deep_link_parser.add_argument('target')
|
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 = subparsers.add_parser('show-command', help='show one mirrored command entry by exact name')
|
||||||
show_command.add_argument('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 = subparsers.add_parser('agent-kill', help='stop a local background agent session')
|
||||||
kill_parser.add_argument('background_id')
|
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 = subparsers.add_parser('agent-chat', help='run an interactive Python local-model chat loop')
|
||||||
chat_parser.add_argument('prompt', nargs='?')
|
chat_parser.add_argument('prompt', nargs='?')
|
||||||
chat_parser.add_argument('--resume-session-id')
|
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}')
|
print(f'{session.session_id}\n{len(session.messages)} messages\nin={session.input_tokens} out={session.output_tokens}')
|
||||||
return 0
|
return 0
|
||||||
if args.command == 'remote-mode':
|
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
|
return 0
|
||||||
if args.command == 'ssh-mode':
|
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
|
return 0
|
||||||
if args.command == 'teleport-mode':
|
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
|
return 0
|
||||||
if args.command == 'direct-connect-mode':
|
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
|
return 0
|
||||||
if args.command == 'deep-link-mode':
|
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
|
return 0
|
||||||
if args.command == 'show-command':
|
if args.command == 'show-command':
|
||||||
module = get_command(args.name)
|
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)
|
_print_agent_result(result, show_transcript=args.show_transcript)
|
||||||
return 0
|
return 0
|
||||||
if args.command == 'agent-bg':
|
if args.command == 'agent-bg':
|
||||||
background_runtime = BackgroundSessionRuntime()
|
return _launch_background_agent(args)
|
||||||
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
|
|
||||||
if args.command == 'agent-bg-worker':
|
if args.command == 'agent-bg-worker':
|
||||||
background_runtime = BackgroundSessionRuntime(Path(args.background_root))
|
return _run_background_worker(args)
|
||||||
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,
|
|
||||||
)
|
|
||||||
if args.command == 'agent-ps':
|
if args.command == 'agent-ps':
|
||||||
print(BackgroundSessionRuntime().render_ps())
|
print(BackgroundSessionRuntime().render_ps())
|
||||||
return 0
|
return 0
|
||||||
@@ -804,6 +1062,39 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
if record.exit_code is not None:
|
if record.exit_code is not None:
|
||||||
print(f'exit_code={record.exit_code}')
|
print(f'exit_code={record.exit_code}')
|
||||||
return 0
|
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':
|
if args.command == 'agent-chat':
|
||||||
agent = _build_agent(args)
|
agent = _build_agent(args)
|
||||||
return _run_agent_chat_loop(
|
return _run_agent_chat_loop(
|
||||||
|
|||||||
+655
-50
@@ -1,11 +1,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import selectors
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
MCP_PROTOCOL_VERSION = '2025-11-25'
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class MCPResource:
|
class MCPResource:
|
||||||
uri: str
|
uri: str
|
||||||
@@ -19,9 +26,33 @@ class MCPResource:
|
|||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
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
|
@dataclass
|
||||||
class MCPRuntime:
|
class MCPRuntime:
|
||||||
resources: tuple[MCPResource, ...] = field(default_factory=tuple)
|
resources: tuple[MCPResource, ...] = field(default_factory=tuple)
|
||||||
|
servers: tuple[MCPServerProfile, ...] = field(default_factory=tuple)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_workspace(
|
def from_workspace(
|
||||||
@@ -30,77 +61,165 @@ class MCPRuntime:
|
|||||||
additional_working_directories: tuple[str, ...] = (),
|
additional_working_directories: tuple[str, ...] = (),
|
||||||
) -> 'MCPRuntime':
|
) -> 'MCPRuntime':
|
||||||
resources: list[MCPResource] = []
|
resources: list[MCPResource] = []
|
||||||
|
servers: list[MCPServerProfile] = []
|
||||||
for path in _discover_manifest_paths(cwd, additional_working_directories):
|
for path in _discover_manifest_paths(cwd, additional_working_directories):
|
||||||
resources.extend(_load_resources_from_manifest(path))
|
manifest_resources, manifest_servers = _load_manifest(path)
|
||||||
return cls(resources=tuple(resources))
|
resources.extend(manifest_resources)
|
||||||
|
servers.extend(manifest_servers)
|
||||||
|
return cls(
|
||||||
|
resources=tuple(resources),
|
||||||
|
servers=tuple(_dedupe_servers(servers)),
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def manifests(self) -> tuple[str, ...]:
|
def manifests(self) -> tuple[str, ...]:
|
||||||
seen: list[str] = []
|
seen: list[str] = []
|
||||||
for resource in self.resources:
|
for entry in [*self.resources, *self.servers]:
|
||||||
if resource.source_manifest not in seen:
|
source_manifest = entry.source_manifest
|
||||||
seen.append(resource.source_manifest)
|
if source_manifest not in seen:
|
||||||
|
seen.append(source_manifest)
|
||||||
return tuple(seen)
|
return tuple(seen)
|
||||||
|
|
||||||
|
def has_transport_servers(self) -> bool:
|
||||||
|
return any(server.transport == 'stdio' for server in self.servers)
|
||||||
|
|
||||||
def list_resources(
|
def list_resources(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
query: str | None = None,
|
query: str | None = None,
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
) -> tuple[MCPResource, ...]:
|
) -> tuple[MCPResource, ...]:
|
||||||
resources = self.resources
|
resources = list(self.resources)
|
||||||
if query:
|
resources.extend(self._list_remote_resources())
|
||||||
needle = query.lower()
|
filtered = _filter_resources(tuple(resources), query=query)
|
||||||
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()
|
|
||||||
)
|
|
||||||
if limit is not None and limit >= 0:
|
if limit is not None and limit >= 0:
|
||||||
resources = resources[:limit]
|
filtered = filtered[:limit]
|
||||||
return resources
|
return filtered
|
||||||
|
|
||||||
def get_resource(self, uri: str) -> MCPResource | None:
|
def get_resource(self, uri: str) -> MCPResource | None:
|
||||||
for resource in self.resources:
|
for resource in self.resources:
|
||||||
if resource.uri == uri:
|
if resource.uri == uri:
|
||||||
return resource
|
return resource
|
||||||
|
for resource in self._list_remote_resources():
|
||||||
|
if resource.uri == uri:
|
||||||
|
return resource
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def read_resource(self, uri: str, *, max_chars: int = 12000) -> str:
|
def read_resource(self, uri: str, *, max_chars: int = 12000) -> str:
|
||||||
resource = self.get_resource(uri)
|
for resource in self.resources:
|
||||||
if resource is None:
|
if resource.uri != uri:
|
||||||
raise FileNotFoundError(f'Unknown MCP resource: {uri}')
|
continue
|
||||||
if resource.inline_text is not None:
|
if resource.inline_text is not None:
|
||||||
return _truncate(resource.inline_text, max_chars)
|
return _truncate(resource.inline_text, max_chars)
|
||||||
if resource.resolved_path is None:
|
if resource.resolved_path is not None:
|
||||||
raise FileNotFoundError(f'MCP resource has no readable content: {uri}')
|
path = Path(resource.resolved_path)
|
||||||
path = Path(resource.resolved_path)
|
if not path.exists() or not path.is_file():
|
||||||
if not path.exists() or not path.is_file():
|
raise FileNotFoundError(f'MCP resource file not found: {path}')
|
||||||
raise FileNotFoundError(f'MCP resource file not found: {path}')
|
text = path.read_text(encoding='utf-8', errors='replace')
|
||||||
text = path.read_text(encoding='utf-8', errors='replace')
|
return _truncate(text, max_chars)
|
||||||
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:
|
def render_summary(self) -> str:
|
||||||
if not self.resources:
|
if not self.resources and not self.servers:
|
||||||
return 'No local MCP manifests or resources discovered.'
|
return 'No local MCP manifests, servers, or resources discovered.'
|
||||||
lines = [
|
lines = [
|
||||||
f'Local MCP manifests: {len(self.manifests)}',
|
f'Local MCP manifests: {len(self.manifests)}',
|
||||||
f'Local MCP resources: {len(self.resources)}',
|
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] = {}
|
by_server: dict[str, int] = {}
|
||||||
for resource in self.resources:
|
for resource in self.resources:
|
||||||
by_server[resource.server_name] = by_server.get(resource.server_name, 0) + 1
|
by_server[resource.server_name] = by_server.get(resource.server_name, 0) + 1
|
||||||
for server_name, count in sorted(by_server.items()):
|
for server_name, count in sorted(by_server.items()):
|
||||||
lines.append(f'- {server_name}: {count} resource(s)')
|
lines.append(f'- local resources for {server_name}: {count}')
|
||||||
for manifest in self.manifests[:10]:
|
for server in self.servers[:10]:
|
||||||
manifest_name = Path(manifest).name
|
details = [server.name, server.transport]
|
||||||
manifest_count = sum(
|
if server.command:
|
||||||
1 for resource in self.resources if resource.source_manifest == manifest
|
details.append(server.command)
|
||||||
)
|
lines.append('- Server: ' + ' ; '.join(details))
|
||||||
lines.append(f'- {manifest_name}: {manifest_count} resource(s)')
|
|
||||||
return '\n'.join(lines)
|
return '\n'.join(lines)
|
||||||
|
|
||||||
def render_resource_index(
|
def render_resource_index(
|
||||||
@@ -122,6 +241,10 @@ class MCPRuntime:
|
|||||||
details.append(f'mime={resource.mime_type}')
|
details.append(f'mime={resource.mime_type}')
|
||||||
if resource.resolved_path:
|
if resource.resolved_path:
|
||||||
details.append(f'path={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))
|
lines.append('- ' + '; '.join(details))
|
||||||
return '\n'.join(lines)
|
return '\n'.join(lines)
|
||||||
|
|
||||||
@@ -144,6 +267,93 @@ class MCPRuntime:
|
|||||||
lines.extend(['', self.read_resource(uri, max_chars=max_chars)])
|
lines.extend(['', self.read_resource(uri, max_chars=max_chars)])
|
||||||
return '\n'.join(lines)
|
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(
|
def _discover_manifest_paths(
|
||||||
cwd: Path,
|
cwd: Path,
|
||||||
@@ -176,15 +386,17 @@ def _discover_manifest_paths(
|
|||||||
return tuple(candidates)
|
return tuple(candidates)
|
||||||
|
|
||||||
|
|
||||||
def _load_resources_from_manifest(path: Path) -> list[MCPResource]:
|
def _load_manifest(path: Path) -> tuple[list[MCPResource], list[MCPServerProfile]]:
|
||||||
try:
|
try:
|
||||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
return []
|
return [], []
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return []
|
return [], []
|
||||||
|
|
||||||
resources: list[MCPResource] = []
|
resources: list[MCPResource] = []
|
||||||
|
servers: list[MCPServerProfile] = []
|
||||||
|
|
||||||
if isinstance(payload.get('resources'), list):
|
if isinstance(payload.get('resources'), list):
|
||||||
resources.extend(
|
resources.extend(
|
||||||
_extract_resources(
|
_extract_resources(
|
||||||
@@ -193,21 +405,86 @@ def _load_resources_from_manifest(path: Path) -> list[MCPResource]:
|
|||||||
manifest_path=path,
|
manifest_path=path,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
servers = payload.get('servers')
|
|
||||||
if isinstance(servers, list):
|
raw_servers = payload.get('servers')
|
||||||
for item in servers:
|
if isinstance(raw_servers, list):
|
||||||
|
for item in raw_servers:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
name = item.get('name')
|
name = item.get('name')
|
||||||
if not isinstance(name, str) or not name.strip():
|
if not isinstance(name, str) or not name.strip():
|
||||||
continue
|
continue
|
||||||
|
server_name = name.strip()
|
||||||
raw_resources = item.get('resources')
|
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
|
continue
|
||||||
resources.extend(
|
if not isinstance(item, dict):
|
||||||
_extract_resources(name.strip(), raw_resources, manifest_path=path)
|
continue
|
||||||
)
|
server = _extract_server_profile(server_name.strip(), item, manifest_path=path)
|
||||||
return resources
|
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(
|
def _extract_resources(
|
||||||
@@ -267,6 +544,334 @@ def _extract_resources(
|
|||||||
return 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:
|
def _truncate(text: str, limit: int) -> str:
|
||||||
if len(text) <= limit:
|
if len(text) <= limit:
|
||||||
return text
|
return text
|
||||||
|
|||||||
+80
-3
@@ -11,7 +11,13 @@ from .task_runtime import TaskRuntime
|
|||||||
|
|
||||||
|
|
||||||
DEFAULT_PLAN_RUNTIME_PATH = Path('.port_sessions') / 'plan_runtime.json'
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -21,6 +27,9 @@ class PlanStep:
|
|||||||
task_id: str | None = None
|
task_id: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
priority: 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]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -29,6 +38,9 @@ class PlanStep:
|
|||||||
'task_id': self.task_id,
|
'task_id': self.task_id,
|
||||||
'description': self.description,
|
'description': self.description,
|
||||||
'priority': self.priority,
|
'priority': self.priority,
|
||||||
|
'active_form': self.active_form,
|
||||||
|
'owner': self.owner,
|
||||||
|
'depends_on': list(self.depends_on),
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -53,6 +65,19 @@ class PlanStep:
|
|||||||
and payload.get('priority').strip()
|
and payload.get('priority').strip()
|
||||||
else None
|
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()
|
and item.get('priority').strip()
|
||||||
else None
|
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(
|
mutation = self._persist(
|
||||||
@@ -158,6 +196,10 @@ class PlanRuntime:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
if sync_tasks and task_runtime is not None:
|
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_items = [
|
||||||
{
|
{
|
||||||
'task_id': step.task_id or f'plan_{index}',
|
'task_id': step.task_id or f'plan_{index}',
|
||||||
@@ -165,6 +207,14 @@ class PlanRuntime:
|
|||||||
'description': step.description,
|
'description': step.description,
|
||||||
'status': _plan_status_to_task_status(step.status),
|
'status': _plan_status_to_task_status(step.status),
|
||||||
'priority': step.priority,
|
'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)
|
for index, step in enumerate(self.steps, start=1)
|
||||||
]
|
]
|
||||||
@@ -238,6 +288,12 @@ class PlanRuntime:
|
|||||||
lines.append('- ' + '; '.join(details))
|
lines.append('- ' + '; '.join(details))
|
||||||
if step.description:
|
if step.description:
|
||||||
lines.append(f' description: {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)
|
return '\n'.join(lines)
|
||||||
|
|
||||||
def _persist(
|
def _persist(
|
||||||
@@ -297,6 +353,7 @@ def _normalize_plan_status(value: Any) -> str:
|
|||||||
'complete': 'completed',
|
'complete': 'completed',
|
||||||
'in-progress': 'in_progress',
|
'in-progress': 'in_progress',
|
||||||
'in progress': 'in_progress',
|
'in progress': 'in_progress',
|
||||||
|
'blocked_on': 'blocked',
|
||||||
}
|
}
|
||||||
lowered = aliases.get(lowered, lowered)
|
lowered = aliases.get(lowered, lowered)
|
||||||
if lowered in VALID_PLAN_STATUSES:
|
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:
|
def _plan_status_to_task_status(status: str) -> str:
|
||||||
if status == 'completed':
|
if status == 'completed':
|
||||||
return 'done'
|
return 'completed'
|
||||||
if status == 'in_progress':
|
if status == 'in_progress':
|
||||||
return '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:
|
def _snapshot_text(text: str, limit: int = 240) -> str:
|
||||||
|
|||||||
+571
-25
@@ -1,25 +1,571 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
import json
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
@dataclass(frozen=True)
|
from pathlib import Path
|
||||||
class RuntimeModeReport:
|
from typing import Any
|
||||||
mode: str
|
|
||||||
connected: bool
|
|
||||||
detail: str
|
DEFAULT_REMOTE_STATE_DIR = Path('.port_sessions')
|
||||||
|
DEFAULT_REMOTE_STATE_FILE = DEFAULT_REMOTE_STATE_DIR / 'remote_runtime.json'
|
||||||
def as_text(self) -> str:
|
SUPPORTED_REMOTE_MODES = (
|
||||||
return f'mode={self.mode}\nconnected={self.connected}\ndetail={self.detail}'
|
'remote',
|
||||||
|
'ssh',
|
||||||
|
'teleport',
|
||||||
def run_remote_mode(target: str) -> RuntimeModeReport:
|
'direct-connect',
|
||||||
return RuntimeModeReport('remote', True, f'Remote control placeholder prepared for {target}')
|
'deep-link',
|
||||||
|
)
|
||||||
|
|
||||||
def run_ssh_mode(target: str) -> RuntimeModeReport:
|
|
||||||
return RuntimeModeReport('ssh', True, f'SSH proxy placeholder prepared for {target}')
|
@dataclass(frozen=True)
|
||||||
|
class RemoteProfile:
|
||||||
|
name: str
|
||||||
def run_teleport_mode(target: str) -> RuntimeModeReport:
|
mode: str
|
||||||
return RuntimeModeReport('teleport', True, f'Teleport resume/create placeholder prepared for {target}')
|
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
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
VALID_TASK_STATUSES = ('todo', 'in_progress', 'done', 'cancelled')
|
VALID_TASK_STATUSES = (
|
||||||
|
'pending',
|
||||||
|
'in_progress',
|
||||||
|
'completed',
|
||||||
|
'blocked',
|
||||||
|
'cancelled',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class PortingTask:
|
class PortingTask:
|
||||||
task_id: str
|
task_id: str
|
||||||
title: str
|
title: str
|
||||||
status: str = 'todo'
|
status: str = 'pending'
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
priority: 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(
|
created_at: str = field(
|
||||||
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
||||||
)
|
)
|
||||||
@@ -29,6 +40,11 @@ class PortingTask:
|
|||||||
'status': self.status,
|
'status': self.status,
|
||||||
'description': self.description,
|
'description': self.description,
|
||||||
'priority': self.priority,
|
'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,
|
'created_at': self.created_at,
|
||||||
'updated_at': self.updated_at,
|
'updated_at': self.updated_at,
|
||||||
}
|
}
|
||||||
@@ -49,6 +65,25 @@ class PortingTask:
|
|||||||
if isinstance(payload.get('priority'), str)
|
if isinstance(payload.get('priority'), str)
|
||||||
else None
|
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=(
|
created_at=(
|
||||||
str(payload.get('created_at'))
|
str(payload.get('created_at'))
|
||||||
if isinstance(payload.get('created_at'), str)
|
if isinstance(payload.get('created_at'), str)
|
||||||
@@ -68,11 +103,28 @@ def _normalize_task_status(value: Any) -> str:
|
|||||||
aliases = {
|
aliases = {
|
||||||
'in-progress': 'in_progress',
|
'in-progress': 'in_progress',
|
||||||
'in progress': 'in_progress',
|
'in progress': 'in_progress',
|
||||||
'complete': 'done',
|
'complete': 'completed',
|
||||||
'completed': 'done',
|
'done': 'completed',
|
||||||
'open': 'todo',
|
'todo': 'pending',
|
||||||
|
'open': 'pending',
|
||||||
}
|
}
|
||||||
lowered = aliases.get(lowered, lowered)
|
lowered = aliases.get(lowered, lowered)
|
||||||
if lowered in VALID_TASK_STATUSES:
|
if lowered in VALID_TASK_STATUSES:
|
||||||
return lowered
|
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'
|
DEFAULT_TASK_RUNTIME_PATH = Path('.port_sessions') / 'task_runtime.json'
|
||||||
|
ACTIONABLE_TASK_STATUSES = ('pending', 'in_progress')
|
||||||
|
TERMINAL_TASK_STATUSES = ('completed', 'cancelled')
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -57,16 +59,38 @@ class TaskRuntime:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
|
owner: str | None = None,
|
||||||
|
actionable_only: bool = False,
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
) -> tuple[PortingTask, ...]:
|
) -> tuple[PortingTask, ...]:
|
||||||
tasks = self.tasks
|
tasks = self.tasks
|
||||||
if status:
|
if status:
|
||||||
normalized = _normalize_status(status)
|
normalized = _normalize_status(status)
|
||||||
tasks = tuple(task for task in tasks if task.status == normalized)
|
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:
|
if limit is not None and limit >= 0:
|
||||||
tasks = tasks[:limit]
|
tasks = tasks[:limit]
|
||||||
return tasks
|
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:
|
def get_task(self, task_id: str) -> PortingTask | None:
|
||||||
for task in self.tasks:
|
for task in self.tasks:
|
||||||
if task.task_id == task_id:
|
if task.task_id == task_id:
|
||||||
@@ -78,9 +102,14 @@ class TaskRuntime:
|
|||||||
*,
|
*,
|
||||||
title: str,
|
title: str,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
status: str = 'todo',
|
status: str = 'pending',
|
||||||
priority: str | None = None,
|
priority: str | None = None,
|
||||||
task_id: 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:
|
) -> TaskMutation:
|
||||||
task = PortingTask(
|
task = PortingTask(
|
||||||
task_id=task_id or f'task_{uuid4().hex[:10]}',
|
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,
|
description=description.strip() if isinstance(description, str) and description.strip() else None,
|
||||||
status=_normalize_status(status),
|
status=_normalize_status(status),
|
||||||
priority=priority.strip() if isinstance(priority, str) and priority.strip() else None,
|
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)
|
return self._persist((*self.tasks, task), task=task)
|
||||||
|
|
||||||
@@ -99,10 +137,19 @@ class TaskRuntime:
|
|||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
priority: 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:
|
) -> TaskMutation:
|
||||||
existing = self.get_task(task_id)
|
existing = self.get_task(task_id)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
raise KeyError(task_id)
|
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(
|
updated = replace(
|
||||||
existing,
|
existing,
|
||||||
title=title.strip() if isinstance(title, str) and title.strip() else existing.title,
|
title=title.strip() if isinstance(title, str) and title.strip() else existing.title,
|
||||||
@@ -119,11 +166,133 @@ class TaskRuntime:
|
|||||||
else None if priority == ''
|
else None if priority == ''
|
||||||
else existing.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(),
|
updated_at=datetime.now(timezone.utc).isoformat(),
|
||||||
)
|
)
|
||||||
tasks = tuple(updated if task.task_id == task_id else task for task in self.tasks)
|
tasks = tuple(updated if task.task_id == task_id else task for task in self.tasks)
|
||||||
return self._persist(tasks, task=updated)
|
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:
|
def replace_tasks(self, items: list[dict[str, Any]]) -> TaskMutation:
|
||||||
tasks: list[PortingTask] = []
|
tasks: list[PortingTask] = []
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
@@ -157,6 +326,25 @@ class TaskRuntime:
|
|||||||
and item.get('priority').strip()
|
and item.get('priority').strip()
|
||||||
else None
|
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=(
|
created_at=(
|
||||||
str(item.get('created_at'))
|
str(item.get('created_at'))
|
||||||
if isinstance(item.get('created_at'), str)
|
if isinstance(item.get('created_at'), str)
|
||||||
@@ -181,26 +369,55 @@ class TaskRuntime:
|
|||||||
'- Status counts: '
|
'- Status counts: '
|
||||||
+ ', '.join(f'{name}={count}' for name, count in sorted(counts.items()))
|
+ ', '.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:
|
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:
|
if len(self.tasks) > 4:
|
||||||
preview += f', ... (+{len(self.tasks) - 4} more)'
|
preview += f', ... (+{len(self.tasks) - 4} more)'
|
||||||
lines.append(f'- Task preview: {preview}')
|
lines.append(f'- Task preview: {preview}')
|
||||||
return '\n'.join(lines)
|
return '\n'.join(lines)
|
||||||
|
|
||||||
def render_tasks(self, *, status: str | None = None, limit: int = 50) -> str:
|
def render_tasks(
|
||||||
tasks = self.list_tasks(status=status, limit=limit)
|
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:
|
if not tasks:
|
||||||
return '# Tasks\n\nNo tasks are currently stored.'
|
return '# Tasks\n\nNo tasks are currently stored.'
|
||||||
lines = ['# Tasks', '']
|
lines = ['# Tasks', '']
|
||||||
|
if actionable_only:
|
||||||
|
lines.append('Showing actionable tasks only.')
|
||||||
|
lines.append('')
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
details = [task.task_id, f'status={task.status}']
|
details = [task.task_id, f'status={task.status}']
|
||||||
if task.priority:
|
if task.priority:
|
||||||
details.append(f'priority={task.priority}')
|
details.append(f'priority={task.priority}')
|
||||||
|
if task.owner:
|
||||||
|
details.append(f'owner={task.owner}')
|
||||||
details.append(f'title={task.title}')
|
details.append(f'title={task.title}')
|
||||||
lines.append('- ' + '; '.join(details))
|
lines.append('- ' + '; '.join(details))
|
||||||
if task.description:
|
if task.description:
|
||||||
lines.append(f' description: {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)
|
return '\n'.join(lines)
|
||||||
|
|
||||||
def render_task(self, task_id: str) -> str:
|
def render_task(self, task_id: str) -> str:
|
||||||
@@ -216,22 +433,52 @@ class TaskRuntime:
|
|||||||
]
|
]
|
||||||
if task.priority:
|
if task.priority:
|
||||||
lines.append(f'- Priority: {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:
|
if task.description:
|
||||||
lines.append(f'- Description: {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}')
|
lines.append(f'- Updated: {task.updated_at}')
|
||||||
return '\n'.join(lines)
|
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(
|
def _persist(
|
||||||
self,
|
self,
|
||||||
tasks: tuple[PortingTask, ...],
|
tasks: tuple[PortingTask, ...],
|
||||||
*,
|
*,
|
||||||
task: PortingTask | None,
|
task: PortingTask | None,
|
||||||
) -> TaskMutation:
|
) -> 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_preview = _snapshot_text(before_text)
|
||||||
before_sha256 = (
|
before_sha256 = (
|
||||||
hashlib.sha256(before_text.encode('utf-8')).hexdigest()
|
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
|
else None
|
||||||
)
|
)
|
||||||
payload_text = self._serialize_payload(tasks)
|
payload_text = self._serialize_payload(tasks)
|
||||||
@@ -246,7 +493,7 @@ class TaskRuntime:
|
|||||||
after_sha256=after_sha256,
|
after_sha256=after_sha256,
|
||||||
before_preview=before_preview if before_text.strip() else None,
|
before_preview=before_preview if before_text.strip() else None,
|
||||||
after_preview=_snapshot_text(payload_text),
|
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),
|
after_count=len(tasks),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -268,11 +515,81 @@ def _normalize_status(value: Any) -> str:
|
|||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
lowered = value.strip().lower().replace('-', '_').replace(' ', '_')
|
lowered = value.strip().lower().replace('-', '_').replace(' ', '_')
|
||||||
aliases = {
|
aliases = {
|
||||||
'complete': 'done',
|
'complete': 'completed',
|
||||||
'completed': 'done',
|
'done': 'completed',
|
||||||
'open': 'todo',
|
'todo': 'pending',
|
||||||
|
'open': 'pending',
|
||||||
}
|
}
|
||||||
lowered = aliases.get(lowered, lowered)
|
lowered = aliases.get(lowered, lowered)
|
||||||
if lowered in VALID_TASK_STATUSES:
|
if lowered in VALID_TASK_STATUSES:
|
||||||
return lowered
|
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('mcpRuntime', snapshot.user_context)
|
||||||
self.assertIn('Local MCP resources: 1', snapshot.user_context['mcpRuntime'])
|
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:
|
def test_user_context_loads_task_runtime_summary(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
workspace = Path(tmp_dir) / 'repo'
|
workspace = Path(tmp_dir) / 'repo'
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class AgentContextUsageTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertGreater(report.total_tokens, 0)
|
self.assertGreater(report.total_tokens, 0)
|
||||||
self.assertIn('## Context Usage', rendered)
|
self.assertIn('## Context Usage', rendered)
|
||||||
|
self.assertIn('**Token counter:**', rendered)
|
||||||
self.assertIn('### System Prompt Sections', rendered)
|
self.assertIn('### System Prompt Sections', rendered)
|
||||||
self.assertIn('### Message Breakdown', rendered)
|
self.assertIn('### Message Breakdown', rendered)
|
||||||
self.assertIn('#### Top Tools', rendered)
|
self.assertIn('#### Top Tools', rendered)
|
||||||
|
|||||||
@@ -125,6 +125,88 @@ class AgentPromptingTests(unittest.TestCase):
|
|||||||
prompt = render_system_prompt(parts)
|
prompt = render_system_prompt(parts)
|
||||||
self.assertIn('# MCP', prompt)
|
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:
|
def test_prompt_builder_mentions_tasks_when_runtime_is_loaded(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
workspace = Path(tmp_dir)
|
workspace = Path(tmp_dir)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -12,6 +14,55 @@ from src.plan_runtime import PlanRuntime
|
|||||||
from src.task_runtime import TaskRuntime
|
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):
|
class AgentSlashCommandTests(unittest.TestCase):
|
||||||
def test_parse_slash_command(self) -> None:
|
def test_parse_slash_command(self) -> None:
|
||||||
parsed = parse_slash_command('/context extra args')
|
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 notes', resource_result.final_output)
|
||||||
self.assertIn('# MCP', legacy_mcp_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:
|
def test_tasks_and_task_commands_render_local_reports(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
workspace = Path(tmp_dir)
|
workspace = Path(tmp_dir)
|
||||||
@@ -100,11 +287,14 @@ class AgentSlashCommandTests(unittest.TestCase):
|
|||||||
tasks_result = agent.run('/tasks')
|
tasks_result = agent.run('/tasks')
|
||||||
task_result = agent.run(f'/task {task_id}')
|
task_result = agent.run(f'/task {task_id}')
|
||||||
todo_result = agent.run('/todo in_progress')
|
todo_result = agent.run('/todo in_progress')
|
||||||
|
next_result = agent.run('/task-next')
|
||||||
self.assertIn('# Tasks', tasks_result.final_output)
|
self.assertIn('# Tasks', tasks_result.final_output)
|
||||||
self.assertIn(task_id, tasks_result.final_output)
|
self.assertIn(task_id, tasks_result.final_output)
|
||||||
self.assertIn('# Task', task_result.final_output)
|
self.assertIn('# Task', task_result.final_output)
|
||||||
self.assertIn('in_progress', task_result.final_output)
|
self.assertIn('in_progress', task_result.final_output)
|
||||||
self.assertIn('# Tasks', todo_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:
|
def test_plan_command_renders_local_report(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
@@ -133,6 +323,7 @@ class AgentSlashCommandTests(unittest.TestCase):
|
|||||||
self.assertIn('# Tools', tools_result.final_output)
|
self.assertIn('# Tools', tools_result.final_output)
|
||||||
self.assertIn('`read_file`', tools_result.final_output)
|
self.assertIn('`read_file`', tools_result.final_output)
|
||||||
self.assertIn('# Status', status_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)
|
self.assertIn('Last run: none', status_result.final_output)
|
||||||
|
|
||||||
def test_hooks_and_trust_commands_render_local_reports(self) -> None:
|
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.assertEqual(recorded_results, ['First chat reply.', 'Second chat reply.'])
|
||||||
self.assertIn('# Agent Chat', recorded_lines)
|
self.assertIn('# Agent Chat', recorded_lines)
|
||||||
self.assertIn('chat_ended=user_exit', 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -36,6 +37,52 @@ def make_urlopen_side_effect(responses: list[dict[str, object]]):
|
|||||||
|
|
||||||
|
|
||||||
class MCPRuntimeTests(unittest.TestCase):
|
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:
|
def test_runtime_discovers_and_reads_local_resources(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
workspace = Path(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.assertEqual(runtime.read_resource('mcp://workspace/inline'), 'inline body')
|
||||||
self.assertIn('mcp notes', runtime.read_resource('mcp://workspace/notes'))
|
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:
|
def test_mcp_tools_execute_against_runtime(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
workspace = Path(tmp_dir)
|
workspace = Path(tmp_dir)
|
||||||
@@ -90,6 +169,47 @@ class MCPRuntimeTests(unittest.TestCase):
|
|||||||
self.assertTrue(read_result.ok)
|
self.assertTrue(read_result.ok)
|
||||||
self.assertIn('mcp notes', read_result.content)
|
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:
|
def test_agent_can_use_mcp_tools_in_model_loop(self) -> None:
|
||||||
responses = [
|
responses = [
|
||||||
{
|
{
|
||||||
@@ -156,3 +276,75 @@ class MCPRuntimeTests(unittest.TestCase):
|
|||||||
if message.get('role') == 'tool'
|
if message.get('role') == 'tool'
|
||||||
)
|
)
|
||||||
self.assertIn('mcp notes', tool_message.get('content', ''))
|
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',
|
'step': 'Patch the tool registry',
|
||||||
'status': 'pending',
|
'status': 'blocked',
|
||||||
|
'depends_on': ['plan_1'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
explanation='Work through the runtime in two phases.',
|
explanation='Work through the runtime in two phases.',
|
||||||
@@ -59,12 +60,15 @@ class PlanRuntimeTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
rendered_plan = plan_runtime.render_plan()
|
rendered_plan = plan_runtime.render_plan()
|
||||||
rendered_tasks = task_runtime.render_tasks()
|
rendered_tasks = task_runtime.render_tasks()
|
||||||
|
rendered_task = task_runtime.render_task('plan_2')
|
||||||
|
|
||||||
self.assertEqual(mutation.after_count, 2)
|
self.assertEqual(mutation.after_count, 2)
|
||||||
self.assertEqual(mutation.synced_tasks, 2)
|
self.assertEqual(mutation.synced_tasks, 2)
|
||||||
self.assertIn('Inspect the runtime loop', rendered_plan)
|
self.assertIn('Inspect the runtime loop', rendered_plan)
|
||||||
self.assertIn('Work through the runtime in two phases.', 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('Inspect the runtime loop', rendered_tasks)
|
||||||
|
self.assertIn('Blocked By: plan_1', rendered_task)
|
||||||
|
|
||||||
def test_plan_tools_execute_against_runtime(self) -> None:
|
def test_plan_tools_execute_against_runtime(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
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',
|
status='in_progress',
|
||||||
)
|
)
|
||||||
assert created.task is not None
|
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_tasks = runtime.render_tasks()
|
||||||
rendered_task = runtime.render_task(created.task.task_id)
|
rendered_task = runtime.render_task(created.task.task_id)
|
||||||
|
|
||||||
self.assertIn('Implement task runtime', rendered_tasks)
|
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:
|
def test_task_tools_execute_against_runtime(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
@@ -67,11 +67,17 @@ class TaskRuntimeTests(unittest.TestCase):
|
|||||||
create_result = execute_tool(
|
create_result = execute_tool(
|
||||||
default_tool_registry(),
|
default_tool_registry(),
|
||||||
'task_create',
|
'task_create',
|
||||||
{'title': 'Review task tools', 'status': 'todo'},
|
{'title': 'Review task tools', 'status': 'pending'},
|
||||||
context,
|
context,
|
||||||
)
|
)
|
||||||
self.assertTrue(create_result.ok)
|
self.assertTrue(create_result.ok)
|
||||||
task_id = str(create_result.metadata.get('task_id'))
|
task_id = str(create_result.metadata.get('task_id'))
|
||||||
|
next_result = execute_tool(
|
||||||
|
default_tool_registry(),
|
||||||
|
'task_next',
|
||||||
|
{},
|
||||||
|
context,
|
||||||
|
)
|
||||||
list_result = execute_tool(
|
list_result = execute_tool(
|
||||||
default_tool_registry(),
|
default_tool_registry(),
|
||||||
'task_list',
|
'task_list',
|
||||||
@@ -87,7 +93,7 @@ class TaskRuntimeTests(unittest.TestCase):
|
|||||||
update_result = execute_tool(
|
update_result = execute_tool(
|
||||||
default_tool_registry(),
|
default_tool_registry(),
|
||||||
'task_update',
|
'task_update',
|
||||||
{'task_id': task_id, 'status': 'done'},
|
{'task_id': task_id, 'status': 'completed'},
|
||||||
context,
|
context,
|
||||||
)
|
)
|
||||||
todo_result = execute_tool(
|
todo_result = execute_tool(
|
||||||
@@ -97,13 +103,85 @@ class TaskRuntimeTests(unittest.TestCase):
|
|||||||
context,
|
context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.assertIn('Review task tools', next_result.content)
|
||||||
self.assertIn(task_id, list_result.content)
|
self.assertIn(task_id, list_result.content)
|
||||||
self.assertIn('Review task tools', get_result.content)
|
self.assertIn('Review task tools', get_result.content)
|
||||||
self.assertTrue(update_result.ok)
|
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.assertTrue(todo_result.ok)
|
||||||
self.assertEqual(todo_result.metadata.get('total_tasks'), 1)
|
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:
|
def test_agent_can_use_task_tools_in_model_loop(self) -> None:
|
||||||
responses = [
|
responses = [
|
||||||
{
|
{
|
||||||
@@ -118,7 +196,7 @@ class TaskRuntimeTests(unittest.TestCase):
|
|||||||
'type': 'function',
|
'type': 'function',
|
||||||
'function': {
|
'function': {
|
||||||
'name': 'task_create',
|
'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