diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 89defe0..c7a5bf0 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -72,12 +72,22 @@ Done: - [x] Local manifest-backed MCP resource discovery - [x] Local MCP resource listing and reading - [x] MCP-backed runtime tools for local resource access +- [x] Real stdio MCP client transport for `initialize`, `resources/list`, `resources/read`, `tools/list`, and `tools/call` +- [x] Transport-backed MCP tool listing and execution +- [x] Local manifest-backed remote runtime discovery +- [x] Local remote profile listing and summary reporting +- [x] Local remote connect/disconnect state persistence +- [x] Local manifest/env-backed search runtime discovery +- [x] Local search-provider activation persistence +- [x] Provider-backed web search execution against configured search backends - [x] Local persistent task runtime discovery - [x] Local task create/get/list/update runtime flows - [x] Local todo-list replacement runtime flow - [x] Local persistent plan runtime discovery - [x] Local plan get/update/clear runtime flows - [x] Local plan-to-task sync flow +- [x] Dependency-aware local task state with blocking and actionable-task selection +- [x] Local task start/complete/block/cancel execution flows - [x] Compaction metadata with compacted message ids - [x] Compaction metadata with preserved-tail ids and compaction depth - [x] Compaction metadata with compacted/preserved lineage ids and revision summaries @@ -125,11 +135,18 @@ Done: - [x] Local background session logs (`agent-logs`) - [x] Local background attach snapshot (`agent-attach`) - [x] Local background kill flow (`agent-kill`) +- [x] Local daemon-style background command family (`daemon start/ps/logs/attach/kill`) +- [x] Local daemon worker command path (`daemon worker`) +- [x] Local remote runtime CLI modes (`remote-mode`, `ssh-mode`, `teleport-mode`, `direct-connect-mode`, `deep-link-mode`) +- [x] Local remote runtime inspection commands (`remote-status`, `remote-profiles`, `remote-disconnect`) +- [x] Local account runtime inspection commands (`account-status`, `account-profiles`, `account-login`, `account-logout`) +- [x] Local search runtime inspection commands (`search-status`, `search-providers`, `search-activate`, `search`) +- [x] Local MCP runtime inspection commands (`mcp-status`, `mcp-resources`, `mcp-resource`, `mcp-tools`, `mcp-call-tool`) - [x] Inventory/helper commands such as `summary`, `manifest`, `commands`, and `tools` Missing: -- [ ] Daemon worker mode +- [ ] Full daemon supervisor parity beyond the current local daemon wrapper and worker flow - [ ] Remote-control / bridge runtime mode - [ ] Browser/native-host runtime mode - [ ] Computer-use MCP mode @@ -152,6 +169,10 @@ Done: - [x] Custom system prompt override and append support - [x] Local hook/policy guidance section in the Python system prompt - [x] Local MCP guidance section in the Python system prompt +- [x] MCP transport/tool guidance section in the Python system prompt +- [x] Local remote-runtime guidance section in the Python system prompt +- [x] Local search-runtime guidance section in the Python system prompt +- [x] Local account-runtime guidance section in the Python system prompt - [x] Local planning guidance section in the Python system prompt - [x] Local task guidance section in the Python system prompt @@ -178,18 +199,23 @@ Done: - [x] `CLAUDE.md` discovery - [x] Extra directory injection through `--add-dir` - [x] Session context usage report +- [x] Tokenizer-aware context accounting with cached model-specific backends and heuristic fallback - [x] Raw context inspection command - [x] Plugin cache snapshot injection - [x] Manifest-based plugin runtime summary injection - [x] Manifest-based hook/policy summary injection - [x] Trust-mode, managed-settings, and safe-env context injection - [x] Manifest-based MCP runtime summary injection +- [x] Manifest-based MCP transport server summary injection +- [x] Manifest-based remote runtime summary injection +- [x] Manifest/env-based search runtime summary injection +- [x] Manifest-based account runtime summary injection - [x] Manifest-based plan runtime summary injection - [x] Manifest-based task runtime summary injection Missing: -- [ ] Tokenizer-accurate context accounting +- [ ] Full tokenizer/chat-message framing parity beyond the current model-aware text token counters - [ ] Full parity with `utils/queryContext.ts` - [ ] Rich memory prompt loading - [ ] Internal permission-aware memory handling @@ -209,6 +235,19 @@ Done: - [x] `/context-raw` - [x] `/env` - [x] `/mcp` +- [x] `/mcp tools` +- [x] `/mcp tool ` +- [x] `/search` +- [x] `/remote` +- [x] `/remotes` +- [x] `/ssh` +- [x] `/teleport` +- [x] `/direct-connect` +- [x] `/deep-link` +- [x] `/disconnect` +- [x] `/account` +- [x] `/login` +- [x] `/logout` - [x] `/resources` - [x] `/resource` - [x] `/plan` @@ -216,6 +255,7 @@ Done: - [x] `/tasks` - [x] `/todo` - [x] `/task` +- [x] `/task-next` - [x] `/prompt` - [x] `/system-prompt` - [x] `/permissions` @@ -228,16 +268,18 @@ Done: - [x] `/status` - [x] `/session` - [x] `/clear` +- [x] `/config` +- [x] `/settings` Missing: - [ ] Full npm slash-command surface -- [ ] Slash commands backed by MCP integration +- [x] Slash commands backed by MCP integration - [ ] Slash commands tied to task/plan systems beyond the current local `/plan`, `/tasks`, and `/task` flows -- [ ] Slash commands tied to remote/background sessions +- [ ] Slash commands tied to remote/background sessions beyond the current local remote connect/disconnect and background inspection flows - [ ] Slash commands with richer interactive behavior - [ ] Slash commands tied to plugins and bundled skills -- [ ] Slash commands tied to settings, config, and account state +- [ ] Slash commands tied to account, settings, and auth flows beyond the current local `/account`, `/login`, `/logout`, `/config`, and `/settings` flows ## 6. Built-in Tools @@ -250,16 +292,41 @@ Done: - [x] `glob_search` - [x] `grep_search` - [x] `bash` +- [x] `web_fetch` +- [x] `search_status` +- [x] `search_list_providers` +- [x] `search_activate_provider` +- [x] `web_search` +- [x] `tool_search` +- [x] `sleep` +- [x] `account_status` +- [x] `account_list_profiles` +- [x] `account_login` +- [x] `account_logout` - [x] `mcp_list_resources` - [x] `mcp_read_resource` +- [x] `mcp_list_tools` +- [x] `mcp_call_tool` +- [x] `remote_status` +- [x] `remote_list_profiles` +- [x] `remote_connect` +- [x] `remote_disconnect` +- [x] `config_list` +- [x] `config_get` +- [x] `config_set` - [x] `plan_get` - [x] `update_plan` - [x] `plan_clear` - [x] `delegate_agent` +- [x] `task_next` - [x] `task_list` - [x] `task_get` - [x] `task_create` - [x] `task_update` +- [x] `task_start` +- [x] `task_complete` +- [x] `task_block` +- [x] `task_cancel` - [x] `todo_write` Missing: @@ -267,11 +334,11 @@ Missing: - [ ] Agent spawning tool parity beyond the current `delegate_agent` runtime tool - [ ] Skill tool - [ ] Notebook edit tool -- [ ] Web fetch tool -- [ ] Web search tool +- [ ] Web fetch parity beyond the current local text-fetch implementation +- [ ] Web search parity beyond the current provider-backed implementation - [ ] Ask-user-question tool - [ ] LSP tool -- [ ] Tool search tool +- [ ] Tool search parity beyond the current local registry search - [ ] Config tool - [ ] Team create/delete tools - [ ] Send-message tool @@ -279,7 +346,7 @@ Missing: - [ ] Browser tool - [ ] Workflow tool - [ ] Remote trigger tool -- [ ] Sleep / cron tools +- [ ] Sleep / cron tools beyond the current local `sleep` tool - [ ] PowerShell tool parity - [ ] Worktree enter/exit tools - [ ] Full `tools.ts` parity @@ -294,13 +361,15 @@ Done: - [x] Local todo-list replacement flow - [x] Local persistent plan runtime with get/update/clear flows - [x] Local plan-to-task sync flow +- [x] Local dependency-aware task execution flow with next-task selection and blocked/unblocked state +- [x] Local remote profile/runtime flow with persisted connect/disconnect state - [x] Local background task management for agent worker sessions Missing: - [ ] Real implementation of the larger upstream command tree -- [ ] Task orchestration system beyond the current local plan/task sync runtime -- [ ] Planner / task execution parity beyond the current local plan persistence and sync flow +- [ ] Task orchestration system beyond the current local dependency-aware task runtime +- [ ] Planner / task execution parity beyond the current local plan persistence, sync, and next-task flow - [ ] Team / collaboration command flows - [ ] Command-specific session behaviors - [ ] Full `src/commands/*` parity @@ -338,12 +407,13 @@ Done: - [x] Local manifest-backed MCP discovery - [x] Local MCP resource listing and reading - [x] MCP-backed runtime tools for local resource access +- [x] Real MCP client support over local stdio transport +- [x] MCP server integration for stdio child-process servers +- [x] MCP-backed tool listing and execution over transport Missing: -- [ ] Real MCP client support -- [ ] MCP server integration -- [ ] Full MCP-backed tool parity beyond the current local resource list/read tools +- [ ] Full MCP-backed tool parity beyond the current stdio resource/tool list/read/call support - [ ] Plugin discovery and loading - [ ] Bundled plugin support - [ ] Plugin lifecycle management @@ -376,12 +446,15 @@ Missing: Done: - [x] Session save/resume on local disk +- [x] Local manifest-backed remote profile/runtime state +- [x] Local remote connect/disconnect session state - [x] Local background agent processes - [x] Local background attach/log/kill workflows +- [x] Local daemon-style wrapper over background agent sessions Missing: -- [ ] Remote execution modes +- [ ] Real remote execution modes beyond the current local manifest-backed remote runtime and CLI/profile flows - [ ] Team runtime features - [ ] Team messaging features - [ ] Shared remote state @@ -410,6 +483,7 @@ Missing: Done: - [x] Minimal internal service layer required by the current Python runtime +- [x] Local account/auth runtime for manifest-backed profile discovery and persisted login state Missing: @@ -435,14 +509,19 @@ Working Python runtime today: - [x] `src/agent_context_usage.py` - [x] `src/agent_session.py` - [x] `src/agent_slash_commands.py` +- [x] `src/account_runtime.py` +- [x] `src/config_runtime.py` - [x] `src/agent_types.py` - [x] `src/mcp_runtime.py` - [x] `src/plan_runtime.py` - [x] `src/plugin_runtime.py` +- [x] `src/remote_runtime.py` +- [x] `src/search_runtime.py` - [x] `src/hook_policy.py` - [x] `src/background_runtime.py` - [x] `src/task.py` - [x] `src/task_runtime.py` +- [x] `src/tokenizer_runtime.py` - [x] `src/openai_compat.py` - [x] `src/session_store.py` - [x] `src/permissions.py` @@ -464,11 +543,11 @@ Mirrored inventory / scaffold areas that still need real implementation work: - [ ] Expand the real Python tool registry toward upstream `tools.ts` - [ ] Replace more snapshot-backed mirrored modules with working runtime code -- [ ] Implement real MCP support +- [ ] Expand MCP parity beyond the current stdio resource/tool transport support - [ ] Expand hooks and policy parity beyond the current manifest/runtime implementation - [ ] Build a real interactive REPL / TUI - [ ] Add tokenizer-accurate context accounting - [ ] Expand background session parity beyond the current local worker/log/attach model -- [ ] Add real remote session modes +- [ ] Add real remote session transport and shared remote state beyond the current local remote-profile runtime - [ ] Port more of the command/task system - [ ] Close the gap between the mirrored workspace and the working runtime diff --git a/README.md b/README.md index 4e97112..19bbde7 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ | 🆕 | **LiteLLM Proxy Support** | Route through LiteLLM Proxy to any provider | | 🆕 | **OpenRouter Support** | Cloud API gateway — access OpenAI, Anthropic, Google models via one endpoint | | 🆕 | **Query Engine** | Runtime event counters, transcript summaries, orchestration reports | +| 🆕 | **Remote Runtime** | Manifest-backed local remote profiles, connect/disconnect state, and remote CLI/slash flows | +| 🆕 | **Daemon Commands** | Local `daemon start/ps/logs/attach/kill` wrapper over background agent sessions | | 🆕 | **Testing Guide** | Comprehensive [TESTING_GUIDE.md](TESTING_GUIDE.md) with commands for every feature | | 🆕 | **Parity Checklist** | Full [PARITY_CHECKLIST.md](PARITY_CHECKLIST.md) tracking implementation status vs npm source | @@ -72,6 +74,7 @@ Built on the public porting workspace from [instructkr/claw-code](https://github | 🪆 **Nested Delegation** | Delegate subtasks to child agents with dependency-aware topological batching | | 📡 **Streaming** | Token-by-token streaming output with `--stream` | | 💬 **Slash Commands** | Local commands: `/help`, `/context`, `/tools`, `/memory`, `/status`, `/model`, and more | +| 🌐 **Remote Runtime** | Manifest-backed remote profiles with local `remote-mode`, `ssh-mode`, `teleport-mode`, and connect/disconnect state | | 🧠 **Context Engine** | Automatic context building with CLAUDE.md discovery, compaction, and snipping | | 🔄 **Session Persistence** | Save and resume agent sessions with file-history replay | | 💰 **Cost & Budget Control** | Token budgets, cost limits, tool-call caps, model-call caps | @@ -114,6 +117,8 @@ Built on the public porting workspace from [instructkr/claw-code](https://github - [x] File history journaling with snapshot IDs and replay summaries - [x] Nested agent delegation with dependency-aware topological batching - [x] Agent manager with lineage tracking and group membership +- [x] Local daemon-style background command family +- [x] Local remote runtime: manifest discovery, profile listing, connect/disconnect persistence, and CLI/slash flows - [x] Plugin runtime: manifest discovery, hooks, aliases, virtual tools, tool blocking - [x] Plugin lifecycle hooks: resume, persist, delegate phases - [x] Plugin session-state persistence and resume restoration @@ -129,7 +134,7 @@ Built on the public porting workspace from [instructkr/claw-code](https://github - [ ] Full interactive REPL / TUI behavior - [ ] Exact tokenizer-accurate context accounting - [ ] Hooks system parity -- [ ] Remote runtime modes (SSH, teleport, deep-link) +- [ ] Real remote transport/runtime parity beyond the current local remote-profile runtime - [ ] Voice and VIM modes - [ ] Editor and platform integrations - [ ] Background and team features @@ -164,6 +169,9 @@ claw-code/ │ ├── session_store.py # Session serialization & persistence │ ├── transcript.py # Transcript block export & mutation tracking │ ├── query_engine.py # Query engine facade & runtime orchestration +│ ├── remote_runtime.py # Local remote profiles, connect/disconnect state, remote CLI support +│ ├── account_runtime.py # Local account profiles, login/logout state, account CLI support +│ ├── config_runtime.py # Local workspace config/settings discovery and mutation │ ├── permissions.py # Tool permission filtering │ ├── cost_tracker.py # Cost & budget enforcement │ ├── tools.py # Mirrored tool inventory diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index e898df3..42ecd03 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -75,12 +75,16 @@ python3 -m src.main agent "/help" python3 -m src.main agent "/commands" python3 -m src.main agent "/context" --cwd .. python3 -m src.main agent "/context-raw" --cwd .. +python3 -m src.main agent "/search" --cwd .. +python3 -m src.main agent "/remote" --cwd .. +python3 -m src.main agent "/remotes" --cwd .. python3 -m src.main agent "/plan" --cwd .. python3 -m src.main agent "/prompt" --cwd .. python3 -m src.main agent "/permissions" --cwd .. python3 -m src.main agent "/hooks" --cwd .. python3 -m src.main agent "/policy" --cwd .. python3 -m src.main agent "/trust" --cwd .. +python3 -m src.main agent "/task-next" --cwd .. python3 -m src.main agent "/tools" --cwd .. python3 -m src.main agent "/memory" --cwd .. python3 -m src.main agent "/status" --cwd .. @@ -180,6 +184,89 @@ python3 -m src.main agent-context-raw --cwd ./test_cases python3 -m src.main agent-prompt --cwd ./test_cases ``` +### 4.9 Remote runtime context and prompt inspection + +Create a local remote manifest: + +```bash +cat > ./test_cases/.claw-remote.json <<'EOF' +{ + "profiles": [ + { + "name": "staging", + "mode": "ssh", + "target": "dev@staging", + "workspaceCwd": "/srv/app", + "sessionUrl": "wss://remote/session" + }, + { + "name": "preview", + "mode": "deep-link", + "target": "preview://session" + } + ] +} +EOF +``` + +Inspect the runtime view: + +```bash +python3 -m src.main agent "/remote" --cwd ./test_cases +python3 -m src.main agent "/remotes" --cwd ./test_cases +python3 -m src.main agent-context-raw --cwd ./test_cases +python3 -m src.main agent-prompt --cwd ./test_cases +``` + +### 4.10 Search runtime context and prompt inspection + +Create a local search manifest: + +```bash +cat > ./test_cases/.claw-search.json <<'EOF' +{ + "providers": [ + { + "name": "local-search", + "provider": "searxng", + "baseUrl": "http://127.0.0.1:8080" + } + ] +} +EOF +``` + +Inspect the runtime view: + +```bash +python3 -m src.main agent "/search" --cwd ./test_cases +python3 -m src.main agent "/search providers" --cwd ./test_cases +python3 -m src.main agent-context-raw --cwd ./test_cases +python3 -m src.main agent-prompt --cwd ./test_cases +``` + +### 4.11 Tokenizer-aware context accounting + +Inspect which token counter backend the runtime is using: + +```bash +python3 -m src.main agent "/status" --cwd ./test_cases +python3 -m src.main agent-context --cwd ./test_cases +``` + +Force a local tokenizer path or model override for context accounting: + +```bash +export CLAW_CODE_TOKENIZER_PATH=/path/to/local/tokenizer +# or +export CLAW_CODE_TOKENIZER_MODEL=Qwen/Qwen3-Coder-30B-A3B-Instruct + +python3 -m src.main agent "/status" --cwd ./test_cases +python3 -m src.main agent-context --cwd ./test_cases +``` + +If no tokenizer backend is available, the runtime will fall back to a heuristic counter and `/status` will show that. + ## 5. Core Agent Loop ### 5.1 Read-only run @@ -223,6 +310,87 @@ python3 -m src.main agent-chat \ --cwd .. ``` +## 6. Search Runtime And Real Web Search + +### 6.1 Configure a provider with a local manifest + +```bash +cat > ./test_cases/.claw-search.json <<'EOF' +{ + "providers": [ + { + "name": "local-search", + "provider": "searxng", + "baseUrl": "http://127.0.0.1:8080" + }, + { + "name": "backup-search", + "provider": "tavily", + "apiKeyEnv": "TAVILY_API_KEY" + } + ] +} +EOF +``` + +### 6.2 Configure a provider from environment variables + +Use one of these: + +```bash +export SEARXNG_BASE_URL=http://127.0.0.1:8080 +export BRAVE_SEARCH_API_KEY=your-brave-key +export TAVILY_API_KEY=your-tavily-key +``` + +### 6.3 Inspect providers from the CLI + +```bash +python3 -m src.main search-status --cwd ./test_cases +python3 -m src.main search-providers --cwd ./test_cases +python3 -m src.main search-status --cwd ./test_cases --provider local-search +python3 -m src.main search-activate local-search --cwd ./test_cases +``` + +### 6.4 Run a real web search from the CLI + +```bash +python3 -m src.main search \ + "python argparse mutually exclusive group" \ + --cwd ./test_cases \ + --provider local-search \ + --max-results 5 +``` + +Limit results to specific domains: + +```bash +python3 -m src.main search \ + "OpenAI Responses API" \ + --cwd ./test_cases \ + --domain openai.com \ + --domain platform.openai.com +``` + +### 6.5 Run a real web search through slash commands + +```bash +python3 -m src.main agent "/search" --cwd ./test_cases +python3 -m src.main agent "/search providers" --cwd ./test_cases +python3 -m src.main agent "/search provider local-search" --cwd ./test_cases +python3 -m src.main agent "/search use local-search" --cwd ./test_cases +python3 -m src.main agent "/search python unittest mock patch examples" --cwd ./test_cases +``` + +### 6.6 Run a real web search through the model tool loop + +```bash +python3 -m src.main agent \ + "Use web_search to find Python unittest mocking references, then summarize the top results." \ + --cwd ./test_cases \ + --show-transcript +``` + Inside chat mode: - type normal prompts to continue the same session @@ -241,7 +409,7 @@ python3 -m src.main agent-chat \ ### 6.1 Read files ```bash -python3 -m src.main agent \ +python3 -m src.main agent \ "Read claw-code-agent/src/agent_tools.py and summarize each tool." \ --cwd .. ``` @@ -340,6 +508,15 @@ python3 -m src.main agent "/plan" --cwd ./test_cases python3 -m src.main agent "/tasks" --cwd ./test_cases ``` +### 6.9 Remote tools + +```bash +python3 -m src.main agent \ + "List the configured remote profiles, connect to staging, then report the active remote status." \ + --cwd ./test_cases \ + --show-transcript +``` + ## 7. Session Persistence And Resume ### 7.1 Create a saved session @@ -417,7 +594,38 @@ python3 -m src.main agent-attach --tail 40 python3 -m src.main agent-kill ``` -## 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 +python3 -m src.main daemon attach +python3 -m src.main daemon kill +``` + +## 9. Remote Runtime CLI + +### 9.1 Remote mode commands + +```bash +python3 -m src.main remote-mode staging --cwd ./test_cases +python3 -m src.main ssh-mode staging --cwd ./test_cases +python3 -m src.main teleport-mode preview --cwd ./test_cases +python3 -m src.main direct-connect-mode direct://workspace --cwd ./test_cases +python3 -m src.main deep-link-mode preview --cwd ./test_cases +``` + +### 9.2 Inspect and clear remote state + +```bash +python3 -m src.main remote-status --cwd ./test_cases +python3 -m src.main remote-profiles --cwd ./test_cases +python3 -m src.main remote-disconnect --cwd ./test_cases +python3 -m src.main remote-status --cwd ./test_cases +``` + +## 10. Structured Output / JSON Schema Create a schema file: @@ -446,9 +654,9 @@ python3 -m src.main agent \ --response-schema-strict ``` -## 10. Budgets And Limits +## 11. Budgets And Limits -### 10.1 Total token budget +### 11.1 Total token budget ```bash python3 -m src.main agent \ @@ -457,7 +665,7 @@ python3 -m src.main agent \ --max-total-tokens 50 ``` -### 10.2 Input / output token budgets +### 11.2 Input / output token budgets ```bash python3 -m src.main agent \ @@ -467,7 +675,7 @@ python3 -m src.main agent \ --max-output-tokens 80 ``` -### 10.3 Reasoning-token budget +### 11.3 Reasoning-token budget ```bash python3 -m src.main agent \ @@ -476,7 +684,7 @@ python3 -m src.main agent \ --max-reasoning-tokens 10 ``` -### 10.4 Tool-call budget +### 11.4 Tool-call budget ```bash python3 -m src.main agent \ @@ -485,7 +693,7 @@ python3 -m src.main agent \ --max-tool-calls 1 ``` -### 10.5 Delegated-task budget +### 11.5 Delegated-task budget ```bash python3 -m src.main agent \ @@ -494,7 +702,7 @@ python3 -m src.main agent \ --max-delegated-tasks 1 ``` -### 10.6 Cost budget +### 11.6 Cost budget ```bash python3 -m src.main agent \ @@ -505,7 +713,7 @@ python3 -m src.main agent \ --max-budget-usd 0.000001 ``` -### 10.7 Model-call budget +### 11.7 Model-call budget ```bash python3 -m src.main agent \ @@ -514,7 +722,7 @@ python3 -m src.main agent \ --max-model-calls 1 ``` -### 10.8 Session-turn budget +### 11.8 Session-turn budget ```bash python3 -m src.main agent \ @@ -523,7 +731,7 @@ python3 -m src.main agent \ --max-session-turns 1 ``` -### 10.9 Budget overrides from local policy +### 11.9 Budget overrides from local policy ```bash cat > ./test_cases/.claw-policy.json <<'EOF' @@ -541,9 +749,9 @@ python3 -m src.main agent \ Expected result: the run stops with a model-call budget exceeded message even though you did not pass `--max-model-calls` on the CLI. -## 11. Streaming, Continuation, And Context Reduction +## 12. Streaming, Continuation, And Context Reduction -### 11.1 Streaming assistant output +### 12.1 Streaming assistant output ```bash python3 -m src.main agent \ @@ -553,7 +761,7 @@ python3 -m src.main agent \ --show-transcript ``` -### 11.2 Automatic continuation after truncation +### 12.2 Automatic continuation after truncation Use a small output budget so the backend is more likely to stop early: @@ -565,7 +773,7 @@ python3 -m src.main agent \ --show-transcript ``` -### 11.3 Snipping older context +### 12.3 Snipping older context ```bash python3 -m src.main agent \ @@ -576,7 +784,7 @@ python3 -m src.main agent \ --show-transcript ``` -### 11.4 Compaction boundaries +### 12.4 Compaction boundaries ```bash python3 -m src.main agent \ @@ -587,9 +795,9 @@ python3 -m src.main agent \ --show-transcript ``` -## 12. File History Replay +## 13. File History Replay -### 12.1 Create file history +### 13.1 Create file history ```bash python3 -m src.main agent \ @@ -598,7 +806,7 @@ python3 -m src.main agent \ --allow-write ``` -### 12.2 Resume and inspect replay +### 13.2 Resume and inspect replay ```bash python3 -m src.main agent-resume \ @@ -610,9 +818,9 @@ python3 -m src.main agent-resume \ Look for `file_history_replay` messages in the transcript. -## 13. Nested Delegation +## 14. Nested Delegation -### 13.1 Basic delegated subtask +### 14.1 Basic delegated subtask ```bash python3 -m src.main agent \ @@ -621,7 +829,7 @@ python3 -m src.main agent \ --show-transcript ``` -### 13.2 Multiple delegated subtasks +### 14.2 Multiple delegated subtasks ```bash python3 -m src.main agent \ @@ -630,7 +838,7 @@ python3 -m src.main agent \ --show-transcript ``` -### 13.3 Resume a delegated child session +### 14.3 Resume a delegated child session 1. Seed a normal saved session: @@ -649,7 +857,7 @@ python3 -m src.main agent \ --show-transcript ``` -### 13.4 Topological dependency batches +### 14.4 Topological dependency batches ```bash python3 -m src.main agent \ @@ -664,7 +872,7 @@ Look for: - `delegate_group_result` - `batch_index=...` -## 14. Plugin Runtime +## 15. Plugin Runtime Create a local plugin manifest: @@ -705,14 +913,14 @@ cat > ./test_cases/plugins/demo/plugin.json <<'EOF' EOF ``` -### 14.1 Plugin prompt/context discovery +### 15.1 Plugin prompt/context discovery ```bash python3 -m src.main agent-prompt --cwd ./test_cases python3 -m src.main agent-context-raw --cwd ./test_cases ``` -### 14.2 Plugin alias tool +### 15.2 Plugin alias tool ```bash echo "hello plugin" > ./test_cases/hello.txt @@ -722,7 +930,7 @@ python3 -m src.main agent \ --show-transcript ``` -### 14.3 Plugin virtual tool +### 15.3 Plugin virtual tool ```bash python3 -m src.main agent \ @@ -731,7 +939,7 @@ python3 -m src.main agent \ --show-transcript ``` -### 14.4 Plugin before/after tool guidance +### 15.4 Plugin before/after tool guidance ```bash python3 -m src.main agent \ @@ -740,7 +948,7 @@ python3 -m src.main agent \ --show-transcript ``` -### 14.5 Plugin lifecycle with resume/persist +### 15.5 Plugin lifecycle with resume/persist 1. Start a session: @@ -765,7 +973,7 @@ Look for: - `Plugin resume hooks:` - `Plugin runtime state:` -## 15. MCP Runtime +## 16. MCP Runtime Create a local MCP manifest: @@ -796,7 +1004,7 @@ cat > ./test_cases_mcp/.claw-mcp.json <<'EOF' EOF ``` -### 15.1 MCP context and slash commands +### 16.1 MCP context and slash commands ```bash python3 -m src.main agent "/mcp" --cwd ./test_cases_mcp @@ -807,7 +1015,7 @@ python3 -m src.main agent-context-raw --cwd ./test_cases_mcp python3 -m src.main agent-prompt --cwd ./test_cases_mcp ``` -### 15.2 MCP tools through the model loop +### 16.2 MCP tools through the model loop ```bash python3 -m src.main agent \ @@ -816,7 +1024,7 @@ python3 -m src.main agent \ --show-transcript ``` -### 15.3 Read inline MCP resources +### 16.3 Read inline MCP resources ```bash python3 -m src.main agent \ @@ -825,7 +1033,161 @@ python3 -m src.main agent \ --show-transcript ``` -## 16. Task Runtime +### 16.4 Real stdio MCP server transport + +Create a simple stdio MCP server: + +```bash +cat > ./test_cases_mcp/fake_stdio_mcp.py <<'EOF' +import json +import sys + +RESOURCES = [ + { + "uri": "mcp://remote/notes", + "name": "Remote Notes", + "mimeType": "text/plain", + } +] +TOOLS = [ + { + "name": "echo", + "description": "Echo text", + "inputSchema": { + "type": "object", + "properties": { + "text": {"type": "string"} + } + } + } +] + +for raw in sys.stdin: + raw = raw.strip() + if not raw: + continue + message = json.loads(raw) + method = message.get("method") + if method == "initialize": + print(json.dumps({ + "jsonrpc": "2.0", + "id": message["id"], + "result": { + "protocolVersion": "2025-11-25", + "capabilities": {"resources": {}, "tools": {}}, + "serverInfo": {"name": "fake-remote", "version": "1.0.0"} + } + }), flush=True) + continue + if method == "notifications/initialized": + continue + if method == "resources/list": + print(json.dumps({ + "jsonrpc": "2.0", + "id": message["id"], + "result": {"resources": RESOURCES} + }), flush=True) + continue + if method == "resources/read": + uri = message.get("params", {}).get("uri") + print(json.dumps({ + "jsonrpc": "2.0", + "id": message["id"], + "result": { + "contents": [ + { + "uri": uri, + "mimeType": "text/plain", + "text": "remote notes via stdio" + } + ] + } + }), flush=True) + continue + if method == "tools/list": + print(json.dumps({ + "jsonrpc": "2.0", + "id": message["id"], + "result": {"tools": TOOLS} + }), flush=True) + continue + if method == "tools/call": + text = message.get("params", {}).get("arguments", {}).get("text", "") + print(json.dumps({ + "jsonrpc": "2.0", + "id": message["id"], + "result": { + "content": [{"type": "text", "text": "echo:" + text}], + "isError": False + } + }), flush=True) + continue +EOF +``` + +Add the stdio server to the MCP manifest: + +```bash +cat > ./test_cases_mcp/.claw-mcp.json <<'EOF' +{ + "servers": [ + { + "name": "workspace", + "resources": [ + { + "uri": "mcp://workspace/notes", + "name": "Notes", + "path": "notes.txt", + "mimeType": "text/plain" + }, + { + "uri": "mcp://workspace/inline", + "name": "Inline", + "text": "inline body" + } + ] + } + ], + "mcpServers": { + "remote": { + "command": "python3", + "args": ["-u", "./fake_stdio_mcp.py"] + } + } +} +EOF +``` + +### 16.5 MCP transport CLI commands + +```bash +python3 -m src.main mcp-status --cwd ./test_cases_mcp +python3 -m src.main mcp-resources --cwd ./test_cases_mcp +python3 -m src.main mcp-resource mcp://remote/notes --cwd ./test_cases_mcp +python3 -m src.main mcp-tools --cwd ./test_cases_mcp +python3 -m src.main mcp-call-tool echo --arguments-json '{"text":"hello"}' --cwd ./test_cases_mcp +``` + +### 16.6 MCP transport slash commands + +```bash +python3 -m src.main agent "/mcp" --cwd ./test_cases_mcp +python3 -m src.main agent "/mcp tools" --cwd ./test_cases_mcp +python3 -m src.main agent "/mcp tool echo" --cwd ./test_cases_mcp +python3 -m src.main agent "/resources" --cwd ./test_cases_mcp +python3 -m src.main agent "/resource mcp://remote/notes" --cwd ./test_cases_mcp +``` + +### 16.7 MCP transport tools through the model loop + +```bash +python3 -m src.main agent \ + "List the available MCP tools, call the remote echo tool with text=hello, then summarize the result." \ + --cwd ./test_cases_mcp \ + --show-transcript +``` + +## 17. Task Runtime Create a clean task workspace: @@ -834,7 +1196,7 @@ mkdir -p ./test_cases_tasks rm -rf ./test_cases_tasks/.port_sessions ``` -### 16.1 Task slash commands +### 17.1 Task slash commands ```bash python3 -m src.main agent "/tasks" --cwd ./test_cases_tasks @@ -844,7 +1206,7 @@ python3 -m src.main agent-context-raw --cwd ./test_cases_tasks python3 -m src.main agent-prompt --cwd ./test_cases_tasks ``` -### 16.2 Create and update tasks through the model loop +### 17.2 Create and update tasks through the model loop ```bash python3 -m src.main agent \ @@ -860,7 +1222,7 @@ Then inspect the stored task file: cat ./test_cases_tasks/.port_sessions/task_runtime.json ``` -### 16.3 Replace the todo list +### 17.3 Replace the todo list ```bash python3 -m src.main agent \ @@ -870,17 +1232,18 @@ python3 -m src.main agent \ --show-transcript ``` -### 16.4 Read back task state +### 17.4 Read back task state ```bash python3 -m src.main agent "/tasks" --cwd ./test_cases_tasks +python3 -m src.main agent "/task-next" --cwd ./test_cases_tasks python3 -m src.main agent \ "List the current tasks and show me the id of each one." \ --cwd ./test_cases_tasks \ --show-transcript ``` -### 16.5 Plan runtime and task sync +### 17.5 Plan runtime and task sync ```bash python3 -m src.main agent \ @@ -894,9 +1257,49 @@ python3 -m src.main agent "/tasks" --cwd ./test_cases_tasks cat ./test_cases_tasks/.port_sessions/plan_runtime.json ``` -## 17. Query Engine And Workspace Commands +### 17.6 Dependency-aware task execution -### 17.1 Workspace inventory +Create a blocked task graph through the model loop: + +```bash +python3 -m src.main agent \ + "Use todo_write to create two tasks: scan with status pending, and patch with status blocked and blocked_by scan. Then show the next actionable tasks." \ + --cwd ./test_cases_tasks \ + --allow-write \ + --show-transcript +``` + +Then advance the task state: + +```bash +python3 -m src.main agent \ + "Mark task scan as completed, then show the next actionable tasks and start task patch with active_form 'Patching files'." \ + --cwd ./test_cases_tasks \ + --allow-write \ + --show-transcript +``` + +Inspect the resulting task state: + +```bash +python3 -m src.main agent "/task-next" --cwd ./test_cases_tasks +python3 -m src.main agent "/tasks" --cwd ./test_cases_tasks +cat ./test_cases_tasks/.port_sessions/task_runtime.json +``` + +### 17.7 Task execution tools directly + +```bash +python3 -m src.main agent \ + "Use todo_write to create task scan and task patch where patch is blocked_by scan. Then use task_next, task_complete for scan, and task_start for patch." \ + --cwd ./test_cases_tasks \ + --allow-write \ + --show-transcript +``` + +## 18. Query Engine And Workspace Commands + +### 18.1 Workspace inventory ```bash python3 -m src.main summary @@ -906,7 +1309,7 @@ python3 -m src.main commands --limit 20 python3 -m src.main tools --limit 20 ``` -### 17.2 Query routing and bootstrap reports +### 18.2 Query routing and bootstrap reports ```bash python3 -m src.main route "inspect the runtime and tools" --limit 10 @@ -914,25 +1317,13 @@ python3 -m src.main bootstrap "inspect the runtime and tools" --limit 10 python3 -m src.main turn-loop "inspect the runtime and tools" --limit 5 --max-turns 3 ``` -### 17.3 Session flushing for the mirrored workspace +### 18.3 Session flushing for the mirrored workspace ```bash python3 -m src.main flush-transcript "store a temporary transcript" python3 -m src.main load-session ``` -## 18. Remote/Direct Mode Simulations - -These are mirrored workspace simulation commands, not the real agent runtime: - -```bash -python3 -m src.main remote-mode demo-target -python3 -m src.main ssh-mode demo-target -python3 -m src.main teleport-mode demo-target -python3 -m src.main direct-connect-mode demo-target -python3 -m src.main deep-link-mode demo-target -``` - ## 19. Parity Tracking Workflow Use this every time a new feature lands: @@ -950,3 +1341,105 @@ Rule for future work: - every new implemented feature should add a checked item in `PARITY_CHECKLIST.md` - every user-testable feature should add at least one concrete command example in `TESTING_GUIDE.md` + +## 20. Config Runtime + +Inspect local config state: + +```bash +python3 -m src.main config-status --cwd ./test_cases +python3 -m src.main config-effective --cwd ./test_cases +``` + +Read a value or a source directly: + +```bash +python3 -m src.main config-get review.mode --cwd ./test_cases +python3 -m src.main config-source project --cwd ./test_cases +``` + +Write a value into the local settings layer: + +```bash +python3 -m src.main config-set review.mode '"strict"' --cwd ./test_cases +python3 -m src.main config-set review.enabled true --cwd ./test_cases +``` + +Test the slash commands: + +```bash +python3 -m src.main agent "/config" --cwd ./test_cases +python3 -m src.main agent "/config effective" --cwd ./test_cases +python3 -m src.main agent "/config get review.mode" --cwd ./test_cases +python3 -m src.main agent "/settings source local" --cwd ./test_cases +``` + +Test the config tools through the real agent loop: + +```bash +python3 -m src.main agent \ + "List the current config keys, set review.mode to strict in local config, then read it back." \ + --cwd ./test_cases \ + --allow-write +``` + +## 21. Account Runtime + +Inspect local account state: + +```bash +python3 -m src.main account-status --cwd ./test_cases +python3 -m src.main account-profiles --cwd ./test_cases +``` + +Activate and clear a local account session: + +```bash +python3 -m src.main account-login local --cwd ./test_cases +python3 -m src.main account-logout --cwd ./test_cases +``` + +Test the slash commands: + +```bash +python3 -m src.main agent "/account" --cwd ./test_cases +python3 -m src.main agent "/account profiles" --cwd ./test_cases +python3 -m src.main agent "/login local" --cwd ./test_cases +python3 -m src.main agent "/logout" --cwd ./test_cases +``` + +Test the account tools through the real agent loop: + +```bash +python3 -m src.main agent \ + "List the configured account profiles, activate the local profile, then report the active account session." \ + --cwd ./test_cases +``` + +## 22. Extended Tool Slice + +Test the new tool-surface additions through the real agent loop: + +```bash +python3 -m src.main agent \ + "Use tool_search to find file-related tools and summarize the best ones for reading and editing files." \ + --cwd ./test_cases +``` + +```bash +python3 -m src.main agent \ + "Use web_fetch on file://$(pwd)/README.md and summarize the first section." \ + --cwd . +``` + +```bash +python3 -m src.main agent \ + "Call the sleep tool for 0.1 seconds, then tell me it completed." \ + --cwd ./test_cases +``` + +Run the direct unit tests for this slice: + +```bash +python3 -m unittest tests.test_extended_tools -v +``` diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py new file mode 100644 index 0000000..1631abf --- /dev/null +++ b/benchmarks/__init__.py @@ -0,0 +1 @@ +"""Claw Code Agent local benchmark suite.""" diff --git a/benchmarks/run.py b/benchmarks/run.py new file mode 100644 index 0000000..dff6079 --- /dev/null +++ b/benchmarks/run.py @@ -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() diff --git a/benchmarks/tasks/__init__.py b/benchmarks/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/benchmarks/tasks/definitions.py b/benchmarks/tasks/definitions.py new file mode 100644 index 0000000..db2804d --- /dev/null +++ b/benchmarks/tasks/definitions.py @@ -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, !'.", + 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/ -> 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\": , \"average_age\": , " + "\"cities\": []}." + ), + 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: " + "':: '. 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 + ] diff --git a/src/__init__.py b/src/__init__.py index a850444..5c49660 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,5 +1,6 @@ """Python porting workspace for the Claude Code rewrite effort.""" +from .account_runtime import AccountRuntime, AccountProfile, AccountSessionState, AccountStatusReport from .agent_context import ( AgentContextSnapshot, build_context_snapshot, @@ -15,17 +16,20 @@ from .agent_tools import build_tool_context, default_tool_registry, execute_tool from .agent_types import AgentPermissions, AgentRunResult, AgentRuntimeConfig, ModelConfig from .background_runtime import BackgroundSessionRuntime from .commands import PORTED_COMMANDS, build_command_backlog -from .mcp_runtime import MCPRuntime +from .config_runtime import ConfigMutation, ConfigRuntime +from .mcp_runtime import MCPRuntime, MCPResource, MCPServerProfile, MCPTool from .parity_audit import ParityAuditResult, run_parity_audit from .plan_runtime import PlanRuntime, PlanStep from .plugin_runtime import PluginRuntime from .port_manifest import PortManifest, build_port_manifest from .query_engine import QueryEnginePort, TurnResult from .runtime import PortRuntime, RuntimeSession +from .search_runtime import SearchProviderProfile, SearchResult, SearchRuntime, SearchStatusReport from .session_store import StoredSession, load_session, save_session from .system_init import build_system_init_message from .task import PortingTask from .task_runtime import TaskRuntime +from .tokenizer_runtime import TokenCounterInfo, clear_token_counter_cache, count_tokens, describe_token_counter from .tools import PORTED_TOOLS, build_tool_backlog __all__ = [ @@ -34,11 +38,20 @@ __all__ = [ 'AgentPermissions', 'AgentRunResult', 'AgentRuntimeConfig', + 'AccountProfile', + 'AccountRuntime', + 'AccountSessionState', + 'AccountStatusReport', 'AgentMessage', 'AgentSessionState', 'BackgroundSessionRuntime', + 'ConfigMutation', + 'ConfigRuntime', 'LocalCodingAgent', + 'MCPResource', 'MCPRuntime', + 'MCPServerProfile', + 'MCPTool', 'ModelConfig', 'ParityAuditResult', 'PlanRuntime', @@ -49,8 +62,13 @@ __all__ = [ 'PortingTask', 'QueryEnginePort', 'RuntimeSession', + 'SearchProviderProfile', + 'SearchResult', + 'SearchRuntime', + 'SearchStatusReport', 'StoredSession', 'TaskRuntime', + 'TokenCounterInfo', 'TurnResult', 'PORTED_COMMANDS', 'PORTED_TOOLS', @@ -61,7 +79,10 @@ __all__ = [ 'build_tool_backlog', 'build_tool_context', 'clear_context_caches', + 'clear_token_counter_cache', + 'count_tokens', 'default_tool_registry', + 'describe_token_counter', 'execute_tool', 'get_system_context', 'get_user_context', diff --git a/src/account_runtime.py b/src/account_runtime.py new file mode 100644 index 0000000..f543749 --- /dev/null +++ b/src/account_runtime.py @@ -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() diff --git a/src/agent_context.py b/src/agent_context.py index d5965e9..1e11e58 100644 --- a/src/agent_context.py +++ b/src/agent_context.py @@ -9,10 +9,14 @@ from functools import lru_cache from pathlib import Path from .agent_plugin_cache import load_plugin_cache_summary +from .account_runtime import AccountRuntime +from .config_runtime import ConfigRuntime from .hook_policy import HookPolicyRuntime from .mcp_runtime import MCPRuntime from .plan_runtime import PlanRuntime from .plugin_runtime import PluginRuntime +from .remote_runtime import RemoteRuntime +from .search_runtime import SearchRuntime from .task_runtime import TaskRuntime from .agent_types import AgentRuntimeConfig @@ -224,6 +228,18 @@ def _get_user_context_cached( mcp_runtime = MCPRuntime.from_workspace(Path(cwd), additional_working_directories) if mcp_runtime.resources: context['mcpRuntime'] = mcp_runtime.render_summary() + remote_runtime = RemoteRuntime.from_workspace(Path(cwd), additional_working_directories) + if remote_runtime.has_remote_config(): + context['remoteRuntime'] = remote_runtime.render_summary() + search_runtime = SearchRuntime.from_workspace(Path(cwd), additional_working_directories) + if search_runtime.has_search_runtime(): + context['searchRuntime'] = search_runtime.render_summary() + account_runtime = AccountRuntime.from_workspace(Path(cwd), additional_working_directories) + if account_runtime.has_account_state(): + context['accountRuntime'] = account_runtime.render_summary() + config_runtime = ConfigRuntime.from_workspace(Path(cwd)) + if config_runtime.has_config(): + context['configRuntime'] = config_runtime.render_summary() plan_runtime = PlanRuntime.from_workspace(Path(cwd)) if plan_runtime.steps: context['planRuntime'] = plan_runtime.render_summary() diff --git a/src/agent_context_usage.py b/src/agent_context_usage.py index 2c868e3..c2337ba 100644 --- a/src/agent_context_usage.py +++ b/src/agent_context_usage.py @@ -1,12 +1,12 @@ from __future__ import annotations import json -import math import re from dataclasses import dataclass from .agent_prompting import SYSTEM_PROMPT_DYNAMIC_BOUNDARY from .agent_session import AgentMessage, AgentSessionState +from .tokenizer_runtime import describe_token_counter, count_tokens _PATH_HEADER_RE = re.compile(r'^## ((?:/|[A-Za-z]:[\\/]).+)$', re.MULTILINE) @@ -48,12 +48,13 @@ class ContextUsageReport: system_context_entries: tuple[UsageEntry, ...] memory_files: tuple[UsageEntry, ...] message_breakdown: MessageBreakdown + token_counter_backend: str + token_counter_source: str + token_counter_accurate: bool -def estimate_tokens(text: str) -> int: - if not text: - return 0 - return max(1, math.ceil(len(text) / 4)) +def estimate_tokens(text: str, model: str | None = None) -> int: + return count_tokens(text, model) def infer_context_window(model: str) -> int: @@ -78,21 +79,23 @@ def collect_context_usage( strategy: str, ) -> ContextUsageReport: raw_max_tokens = infer_context_window(model) + token_counter = describe_token_counter(model) + count = lambda text: estimate_tokens(text, model) # noqa: E731 system_prompt_sections = tuple( - UsageEntry(name=_section_name(part, idx), tokens=estimate_tokens(part)) + UsageEntry(name=_section_name(part, idx), tokens=count(part)) for idx, part in enumerate(session.system_prompt_parts, start=1) ) system_context_entries = tuple( - UsageEntry(name=key, tokens=estimate_tokens(f'{key}: {value}')) + UsageEntry(name=key, tokens=count(f'{key}: {value}')) for key, value in session.system_context.items() if value ) user_context_entries = tuple( - UsageEntry(name=key, tokens=estimate_tokens(_render_user_context_chunk(key, value))) + UsageEntry(name=key, tokens=count(_render_user_context_chunk(key, value))) for key, value in session.user_context.items() if value ) - memory_files = tuple(_parse_memory_usage(session.user_context.get('claudeMd'))) + memory_files = tuple(_parse_memory_usage(session.user_context.get('claudeMd'), model=model)) user_context_tokens = sum(entry.tokens for entry in user_context_entries) system_prompt_tokens = ( @@ -112,20 +115,20 @@ def collect_context_usage( if _is_user_context_message(session, index, message): continue if message.role == 'user': - conversation_user_tokens += estimate_tokens(message.content) + conversation_user_tokens += count(message.content) continue if message.role == 'assistant': - assistant_tokens += estimate_tokens(message.content) + assistant_tokens += count(message.content) for tool_call in message.tool_calls: serialized = json.dumps(tool_call, ensure_ascii=True) - tokens = estimate_tokens(serialized) + tokens = count(serialized) tool_call_tokens += tokens tool_name = _extract_tool_call_name(tool_call) call_totals = tool_usage.setdefault(tool_name, [0, 0]) call_totals[0] += tokens continue if message.role == 'tool': - tokens = estimate_tokens(message.content) + tokens = count(message.content) tool_result_tokens += tokens result_totals = tool_usage.setdefault(message.name or 'tool', [0, 0]) result_totals[1] += tokens @@ -176,6 +179,9 @@ def collect_context_usage( user_context_tokens=user_context_tokens, tool_calls_by_type=tool_calls_by_type, ), + token_counter_backend=token_counter.backend, + token_counter_source=token_counter.source, + token_counter_accurate=token_counter.accurate, ) @@ -185,6 +191,7 @@ def format_context_usage(report: ContextUsageReport) -> str: '', f'**Model:** {report.model} ', f'**Estimated tokens:** {_format_tokens(report.total_tokens)} / {_format_tokens(report.raw_max_tokens)} ({report.percentage:.1f}%) ', + f'**Token counter:** {report.token_counter_backend} ({report.token_counter_source}){" [accurate]" if report.token_counter_accurate else " [fallback]"} ', f'**Context strategy:** {report.strategy} ', f'**Messages in session:** {report.message_count}', '', @@ -330,7 +337,7 @@ def _is_user_context_message( ) -def _parse_memory_usage(claude_md: str | None) -> list[UsageEntry]: +def _parse_memory_usage(claude_md: str | None, *, model: str | None = None) -> list[UsageEntry]: if not claude_md: return [] matches = list(_PATH_HEADER_RE.finditer(claude_md)) @@ -341,7 +348,7 @@ def _parse_memory_usage(claude_md: str | None) -> list[UsageEntry]: start = match.end() end = matches[idx + 1].start() if idx + 1 < len(matches) else len(claude_md) content = claude_md[start:end].strip() - entries.append(UsageEntry(name=match.group(1), tokens=estimate_tokens(content))) + entries.append(UsageEntry(name=match.group(1), tokens=estimate_tokens(content, model))) return entries diff --git a/src/agent_prompting.py b/src/agent_prompting.py index be77493..4c98c5c 100644 --- a/src/agent_prompting.py +++ b/src/agent_prompting.py @@ -92,6 +92,10 @@ def build_system_prompt_parts( get_using_your_tools_section(enabled_tool_names), get_plugin_guidance_section(prompt_context), get_mcp_guidance_section(prompt_context), + get_remote_guidance_section(prompt_context), + get_search_guidance_section(prompt_context), + get_account_guidance_section(prompt_context), + get_config_guidance_section(prompt_context), get_plan_guidance_section(prompt_context), get_task_guidance_section(prompt_context), get_hook_policy_guidance_section(prompt_context), @@ -229,13 +233,62 @@ def get_mcp_guidance_section(prompt_context: PromptContext) -> str: if not mcp_runtime: return '' items = [ - 'Local MCP manifests may expose additional resources through the runtime.', + 'Local MCP manifests may expose additional resources and transport-backed tools through the runtime.', 'Use MCP resource tools when the task depends on manifest-backed external context or curated workspace resources.', - 'Treat MCP resource summaries as discoverability hints and prefer reading the specific resource URI before relying on its contents.', + 'Use MCP transport tools when a configured MCP server exposes real callable tools that should stay outside the local Python tool registry.', + 'Treat MCP resource and tool summaries as discoverability hints and prefer reading a specific resource URI or calling a specific MCP tool before relying on its contents.', ] return '\n'.join(['# MCP', *prepend_bullets(items)]) +def get_remote_guidance_section(prompt_context: PromptContext) -> str: + remote_runtime = prompt_context.user_context.get('remoteRuntime') + if not remote_runtime: + return '' + items = [ + 'Local remote manifests or an active remote connection may be available in the workspace context.', + 'Use remote status or remote-connect flows before assuming a specific remote target is active.', + 'Treat remote summaries as runtime state for the current workspace, including active target, session URL, and remote workspace path when present.', + ] + return '\n'.join(['# Remote', *prepend_bullets(items)]) + + +def get_search_guidance_section(prompt_context: PromptContext) -> str: + search_runtime = prompt_context.user_context.get('searchRuntime') + if not search_runtime: + return '' + items = [ + 'Local workspace web-search providers may be available through the runtime.', + 'Use the web_search tool when the task requires discovering external pages rather than fetching a known URL directly.', + 'Use web_fetch after web_search when you need to inspect the contents of a selected result page.', + ] + return '\n'.join(['# Search', *prepend_bullets(items)]) + + +def get_account_guidance_section(prompt_context: PromptContext) -> str: + account_runtime = prompt_context.user_context.get('accountRuntime') + if not account_runtime: + return '' + items = [ + 'Local workspace account or auth state may be available through the runtime.', + 'Use account tools and account slash commands when the task depends on local login state, configured profiles, or auth metadata.', + 'Treat local account summaries as workspace runtime state, including active identity, configured profiles, and visible credential env vars.', + ] + return '\n'.join(['# Account', *prepend_bullets(items)]) + + +def get_config_guidance_section(prompt_context: PromptContext) -> str: + config_runtime = prompt_context.user_context.get('configRuntime') + if not config_runtime: + return '' + items = [ + 'Local workspace config and settings files may be available through the runtime.', + 'Use config tools instead of ad-hoc file edits when the task is specifically about settings or config state.', + 'Treat the effective config as merged workspace state, and inspect the specific source when override order matters.', + ] + return '\n'.join(['# Config', *prepend_bullets(items)]) + + def get_task_guidance_section(prompt_context: PromptContext) -> str: task_runtime = prompt_context.user_context.get('taskRuntime') if not task_runtime: @@ -244,6 +297,7 @@ def get_task_guidance_section(prompt_context: PromptContext) -> str: 'A local runtime task list may be available to track ongoing work.', 'Use task and todo tools to keep the plan state current when the task spans multiple steps or files.', 'Prefer updating the stored task list instead of repeating the same progress summary in free-form text.', + 'Use task_next and the richer task state tools when dependencies or blocked work matter.', ] return '\n'.join(['# Tasks', *prepend_bullets(items)]) diff --git a/src/agent_runtime.py b/src/agent_runtime.py index c2140e8..013f118 100644 --- a/src/agent_runtime.py +++ b/src/agent_runtime.py @@ -7,9 +7,12 @@ from pathlib import Path from typing import Any from uuid import uuid4 +from .account_runtime import AccountRuntime from .agent_manager import AgentManager +from .agent_context import clear_context_caches from .agent_context import render_context_report as render_agent_context_report from .agent_context_usage import collect_context_usage, estimate_tokens, format_context_usage +from .config_runtime import ConfigRuntime from .hook_policy import HookPolicyRuntime from .mcp_runtime import MCPRuntime from .agent_prompting import ( @@ -42,7 +45,10 @@ from .agent_types import ( from .openai_compat import OpenAICompatClient, OpenAICompatError from .plan_runtime import PlanRuntime from .plugin_runtime import PluginRuntime +from .remote_runtime import RemoteRuntime +from .search_runtime import SearchRuntime from .task_runtime import TaskRuntime +from .tokenizer_runtime import describe_token_counter from .session_store import ( StoredAgentSession, load_agent_session, @@ -75,6 +81,10 @@ class LocalCodingAgent: plugin_runtime: PluginRuntime | None = None hook_policy_runtime: HookPolicyRuntime | None = None mcp_runtime: MCPRuntime | None = None + remote_runtime: RemoteRuntime | None = None + search_runtime: SearchRuntime | None = None + account_runtime: AccountRuntime | None = None + config_runtime: ConfigRuntime | None = None plan_runtime: PlanRuntime | None = None task_runtime: TaskRuntime | None = None last_session: AgentSessionState | None = field(default=None, init=False, repr=False) @@ -104,6 +114,23 @@ class LocalCodingAgent: self.runtime_config.cwd, tuple(str(path) for path in self.runtime_config.additional_working_directories), ) + if self.remote_runtime is None: + self.remote_runtime = RemoteRuntime.from_workspace( + self.runtime_config.cwd, + tuple(str(path) for path in self.runtime_config.additional_working_directories), + ) + if self.search_runtime is None: + self.search_runtime = SearchRuntime.from_workspace( + self.runtime_config.cwd, + tuple(str(path) for path in self.runtime_config.additional_working_directories), + ) + if self.account_runtime is None: + self.account_runtime = AccountRuntime.from_workspace( + self.runtime_config.cwd, + tuple(str(path) for path in self.runtime_config.additional_working_directories), + ) + if self.config_runtime is None: + self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd) if self.plan_runtime is None: self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd) if self.task_runtime is None: @@ -120,12 +147,17 @@ class LocalCodingAgent: self.client = OpenAICompatClient(self.model_config) self.tool_context = build_tool_context( self.runtime_config, + tool_registry=self.tool_registry, extra_env=( self.hook_policy_runtime.safe_env() if self.hook_policy_runtime is not None else None ), + search_runtime=self.search_runtime, + account_runtime=self.account_runtime, + config_runtime=self.config_runtime, mcp_runtime=self.mcp_runtime, + remote_runtime=self.remote_runtime, plan_runtime=self.plan_runtime, task_runtime=self.task_runtime, ) @@ -919,6 +951,7 @@ class LocalCodingAgent: 'preflight_count': len(plugin_preflight_messages), } ) + self._refresh_runtime_views_for_tool_result(tool_call.name, tool_result) history_entry = self._build_file_history_entry( tool_call=tool_call, tool_result=tool_result, @@ -1270,9 +1303,9 @@ class LocalCodingAgent: if current_total <= target_tokens and not reactive: break message = session.messages[index] - original_tokens = estimate_tokens(message.content) + original_tokens = estimate_tokens(message.content, self.model_config.model) replacement = self._build_snipped_message_content(message) - replacement_tokens = estimate_tokens(replacement) + replacement_tokens = estimate_tokens(replacement, self.model_config.model) if replacement_tokens >= original_tokens: continue session.tombstone_message( @@ -2832,28 +2865,194 @@ class LocalCodingAgent: return '# Memory\n\nNo CLAUDE.md memory files are currently loaded.' return '\n'.join(['# Memory', '', claude_md]) + def render_account_report(self, profile: str | None = None) -> str: + if self.account_runtime is None: + return '# Account\n\nNo local account runtime is available.' + if profile: + return self.account_runtime.render_profile(profile) + return '\n'.join(['# Account', '', self.account_runtime.render_summary()]) + + def render_search_report( + self, + query: str | None = None, + *, + provider: str | None = None, + max_results: int = 5, + domains: tuple[str, ...] = (), + ) -> str: + if self.search_runtime is None or not self.search_runtime.has_search_runtime(): + return ( + '# Search\n\nNo local search provider is available. ' + 'Add a .claw-search.json or .claude/search.json manifest, ' + 'or set SEARXNG_BASE_URL, BRAVE_SEARCH_API_KEY, or TAVILY_API_KEY.' + ) + if query: + try: + return self.search_runtime.render_search_results( + query, + provider_name=provider, + max_results=max_results, + domains=domains, + timeout_seconds=self.runtime_config.command_timeout_seconds, + ) + except (KeyError, LookupError, OSError, ValueError) as exc: + return f'# Search\n\nSearch failed: {exc}' + if provider: + return self.search_runtime.render_provider(provider) + return '\n'.join(['# Search', '', self.search_runtime.render_summary()]) + + def render_search_providers_report(self, query: str | None = None) -> str: + if self.search_runtime is None or not self.search_runtime.has_search_runtime(): + return '# Search Providers\n\nNo local search providers discovered.' + return self.search_runtime.render_providers_index(query=query) + + def render_search_activate_report(self, provider: str) -> str: + if self.search_runtime is None or not self.search_runtime.has_search_runtime(): + return '# Search\n\nNo local search provider is available.' + try: + report = self.search_runtime.activate_provider(provider) + except KeyError: + return f'# Search\n\nUnknown search provider: {provider}' + clear_context_caches() + self.tool_context = replace( + self.tool_context, + search_runtime=self.search_runtime, + ) + return '\n'.join(['# Search', '', report.as_text()]) + + def render_account_profiles_report(self, query: str | None = None) -> str: + if self.account_runtime is None: + return '# Account Profiles\n\nNo local account runtime is available.' + return self.account_runtime.render_profiles_index(query=query) + + def render_account_login_report( + self, + target: str, + *, + provider: str | None = None, + auth_mode: str | None = None, + ) -> str: + if self.account_runtime is None: + return '# Account\n\nNo local account runtime is available.' + report = self.account_runtime.login(target, provider=provider, auth_mode=auth_mode) + clear_context_caches() + return '\n'.join(['# Account', '', report.as_text()]) + + def render_account_logout_report(self) -> str: + if self.account_runtime is None: + return '# Account\n\nNo local account runtime is available.' + report = self.account_runtime.logout(reason='slash_or_cli_logout') + clear_context_caches() + return '\n'.join(['# Account', '', report.as_text()]) + + def render_config_report(self) -> str: + if self.config_runtime is None: + return '# Config\n\nNo local config runtime is available.' + return '\n'.join(['# Config', '', self.config_runtime.render_summary()]) + + def render_config_effective_report(self) -> str: + if self.config_runtime is None: + return '# Config Effective\n\nNo local config runtime is available.' + return '\n'.join(['# Config Effective', '', self.config_runtime.render_effective_config()]) + + def render_config_source_report(self, source: str) -> str: + if self.config_runtime is None: + return '# Config Source\n\nNo local config runtime is available.' + return '\n'.join(['# Config Source', '', self.config_runtime.render_source(source)]) + + def render_config_value_report(self, key_path: str, source: str | None = None) -> str: + if self.config_runtime is None: + return '# Config Value\n\nNo local config runtime is available.' + try: + rendered = self.config_runtime.render_value(key_path, source=source) + except KeyError as exc: + label = source if source is not None else key_path + return f'# Config Value\n\nUnknown config key or source: {label or exc.args[0]}' + return '\n'.join(['# Config Value', '', rendered]) + def render_mcp_report(self, query: str | None = None) -> str: if self.mcp_runtime is None: - return '# MCP\n\nNo local MCP manifests or resources discovered.' + return '# MCP\n\nNo local MCP manifests, servers, or resources discovered.' if query: return self.mcp_runtime.render_resource_index(query=query) return '\n'.join(['# MCP', '', self.mcp_runtime.render_summary()]) + def render_remote_report(self, target: str | None = None) -> str: + if self.remote_runtime is None: + return '# Remote\n\nNo local remote runtime is available.' + if target: + report = self.remote_runtime.connect(target) + clear_context_caches() + return '\n'.join(['# Remote', '', report.as_text()]) + return '\n'.join(['# Remote', '', self.remote_runtime.render_summary()]) + + def render_remote_mode_report(self, target: str, *, mode: str) -> str: + if self.remote_runtime is None: + return '# Remote\n\nNo local remote runtime is available.' + report = self.remote_runtime.connect(target, mode=mode) + clear_context_caches() + return '\n'.join(['# Remote', '', report.as_text()]) + + def render_remote_profiles_report(self, query: str | None = None) -> str: + if self.remote_runtime is None: + return '# Remote Profiles\n\nNo local remote runtime is available.' + return self.remote_runtime.render_profiles_index(query=query) + + def render_remote_disconnect_report(self) -> str: + if self.remote_runtime is None: + return '# Remote\n\nNo local remote runtime is available.' + report = self.remote_runtime.disconnect() + clear_context_caches() + return '\n'.join(['# Remote', '', report.as_text()]) + def render_mcp_resources_report(self, query: str | None = None) -> str: if self.mcp_runtime is None: - return '# MCP Resources\n\nNo local MCP manifests or resources discovered.' + return '# MCP Resources\n\nNo local MCP manifests, servers, or resources discovered.' return self.mcp_runtime.render_resource_index(query=query) def render_mcp_resource_report(self, uri: str) -> str: if self.mcp_runtime is None: - return '# MCP Resource\n\nNo local MCP manifests or resources discovered.' + return '# MCP Resource\n\nNo local MCP manifests, servers, or resources discovered.' return self.mcp_runtime.render_resource(uri) + def render_mcp_tools_report( + self, + query: str | None = None, + *, + server: str | None = None, + ) -> str: + if self.mcp_runtime is None: + return '# MCP Tools\n\nNo local MCP manifests, servers, or resources discovered.' + return self.mcp_runtime.render_tool_index(query=query, server_name=server) + + def render_mcp_call_tool_report( + self, + tool_name: str, + *, + arguments: dict[str, Any] | None = None, + server: str | None = None, + ) -> str: + if self.mcp_runtime is None: + return '# MCP Tool Result\n\nNo local MCP manifests, servers, or resources discovered.' + try: + return self.mcp_runtime.render_tool_call( + tool_name, + arguments=arguments, + server_name=server, + ) + except FileNotFoundError as exc: + return f'# MCP Tool Result\n\n{exc}' + def render_tasks_report(self, status: str | None = None) -> str: if self.task_runtime is None: return '# Tasks\n\nNo local task runtime is available.' return self.task_runtime.render_tasks(status=status) + def render_next_tasks_report(self) -> str: + if self.task_runtime is None: + return '# Next Tasks\n\nNo local task runtime is available.' + return self.task_runtime.render_next_tasks() + def render_plan_report(self) -> str: if self.plan_runtime is None: return '# Plan\n\nNo local plan runtime is available.' @@ -2891,10 +3090,12 @@ class LocalCodingAgent: return '\n'.join(lines) def render_status_report(self) -> str: + token_counter = describe_token_counter(self.model_config.model) lines = [ '# Status', '', f'- Model: {self.model_config.model}', + f'- Token counter: {token_counter.backend} ({token_counter.source})', f'- Registered tools: {len(self.tool_registry)}', f'- Streaming model responses: {self.runtime_config.stream_model_responses}', f'- Session ID: {self.active_session_id or "none"}', @@ -2904,8 +3105,37 @@ class LocalCodingAgent: lines.append( f'- Workspace trust mode: {"trusted" if self.hook_policy_runtime.is_trusted() else "untrusted"}' ) - if self.mcp_runtime is not None and self.mcp_runtime.resources: - lines.append(f'- MCP resources: {len(self.mcp_runtime.resources)}') + if self.mcp_runtime is not None: + if self.mcp_runtime.resources: + lines.append(f'- MCP local resources: {len(self.mcp_runtime.resources)}') + if self.mcp_runtime.servers: + lines.append(f'- MCP servers: {len(self.mcp_runtime.servers)}') + if self.remote_runtime is not None and self.remote_runtime.has_remote_config(): + lines.append(f'- Remote profiles: {len(self.remote_runtime.profiles)}') + if self.remote_runtime.active_connection is not None: + connection = self.remote_runtime.active_connection + lines.append( + f'- Active remote: {connection.mode} -> {connection.target}' + ) + if self.search_runtime is not None and self.search_runtime.has_search_runtime(): + lines.append(f'- Search providers: {len(self.search_runtime.providers)}') + active_provider = self.search_runtime.current_provider() + if active_provider is not None: + lines.append( + f'- Active search provider: {active_provider.name} ({active_provider.provider})' + ) + if self.account_runtime is not None and self.account_runtime.has_account_state(): + lines.append(f'- Account profiles: {len(self.account_runtime.profiles)}') + if self.account_runtime.active_session is not None: + session = self.account_runtime.active_session + lines.append( + f'- Active account: {session.provider} -> {session.identity}' + ) + if self.config_runtime is not None and self.config_runtime.has_config(): + lines.append(f'- Config sources: {len(self.config_runtime.sources)}') + lines.append( + f'- Effective config keys: {len(self.config_runtime.list_keys())}' + ) if self.plan_runtime is not None and self.plan_runtime.steps: lines.append(f'- Local plan steps: {len(self.plan_runtime.steps)}') if self.task_runtime is not None and self.task_runtime.tasks: @@ -2945,6 +3175,68 @@ class LocalCodingAgent: ) self.resume_source_session_id = None + def _refresh_runtime_views_for_tool_result( + self, + tool_name: str, + tool_result: ToolExecutionResult, + ) -> None: + if not tool_result.ok: + return + refresh_tool_names = { + 'update_plan', + 'plan_clear', + 'task_create', + 'task_update', + 'task_start', + 'task_complete', + 'task_block', + 'task_cancel', + 'todo_write', + 'search_activate_provider', + 'remote_connect', + 'remote_disconnect', + 'account_login', + 'account_logout', + 'config_set', + } + if tool_name not in refresh_tool_names: + return + clear_context_caches() + additional_dirs = tuple( + str(path) for path in self.runtime_config.additional_working_directories + ) + if tool_name.startswith('remote_'): + self.remote_runtime = RemoteRuntime.from_workspace( + self.runtime_config.cwd, + additional_working_directories=additional_dirs, + ) + if tool_name.startswith('search_'): + self.search_runtime = SearchRuntime.from_workspace( + self.runtime_config.cwd, + additional_working_directories=additional_dirs, + ) + if tool_name.startswith('account_'): + self.account_runtime = AccountRuntime.from_workspace( + self.runtime_config.cwd, + additional_working_directories=additional_dirs, + ) + if tool_name == 'config_set': + self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd) + if tool_name.startswith('task_') or tool_name == 'todo_write': + self.task_runtime = TaskRuntime.from_workspace(self.runtime_config.cwd) + if tool_name.startswith('plan_') or tool_name == 'update_plan': + self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd) + self.tool_context = replace( + self.tool_context, + tool_registry=self.tool_registry, + search_runtime=self.search_runtime, + account_runtime=self.account_runtime, + config_runtime=self.config_runtime, + remote_runtime=self.remote_runtime, + plan_runtime=self.plan_runtime, + task_runtime=self.task_runtime, + ) + def _apply_plugin_before_prompt_hooks(self, prompt: str) -> str: if self.plugin_runtime is None: return prompt diff --git a/src/agent_slash_commands.py b/src/agent_slash_commands.py index 351bb6e..9be00f7 100644 --- a/src/agent_slash_commands.py +++ b/src/agent_slash_commands.py @@ -114,6 +114,66 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]: description='Show discovered local MCP manifests and resource counts.', handler=_handle_mcp, ), + SlashCommandSpec( + names=('search',), + description='Show search runtime status, list or activate providers, or run a real web search query.', + handler=_handle_search, + ), + SlashCommandSpec( + names=('remote',), + description='Show local remote runtime status or activate a remote target/profile.', + handler=_handle_remote, + ), + SlashCommandSpec( + names=('account',), + description='Show local account runtime status or configured account profiles.', + handler=_handle_account, + ), + SlashCommandSpec( + names=('login',), + description='Activate a local account profile or ephemeral identity.', + handler=_handle_login, + ), + SlashCommandSpec( + names=('logout',), + description='Clear the active local account session.', + handler=_handle_logout, + ), + SlashCommandSpec( + names=('config', 'settings'), + description='Show local config runtime state, effective config, config sources, or a config value.', + handler=_handle_config, + ), + SlashCommandSpec( + names=('remotes',), + description='List configured local remote profiles.', + handler=_handle_remotes, + ), + SlashCommandSpec( + names=('ssh',), + description='Activate a local SSH remote target/profile.', + handler=_handle_ssh, + ), + SlashCommandSpec( + names=('teleport',), + description='Activate a local teleport remote target/profile.', + handler=_handle_teleport, + ), + SlashCommandSpec( + names=('direct-connect',), + description='Activate a local direct-connect remote target/profile.', + handler=_handle_direct_connect, + ), + SlashCommandSpec( + names=('deep-link',), + description='Activate a local deep-link remote target/profile.', + handler=_handle_deep_link, + ), + SlashCommandSpec( + names=('disconnect', 'remote-disconnect'), + description='Disconnect the active local remote runtime target.', + handler=_handle_remote_disconnect, + ), SlashCommandSpec( names=('resources',), description='List local MCP resources, optionally filtered by a query string.', @@ -129,6 +189,11 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]: description='Show the local runtime task list, optionally filtered by status.', handler=_handle_tasks, ), + SlashCommandSpec( + names=('task-next', 'next-task'), + description='Show the next actionable tasks from the local runtime task list.', + handler=_handle_task_next, + ), SlashCommandSpec( names=('plan', 'planner'), description='Show the current local runtime plan.', @@ -221,8 +286,131 @@ def _handle_context_raw(agent: 'LocalCodingAgent', _args: str, input_text: str) def _handle_mcp(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + command = args.strip() + if not command: + return _local_result(input_text, agent.render_mcp_report()) + if command == 'tools': + return _local_result(input_text, agent.render_mcp_tools_report()) + if command.startswith('tools '): + query = command.split(' ', 1)[1].strip() + return _local_result(input_text, agent.render_mcp_tools_report(query or None)) + if command.startswith('tool '): + tool_name = command.split(' ', 1)[1].strip() + if not tool_name: + return _local_result(input_text, 'Usage: /mcp tool ') + 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 ') + 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 ') + 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 ') + return _local_result(input_text, agent.render_account_report(profile)) + return _local_result(input_text, 'Usage: /account [profiles|profile ]') + + +def _handle_login(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + target = args.strip() + if not target: + return _local_result(input_text, 'Usage: /login ') + 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 ') + 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 ') + return _local_result(input_text, agent.render_config_value_report(key_path)) + return _local_result( + input_text, + 'Usage: /config [effective|source |get ]', + ) + + +def _handle_remotes(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: query = args or None - return _local_result(input_text, agent.render_mcp_report(query)) + return _local_result(input_text, agent.render_remote_profiles_report(query)) + + +def _handle_ssh(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + if not args: + return _local_result(input_text, 'Usage: /ssh ') + 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 ') + 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 ') + 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 ') + return _local_result(input_text, agent.render_remote_mode_report(args, mode='deep-link')) + + +def _handle_remote_disconnect( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + return _local_result(input_text, agent.render_remote_disconnect_report()) def _handle_resources(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: @@ -241,6 +429,10 @@ def _handle_tasks(agent: 'LocalCodingAgent', args: str, input_text: str) -> Slas return _local_result(input_text, agent.render_tasks_report(status)) +def _handle_task_next(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult: + return _local_result(input_text, agent.render_next_tasks_report()) + + def _handle_plan(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult: return _local_result(input_text, agent.render_plan_report()) diff --git a/src/agent_tools.py b/src/agent_tools.py index 8fbd01a..0a9ddac 100644 --- a/src/agent_tools.py +++ b/src/agent_tools.py @@ -7,6 +7,9 @@ import re import selectors import subprocess import time +import urllib.error +import urllib.parse +import urllib.request from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Iterator, Union @@ -14,8 +17,12 @@ from typing import TYPE_CHECKING, Any, Callable, Iterator, Union from .agent_types import AgentPermissions, AgentRuntimeConfig, ToolExecutionResult if TYPE_CHECKING: + from .account_runtime import AccountRuntime + from .config_runtime import ConfigRuntime from .mcp_runtime import MCPRuntime from .plan_runtime import PlanRuntime + from .remote_runtime import RemoteRuntime + from .search_runtime import SearchRuntime from .task_runtime import TaskRuntime @@ -34,7 +41,12 @@ class ToolExecutionContext: max_output_chars: int permissions: AgentPermissions extra_env: dict[str, str] = field(default_factory=dict) + tool_registry: dict[str, 'AgentTool'] | None = None + search_runtime: 'SearchRuntime | None' = None + account_runtime: 'AccountRuntime | None' = None + config_runtime: 'ConfigRuntime | None' = None mcp_runtime: 'MCPRuntime | None' = None + remote_runtime: 'RemoteRuntime | None' = None plan_runtime: 'PlanRuntime | None' = None task_runtime: 'TaskRuntime | None' = None @@ -99,7 +111,12 @@ def build_tool_context( config: AgentRuntimeConfig, *, extra_env: dict[str, str] | None = None, + tool_registry: dict[str, AgentTool] | None = None, + search_runtime: 'SearchRuntime | None' = None, + account_runtime: 'AccountRuntime | None' = None, + config_runtime: 'ConfigRuntime | None' = None, mcp_runtime: 'MCPRuntime | None' = None, + remote_runtime: 'RemoteRuntime | None' = None, plan_runtime: 'PlanRuntime | None' = None, task_runtime: 'TaskRuntime | None' = None, ) -> ToolExecutionContext: @@ -109,7 +126,12 @@ def build_tool_context( max_output_chars=config.max_output_chars, permissions=config.permissions, extra_env=dict(extra_env or {}), + tool_registry=tool_registry, + search_runtime=search_runtime, + account_runtime=account_runtime, + config_runtime=config_runtime, mcp_runtime=mcp_runtime, + remote_runtime=remote_runtime, plan_runtime=plan_runtime, task_runtime=task_runtime, ) @@ -255,6 +277,195 @@ def default_tool_registry() -> dict[str, AgentTool]: }, handler=_run_bash, ), + AgentTool( + name='web_fetch', + description='Fetch a text resource from http, https, or file URLs and return a truncated text response.', + parameters={ + 'type': 'object', + 'properties': { + 'url': {'type': 'string'}, + 'max_chars': {'type': 'integer', 'minimum': 1, 'maximum': 100000}, + }, + 'required': ['url'], + }, + handler=_web_fetch, + ), + AgentTool( + name='search_status', + description='Show the local search runtime summary or a specific configured search provider.', + parameters={ + 'type': 'object', + 'properties': { + 'provider': {'type': 'string'}, + }, + }, + handler=_search_status, + ), + AgentTool( + name='search_list_providers', + description='List configured local search providers from workspace search manifests and environment configuration.', + parameters={ + 'type': 'object', + 'properties': { + 'query': {'type': 'string'}, + 'max_providers': {'type': 'integer', 'minimum': 1, 'maximum': 200}, + }, + }, + handler=_search_list_providers, + ), + AgentTool( + name='search_activate_provider', + description='Set the active local search provider for the current workspace.', + parameters={ + 'type': 'object', + 'properties': { + 'provider': {'type': 'string'}, + }, + 'required': ['provider'], + }, + handler=_search_activate_provider, + ), + AgentTool( + name='web_search', + description='Run a real web search against a configured search backend and return ranked results.', + parameters={ + 'type': 'object', + 'properties': { + 'query': {'type': 'string'}, + 'provider': {'type': 'string'}, + 'domains': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + 'max_results': {'type': 'integer', 'minimum': 1, 'maximum': 20}, + }, + 'required': ['query'], + }, + handler=_web_search, + ), + AgentTool( + name='tool_search', + description='Search the active tool registry by tool name or description.', + parameters={ + 'type': 'object', + 'properties': { + 'query': {'type': 'string'}, + 'max_results': {'type': 'integer', 'minimum': 1, 'maximum': 100}, + }, + 'required': ['query'], + }, + handler=_tool_search, + ), + AgentTool( + name='sleep', + description='Pause execution briefly for bounded local wait flows.', + parameters={ + 'type': 'object', + 'properties': { + 'seconds': {'type': 'number', 'minimum': 0.0, 'maximum': 5.0}, + }, + 'required': ['seconds'], + }, + handler=_sleep, + ), + AgentTool( + name='account_status', + description='Show local account runtime summary or a specific configured account profile.', + parameters={ + 'type': 'object', + 'properties': { + 'profile': {'type': 'string'}, + }, + }, + handler=_account_status, + ), + AgentTool( + name='account_list_profiles', + description='List configured local account profiles from workspace account manifests.', + parameters={ + 'type': 'object', + 'properties': { + 'query': {'type': 'string'}, + 'max_profiles': {'type': 'integer', 'minimum': 1, 'maximum': 200}, + }, + }, + handler=_account_list_profiles, + ), + AgentTool( + name='account_login', + description='Activate a local account profile or ephemeral account identity and persist it as the active account session.', + parameters={ + 'type': 'object', + 'properties': { + 'target': {'type': 'string'}, + 'provider': {'type': 'string'}, + 'auth_mode': {'type': 'string'}, + }, + 'required': ['target'], + }, + handler=_account_login, + ), + AgentTool( + name='account_logout', + description='Clear the active local account session state.', + parameters={ + 'type': 'object', + 'properties': { + 'reason': {'type': 'string'}, + }, + }, + handler=_account_logout, + ), + AgentTool( + name='config_list', + description='List merged or source-specific workspace config keys from local settings files.', + parameters={ + 'type': 'object', + 'properties': { + 'source': {'type': 'string'}, + 'prefix': {'type': 'string'}, + 'limit': {'type': 'integer', 'minimum': 1, 'maximum': 500}, + }, + }, + handler=_config_list, + ), + AgentTool( + name='config_get', + description='Read a merged or source-specific workspace config value by dotted key path.', + parameters={ + 'type': 'object', + 'properties': { + 'key_path': {'type': 'string'}, + 'source': {'type': 'string'}, + }, + 'required': ['key_path'], + }, + handler=_config_get, + ), + AgentTool( + name='config_set', + description='Write a workspace config value by dotted key path into a chosen config source.', + parameters={ + 'type': 'object', + 'properties': { + 'key_path': {'type': 'string'}, + 'source': {'type': 'string'}, + 'value': { + 'oneOf': [ + {'type': 'string'}, + {'type': 'number'}, + {'type': 'integer'}, + {'type': 'boolean'}, + {'type': 'array'}, + {'type': 'object'}, + {'type': 'null'}, + ] + }, + }, + 'required': ['key_path', 'value'], + }, + handler=_config_set, + ), AgentTool( name='mcp_list_resources', description='List local MCP resources discovered from workspace MCP manifests.', @@ -280,6 +491,82 @@ def default_tool_registry() -> dict[str, AgentTool]: }, handler=_mcp_read_resource, ), + AgentTool( + name='mcp_list_tools', + description='List MCP tools exposed by configured MCP servers over real transport.', + parameters={ + 'type': 'object', + 'properties': { + 'query': {'type': 'string'}, + 'server': {'type': 'string'}, + 'max_tools': {'type': 'integer', 'minimum': 1, 'maximum': 200}, + }, + }, + handler=_mcp_list_tools, + ), + AgentTool( + name='mcp_call_tool', + description='Call an MCP tool exposed by a configured MCP server over real transport.', + parameters={ + 'type': 'object', + 'properties': { + 'tool_name': {'type': 'string'}, + 'server': {'type': 'string'}, + 'arguments': {'type': 'object'}, + 'max_chars': {'type': 'integer', 'minimum': 1, 'maximum': 50000}, + }, + 'required': ['tool_name'], + }, + handler=_mcp_call_tool, + ), + AgentTool( + name='remote_status', + description='Show the local remote runtime summary or a specific configured remote profile.', + parameters={ + 'type': 'object', + 'properties': { + 'profile': {'type': 'string'}, + }, + }, + handler=_remote_status, + ), + AgentTool( + name='remote_list_profiles', + description='List configured local remote profiles from workspace remote manifests.', + parameters={ + 'type': 'object', + 'properties': { + 'query': {'type': 'string'}, + 'mode': {'type': 'string'}, + 'max_profiles': {'type': 'integer', 'minimum': 1, 'maximum': 200}, + }, + }, + handler=_remote_list_profiles, + ), + AgentTool( + name='remote_connect', + description='Activate a local remote target or configured remote profile and persist it as the active connection.', + parameters={ + 'type': 'object', + 'properties': { + 'target': {'type': 'string'}, + 'mode': {'type': 'string'}, + }, + 'required': ['target'], + }, + handler=_remote_connect, + ), + AgentTool( + name='remote_disconnect', + description='Clear the active local remote connection state.', + parameters={ + 'type': 'object', + 'properties': { + 'reason': {'type': 'string'}, + }, + }, + handler=_remote_disconnect, + ), AgentTool( name='plan_get', description='Show the current local runtime plan.', @@ -307,6 +594,12 @@ def default_tool_registry() -> dict[str, AgentTool]: 'task_id': {'type': 'string'}, 'description': {'type': 'string'}, 'priority': {'type': 'string'}, + 'active_form': {'type': 'string'}, + 'owner': {'type': 'string'}, + 'depends_on': { + 'type': 'array', + 'items': {'type': 'string'}, + }, }, 'required': ['step'], }, @@ -327,6 +620,17 @@ def default_tool_registry() -> dict[str, AgentTool]: }, handler=_plan_clear, ), + AgentTool( + name='task_next', + description='Show the next actionable tasks from the local runtime task list.', + parameters={ + 'type': 'object', + 'properties': { + 'max_tasks': {'type': 'integer', 'minimum': 1, 'maximum': 50}, + }, + }, + handler=_task_next, + ), AgentTool( name='task_list', description='List locally stored runtime tasks.', @@ -334,6 +638,8 @@ def default_tool_registry() -> dict[str, AgentTool]: 'type': 'object', 'properties': { 'status': {'type': 'string'}, + 'owner': {'type': 'string'}, + 'actionable_only': {'type': 'boolean'}, 'max_tasks': {'type': 'integer', 'minimum': 1, 'maximum': 200}, }, }, @@ -362,6 +668,11 @@ def default_tool_registry() -> dict[str, AgentTool]: 'status': {'type': 'string'}, 'priority': {'type': 'string'}, 'task_id': {'type': 'string'}, + 'active_form': {'type': 'string'}, + 'owner': {'type': 'string'}, + 'blocks': {'type': 'array', 'items': {'type': 'string'}}, + 'blocked_by': {'type': 'array', 'items': {'type': 'string'}}, + 'metadata': {'type': 'object'}, }, 'required': ['title'], }, @@ -378,11 +689,69 @@ def default_tool_registry() -> dict[str, AgentTool]: 'description': {'type': 'string'}, 'status': {'type': 'string'}, 'priority': {'type': 'string'}, + 'active_form': {'type': 'string'}, + 'owner': {'type': 'string'}, + 'blocks': {'type': 'array', 'items': {'type': 'string'}}, + 'blocked_by': {'type': 'array', 'items': {'type': 'string'}}, + 'metadata': {'type': 'object'}, }, 'required': ['task_id'], }, handler=_task_update, ), + AgentTool( + name='task_start', + description='Mark a task as in progress, or blocked if dependencies are unresolved.', + parameters={ + 'type': 'object', + 'properties': { + 'task_id': {'type': 'string'}, + 'owner': {'type': 'string'}, + 'active_form': {'type': 'string'}, + }, + 'required': ['task_id'], + }, + handler=_task_start, + ), + AgentTool( + name='task_complete', + description='Mark a task as completed and release blocked dependents when possible.', + parameters={ + 'type': 'object', + 'properties': { + 'task_id': {'type': 'string'}, + }, + 'required': ['task_id'], + }, + handler=_task_complete, + ), + AgentTool( + name='task_block', + description='Mark a task as blocked with optional dependencies and reason.', + parameters={ + 'type': 'object', + 'properties': { + 'task_id': {'type': 'string'}, + 'blocked_by': {'type': 'array', 'items': {'type': 'string'}}, + 'reason': {'type': 'string'}, + }, + 'required': ['task_id'], + }, + handler=_task_block, + ), + AgentTool( + name='task_cancel', + description='Mark a task as cancelled with an optional reason.', + parameters={ + 'type': 'object', + 'properties': { + 'task_id': {'type': 'string'}, + 'reason': {'type': 'string'}, + }, + 'required': ['task_id'], + }, + handler=_task_cancel, + ), AgentTool( name='todo_write', description='Replace the current local runtime task list with a structured todo list.', @@ -399,6 +768,11 @@ def default_tool_registry() -> dict[str, AgentTool]: 'description': {'type': 'string'}, 'status': {'type': 'string'}, 'priority': {'type': 'string'}, + 'active_form': {'type': 'string'}, + 'owner': {'type': 'string'}, + 'blocks': {'type': 'array', 'items': {'type': 'string'}}, + 'blocked_by': {'type': 'array', 'items': {'type': 'string'}}, + 'metadata': {'type': 'object'}, }, 'required': ['title'], }, @@ -495,6 +869,13 @@ def _coerce_int(arguments: dict[str, Any], key: str, default: int) -> int: return value +def _coerce_float(arguments: dict[str, Any], key: str, default: float) -> float: + value = arguments.get(key, default) + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ToolExecutionError(f'{key} must be a number') + return float(value) + + def _resolve_path(raw_path: str, context: ToolExecutionContext, *, allow_missing: bool = True) -> Path: expanded = Path(raw_path).expanduser() candidate = expanded if expanded.is_absolute() else context.root / expanded @@ -741,6 +1122,295 @@ def _run_bash(arguments: dict[str, Any], context: ToolExecutionContext) -> str: ) +def _web_fetch(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + raw_url = _require_string(arguments, 'url') + max_chars = _coerce_int(arguments, 'max_chars', context.max_output_chars) + parsed = urllib.parse.urlparse(raw_url) + if parsed.scheme not in {'http', 'https', 'file'}: + raise ToolExecutionError('url must use http, https, or file scheme') + request = urllib.request.Request( + raw_url, + headers={'User-Agent': 'claw-code-agent/1.0'}, + ) + try: + with urllib.request.urlopen(request, timeout=context.command_timeout_seconds) as response: + raw_bytes = response.read(max_chars + 1) + content_type = response.headers.get_content_type() if hasattr(response, 'headers') else None + text = raw_bytes.decode('utf-8', errors='replace') + except (urllib.error.URLError, OSError) as exc: + raise ToolExecutionError(f'Failed to fetch {raw_url}: {exc}') from exc + truncated = len(text) > max_chars + rendered = text[:max_chars] + if truncated: + rendered += '\n...[truncated]...' + return ( + _truncate_output(rendered, context.max_output_chars), + { + 'action': 'web_fetch', + 'url': raw_url, + 'content_type': content_type, + 'truncated': truncated, + 'fetched_chars': len(text), + 'preview': _snapshot_text(rendered), + }, + ) + + +def _search_status(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + runtime = _require_search_runtime(context) + provider = arguments.get('provider') + if provider is not None and not isinstance(provider, str): + raise ToolExecutionError('provider must be a string') + if provider: + return runtime.render_provider(provider) + return '\n'.join(['# Search', '', runtime.render_summary()]) + + +def _search_list_providers(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + runtime = _require_search_runtime(context) + query = arguments.get('query') + if query is not None and not isinstance(query, str): + raise ToolExecutionError('query must be a string') + max_providers = _coerce_int(arguments, 'max_providers', 20) + providers = runtime.list_providers(query=query, limit=max_providers) + lines = ['# Search Providers', ''] + if not providers: + lines.append('No local search providers discovered.') + return '\n'.join(lines) + current = runtime.current_provider() + current_name = current.name if current is not None else None + 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}') + if current_name == provider.name: + details.append('active=true') + lines.append('- ' + ' ; '.join(details)) + return '\n'.join(lines) + + +def _search_activate_provider( + arguments: dict[str, Any], + context: ToolExecutionContext, +) -> str: + runtime = _require_search_runtime(context) + provider = _require_string(arguments, 'provider') + try: + report = runtime.activate_provider(provider) + except KeyError as exc: + raise ToolExecutionError(f'Unknown search provider: {provider}') from exc + return ( + '\n'.join(['# Search', '', report.as_text()]), + { + 'action': 'search_activate_provider', + 'provider': report.provider_name, + 'provider_kind': report.provider_kind, + 'base_url': report.base_url, + }, + ) + + +def _web_search(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + runtime = _require_search_runtime(context) + query = _require_string(arguments, 'query') + provider = arguments.get('provider') + if provider is not None and not isinstance(provider, str): + raise ToolExecutionError('provider must be a string') + raw_domains = arguments.get('domains', ()) + if raw_domains is None: + raw_domains = () + if not isinstance(raw_domains, (list, tuple)): + raise ToolExecutionError('domains must be an array of strings') + domains: tuple[str, ...] = tuple( + item.strip() for item in raw_domains if isinstance(item, str) and item.strip() + ) + if len(domains) != len(raw_domains): + raise ToolExecutionError('domains must contain only non-empty strings') + max_results = arguments.get('max_results') + if max_results is None: + default_provider = runtime.get_provider(provider) if provider else runtime.current_provider() + max_results = default_provider.default_max_results if default_provider is not None else 5 + if isinstance(max_results, bool) or not isinstance(max_results, int): + raise ToolExecutionError('max_results must be an integer') + if max_results < 1 or max_results > 20: + raise ToolExecutionError('max_results must be between 1 and 20') + try: + selected_provider, results = runtime.search( + query, + provider_name=provider, + max_results=max_results, + domains=domains, + timeout_seconds=context.command_timeout_seconds, + ) + except KeyError as exc: + raise ToolExecutionError(f'Unknown search provider: {provider or exc.args[0]}') from exc + except LookupError as exc: + raise ToolExecutionError(str(exc)) from exc + except (ValueError, OSError, urllib.error.URLError) as exc: + raise ToolExecutionError(f'Web search failed: {exc}') from exc + lines = ['# Web Search', ''] + lines.append(f'- Provider: {selected_provider.name} ({selected_provider.provider})') + lines.append(f'- Query: {query}') + lines.append(f'- Results: {len(results)}') + if domains: + lines.append('- Domains: ' + ', '.join(domains)) + lines.append('') + if not results: + lines.append('No search results.') + else: + for result in results: + lines.append(f'{result.rank}. {result.title}') + lines.append(f' {result.url}') + if result.snippet: + lines.append(f' {result.snippet}') + rendered = '\n'.join(lines) + return ( + rendered, + { + 'action': 'web_search', + 'provider': selected_provider.name, + 'provider_kind': selected_provider.provider, + 'query': query, + 'result_count': len(results), + 'domains': list(domains), + 'top_urls': [result.url for result in results[:5]], + }, + ) + + +def _tool_search(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + query = _require_string(arguments, 'query').lower() + max_results = _coerce_int(arguments, 'max_results', 20) + registry = context.tool_registry or default_tool_registry() + matches: list[tuple[str, str]] = [] + for tool in registry.values(): + haystack = f'{tool.name} {tool.description}'.lower() + if query in haystack: + matches.append((tool.name, tool.description)) + if not matches: + return '(no matching tools)' + lines = ['# Tool Search', ''] + for name, description in matches[:max_results]: + lines.append(f'- `{name}`: {description}') + return '\n'.join(lines) + + +def _sleep(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + seconds = _coerce_float(arguments, 'seconds', 0.0) + if seconds < 0.0 or seconds > 5.0: + raise ToolExecutionError('seconds must be between 0.0 and 5.0') + time.sleep(seconds) + return ( + f'slept for {seconds:.3f} seconds', + { + 'action': 'sleep', + 'seconds': seconds, + }, + ) + + +def _account_status(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + runtime = _require_account_runtime(context) + profile = arguments.get('profile') + if profile is not None and not isinstance(profile, str): + raise ToolExecutionError('profile must be a string') + if profile: + return runtime.render_profile(profile) + return '\n'.join(['# Account', '', runtime.render_summary()]) + + +def _account_list_profiles(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + runtime = _require_account_runtime(context) + query = arguments.get('query') + if query is not None and not isinstance(query, str): + raise ToolExecutionError('query must be a string') + max_profiles = _coerce_int(arguments, 'max_profiles', 20) + profiles = runtime.list_profiles(query=query, limit=max_profiles) + 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 _account_login(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + runtime = _require_account_runtime(context) + target = _require_string(arguments, 'target') + provider = arguments.get('provider') + auth_mode = arguments.get('auth_mode') + if provider is not None and not isinstance(provider, str): + raise ToolExecutionError('provider must be a string') + if auth_mode is not None and not isinstance(auth_mode, str): + raise ToolExecutionError('auth_mode must be a string') + report = runtime.login(target, provider=provider, auth_mode=auth_mode) + return '\n'.join(['# Account', '', report.as_text()]) + + +def _account_logout(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + runtime = _require_account_runtime(context) + reason = arguments.get('reason', 'tool_logout') + if not isinstance(reason, str): + raise ToolExecutionError('reason must be a string') + report = runtime.logout(reason=reason) + return '\n'.join(['# Account', '', report.as_text()]) + + +def _config_list(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + runtime = _require_config_runtime(context) + source = arguments.get('source') + prefix = arguments.get('prefix') + if source is not None and not isinstance(source, str): + raise ToolExecutionError('source must be a string') + if prefix is not None and not isinstance(prefix, str): + raise ToolExecutionError('prefix must be a string') + limit = _coerce_int(arguments, 'limit', 100) + try: + rendered = runtime.render_keys(source=source, prefix=prefix, limit=limit) + except KeyError as exc: + raise ToolExecutionError(f'Unknown config source: {exc.args[0]}') from exc + return '\n'.join(['# Config Keys', '', rendered]) + + +def _config_get(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + runtime = _require_config_runtime(context) + key_path = _require_string(arguments, 'key_path') + source = arguments.get('source') + if source is not None and not isinstance(source, str): + raise ToolExecutionError('source must be a string') + try: + rendered = runtime.render_value(key_path, source=source) + except KeyError as exc: + raise ToolExecutionError(f'Unknown config key or source: {exc.args[0]}') from exc + return '\n'.join(['# Config Value', '', rendered]) + + +def _config_set(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + _ensure_write_allowed(context) + runtime = _require_config_runtime(context) + key_path = _require_string(arguments, 'key_path') + source = arguments.get('source', 'local') + if not isinstance(source, str): + raise ToolExecutionError('source must be a string') + if 'value' not in arguments: + raise ToolExecutionError('value is required') + value = arguments.get('value') + try: + mutation = runtime.set_value(key_path, value, source=source) + except KeyError as exc: + raise ToolExecutionError(f'Unknown config source: {exc.args[0]}') from exc + return ( + f'set {key_path} in {mutation.source_name} config', + _config_mutation_metadata(mutation, value), + ) + + def _mcp_list_resources(arguments: dict[str, Any], context: ToolExecutionContext) -> str: runtime = _require_mcp_runtime(context) query = arguments.get('query') @@ -774,13 +1444,153 @@ def _mcp_read_resource(arguments: dict[str, Any], context: ToolExecutionContext) return content +def _mcp_list_tools(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + runtime = _require_mcp_runtime(context) + query = arguments.get('query') + server = arguments.get('server') + if query is not None and not isinstance(query, str): + raise ToolExecutionError('query must be a string') + if server is not None and not isinstance(server, str): + raise ToolExecutionError('server must be a string') + max_tools = _coerce_int(arguments, 'max_tools', 50) + tools = runtime.list_tools(query=query, server_name=server, limit=max_tools) + if not tools: + return '(no MCP tools)' + lines: list[str] = [] + 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 _mcp_call_tool(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + runtime = _require_mcp_runtime(context) + tool_name = _require_string(arguments, 'tool_name') + server = arguments.get('server') + if server is not None and not isinstance(server, str): + raise ToolExecutionError('server must be a string') + raw_arguments = arguments.get('arguments', {}) + if raw_arguments is None: + raw_arguments = {} + if not isinstance(raw_arguments, dict): + raise ToolExecutionError('arguments must be an object') + max_chars = _coerce_int(arguments, 'max_chars', context.max_output_chars) + try: + content, metadata = runtime.call_tool( + tool_name, + arguments=raw_arguments, + server_name=server, + max_chars=max_chars, + ) + except FileNotFoundError as exc: + raise ToolExecutionError(str(exc)) from exc + return ( + content, + { + 'action': 'mcp_call_tool', + 'tool_name': tool_name, + 'server_name': metadata.get('server_name'), + 'mcp_is_error': metadata.get('is_error'), + }, + ) + + +def _remote_status( + arguments: dict[str, Any], + context: ToolExecutionContext, +) -> str: + runtime = _require_remote_runtime(context) + profile = arguments.get('profile') + if profile is not None and not isinstance(profile, str): + raise ToolExecutionError('profile must be a string') + if profile: + return runtime.render_profile(profile) + return '\n'.join(['# Remote', '', runtime.render_summary()]) + + +def _remote_list_profiles( + arguments: dict[str, Any], + context: ToolExecutionContext, +) -> str: + runtime = _require_remote_runtime(context) + query = arguments.get('query') + mode = arguments.get('mode') + if query is not None and not isinstance(query, str): + raise ToolExecutionError('query must be a string') + if mode is not None and not isinstance(mode, str): + raise ToolExecutionError('mode must be a string') + max_profiles = _coerce_int(arguments, 'max_profiles', 20) + return runtime.render_profiles_index(query=query, mode=mode, limit=max_profiles) + + +def _remote_connect( + arguments: dict[str, Any], + context: ToolExecutionContext, +) -> tuple[str, dict[str, Any]]: + runtime = _require_remote_runtime(context) + target = _require_string(arguments, 'target') + mode = arguments.get('mode') + if mode is not None and not isinstance(mode, str): + raise ToolExecutionError('mode must be a string') + report = runtime.connect(target, mode=mode) + return ( + report.as_text(), + { + 'action': 'remote_connect', + 'mode': report.mode, + 'target': report.target or target, + 'profile_name': report.profile_name, + 'session_url': report.session_url, + 'workspace_cwd': report.workspace_cwd, + }, + ) + + +def _remote_disconnect( + arguments: dict[str, Any], + context: ToolExecutionContext, +) -> tuple[str, dict[str, Any]]: + runtime = _require_remote_runtime(context) + reason = arguments.get('reason') + if reason is not None and not isinstance(reason, str): + raise ToolExecutionError('reason must be a string') + report = runtime.disconnect(reason=reason or 'tool_request') + return ( + report.as_text(), + { + 'action': 'remote_disconnect', + 'mode': report.mode, + 'target': report.target, + }, + ) + + def _task_list(arguments: dict[str, Any], context: ToolExecutionContext) -> str: runtime = _require_task_runtime(context) status = arguments.get('status') + owner = arguments.get('owner') + actionable_only = arguments.get('actionable_only', False) if status is not None and not isinstance(status, str): raise ToolExecutionError('status must be a string') + if owner is not None and not isinstance(owner, str): + raise ToolExecutionError('owner must be a string') + if not isinstance(actionable_only, bool): + raise ToolExecutionError('actionable_only must be a boolean') max_tasks = _coerce_int(arguments, 'max_tasks', 50) - return runtime.render_tasks(status=status, limit=max_tasks) + return runtime.render_tasks( + status=status, + owner=owner, + actionable_only=actionable_only, + limit=max_tasks, + ) + + +def _task_next(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + runtime = _require_task_runtime(context) + max_tasks = _coerce_int(arguments, 'max_tasks', 10) + return runtime.render_next_tasks(limit=max_tasks) def _task_get(arguments: dict[str, Any], context: ToolExecutionContext) -> str: @@ -851,6 +1661,11 @@ def _task_create(arguments: dict[str, Any], context: ToolExecutionContext) -> st status = arguments.get('status', 'todo') priority = arguments.get('priority') task_id = arguments.get('task_id') + active_form = arguments.get('active_form') + owner = arguments.get('owner') + blocks = arguments.get('blocks', []) + blocked_by = arguments.get('blocked_by', []) + metadata = arguments.get('metadata') if description is not None and not isinstance(description, str): raise ToolExecutionError('description must be a string') if status is not None and not isinstance(status, str): @@ -859,12 +1674,27 @@ def _task_create(arguments: dict[str, Any], context: ToolExecutionContext) -> st raise ToolExecutionError('priority must be a string') if task_id is not None and not isinstance(task_id, str): raise ToolExecutionError('task_id must be a string') + if active_form is not None and not isinstance(active_form, str): + raise ToolExecutionError('active_form must be a string') + if owner is not None and not isinstance(owner, str): + raise ToolExecutionError('owner must be a string') + if not isinstance(blocks, list): + raise ToolExecutionError('blocks must be an array of strings') + if not isinstance(blocked_by, list): + raise ToolExecutionError('blocked_by must be an array of strings') + if metadata is not None and not isinstance(metadata, dict): + raise ToolExecutionError('metadata must be an object') mutation = runtime.create_task( title=title, description=description, - status=status or 'todo', + status=status or 'pending', priority=priority, task_id=task_id, + active_form=active_form, + owner=owner, + blocks=blocks, + blocked_by=blocked_by, + metadata=metadata, ) task = mutation.task assert task is not None @@ -888,14 +1718,27 @@ def _task_update(arguments: dict[str, Any], context: ToolExecutionContext) -> st description = arguments.get('description') status = arguments.get('status') priority = arguments.get('priority') + active_form = arguments.get('active_form') + owner = arguments.get('owner') + blocks = arguments.get('blocks') + blocked_by = arguments.get('blocked_by') + metadata = arguments.get('metadata') for key, value in ( ('title', title), ('description', description), ('status', status), ('priority', priority), + ('active_form', active_form), + ('owner', owner), ): if value is not None and not isinstance(value, str): raise ToolExecutionError(f'{key} must be a string') + if blocks is not None and not isinstance(blocks, list): + raise ToolExecutionError('blocks must be an array of strings') + if blocked_by is not None and not isinstance(blocked_by, list): + raise ToolExecutionError('blocked_by must be an array of strings') + if metadata is not None and not isinstance(metadata, dict): + raise ToolExecutionError('metadata must be an object') try: mutation = runtime.update_task( task_id, @@ -903,6 +1746,11 @@ def _task_update(arguments: dict[str, Any], context: ToolExecutionContext) -> st description=description, status=status, priority=priority, + active_form=active_form, + owner=owner, + blocks=blocks, + blocked_by=blocked_by, + metadata=metadata, ) except KeyError as exc: raise ToolExecutionError(f'Unknown task id: {task_id}') from exc @@ -920,6 +1768,109 @@ def _task_update(arguments: dict[str, Any], context: ToolExecutionContext) -> st ) +def _task_start(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + _ensure_write_allowed(context) + runtime = _require_task_runtime(context) + task_id = _require_string(arguments, 'task_id') + owner = arguments.get('owner') + active_form = arguments.get('active_form') + if owner is not None and not isinstance(owner, str): + raise ToolExecutionError('owner must be a string') + if active_form is not None and not isinstance(active_form, str): + raise ToolExecutionError('active_form must be a string') + try: + mutation = runtime.start_task(task_id, owner=owner, active_form=active_form) + except KeyError as exc: + raise ToolExecutionError(f'Unknown task id: {task_id}') from exc + task = mutation.task + assert task is not None + return ( + f'started task {task.task_id}: {task.title} [{task.status}]', + _task_mutation_metadata( + action='task_start', + mutation=mutation, + task_id=task.task_id, + task_status=task.status, + total_tasks=mutation.after_count, + ), + ) + + +def _task_complete(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + _ensure_write_allowed(context) + runtime = _require_task_runtime(context) + task_id = _require_string(arguments, 'task_id') + try: + mutation = runtime.complete_task(task_id) + except KeyError as exc: + raise ToolExecutionError(f'Unknown task id: {task_id}') from exc + task = runtime.get_task(task_id) + assert task is not None + return ( + f'completed task {task.task_id}: {task.title} [{task.status}]', + _task_mutation_metadata( + action='task_complete', + mutation=mutation, + task_id=task.task_id, + task_status=task.status, + total_tasks=mutation.after_count, + ), + ) + + +def _task_block(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + _ensure_write_allowed(context) + runtime = _require_task_runtime(context) + task_id = _require_string(arguments, 'task_id') + blocked_by = arguments.get('blocked_by') + reason = arguments.get('reason') + if blocked_by is not None and not isinstance(blocked_by, list): + raise ToolExecutionError('blocked_by must be an array of strings') + if reason is not None and not isinstance(reason, str): + raise ToolExecutionError('reason must be a string') + try: + mutation = runtime.block_task(task_id, blocked_by=blocked_by, reason=reason) + except KeyError as exc: + raise ToolExecutionError(f'Unknown task id: {task_id}') from exc + task = mutation.task + assert task is not None + return ( + f'blocked task {task.task_id}: {task.title} [{task.status}]', + _task_mutation_metadata( + action='task_block', + mutation=mutation, + task_id=task.task_id, + task_status=task.status, + total_tasks=mutation.after_count, + ), + ) + + +def _task_cancel(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + _ensure_write_allowed(context) + runtime = _require_task_runtime(context) + task_id = _require_string(arguments, 'task_id') + reason = arguments.get('reason') + if reason is not None and not isinstance(reason, str): + raise ToolExecutionError('reason must be a string') + try: + mutation = runtime.cancel_task(task_id, reason=reason) + except KeyError as exc: + raise ToolExecutionError(f'Unknown task id: {task_id}') from exc + task = mutation.task + assert task is not None + return ( + f'cancelled task {task.task_id}: {task.title} [{task.status}]', + _task_mutation_metadata( + action='task_cancel', + mutation=mutation, + task_id=task.task_id, + task_status=task.status, + total_tasks=mutation.after_count, + ), + ) + + def _todo_write(arguments: dict[str, Any], context: ToolExecutionContext) -> str: _ensure_write_allowed(context) runtime = _require_task_runtime(context) @@ -1073,14 +2024,47 @@ def _delegate_agent_placeholder( ) -def _require_mcp_runtime(context: ToolExecutionContext): - if context.mcp_runtime is None or not context.mcp_runtime.resources: +def _require_account_runtime(context: ToolExecutionContext): + if context.account_runtime is None: + raise ToolExecutionError('No local account runtime is available.') + return context.account_runtime + + +def _require_search_runtime(context: ToolExecutionContext): + if context.search_runtime is None or not context.search_runtime.has_search_runtime(): raise ToolExecutionError( - 'No local MCP resources are available. Add a .claw-mcp.json or .mcp.json manifest first.' + 'No 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.' + ) + return context.search_runtime + + +def _require_config_runtime(context: ToolExecutionContext): + if context.config_runtime is None: + raise ToolExecutionError('No local config runtime is available.') + return context.config_runtime + + +def _require_mcp_runtime(context: ToolExecutionContext): + if ( + context.mcp_runtime is None + or ( + not context.mcp_runtime.resources + and not context.mcp_runtime.servers + ) + ): + raise ToolExecutionError( + 'No MCP runtime is available. Add a .claw-mcp.json, .mcp.json, or mcpServers manifest first.' ) return context.mcp_runtime +def _require_remote_runtime(context: ToolExecutionContext): + if context.remote_runtime is None: + raise ToolExecutionError('Local remote runtime is not available.') + return context.remote_runtime + + def _require_plan_runtime(context: ToolExecutionContext): if context.plan_runtime is None: raise ToolExecutionError('Local plan runtime is not available.') @@ -1123,6 +2107,32 @@ def _task_mutation_metadata( return payload +def _config_mutation_metadata( + mutation, + value: Any, +) -> dict[str, Any]: + try: + relative_path = str(Path(mutation.store_path).relative_to(Path.cwd())) + except ValueError: + relative_path = str(mutation.store_path) + return { + 'action': 'config_set', + 'path': relative_path, + 'source_name': mutation.source_name, + 'key_path': mutation.key_path, + 'before_sha256': mutation.before_sha256, + 'after_sha256': mutation.after_sha256, + 'before_preview': mutation.before_preview, + 'after_preview': mutation.after_preview, + 'effective_key_count': mutation.effective_key_count, + 'value_preview': _snapshot_text( + json.dumps(value, ensure_ascii=True, sort_keys=True) + if not isinstance(value, str) + else value + ), + } + + def _plan_mutation_metadata( *, action: str, diff --git a/src/config_runtime.py b/src/config_runtime.py new file mode 100644 index 0000000..59e87de --- /dev/null +++ b/src/config_runtime.py @@ -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] + '...' diff --git a/src/main.py b/src/main.py index 84cdf14..96791a0 100644 --- a/src/main.py +++ b/src/main.py @@ -9,6 +9,7 @@ import json from typing import Callable from .background_runtime import BackgroundSessionRuntime, build_background_worker_command +from .account_runtime import AccountRuntime from .agent_runtime import LocalCodingAgent from .agent_types import ( AgentPermissions, @@ -21,12 +22,21 @@ from .agent_types import ( from .bootstrap_graph import build_bootstrap_graph from .command_graph import build_command_graph from .commands import execute_command, get_command, get_commands, render_command_index -from .direct_modes import run_deep_link, run_direct_connect +from .config_runtime import ConfigRuntime +from .mcp_runtime import MCPRuntime from .parity_audit import run_parity_audit from .permissions import ToolPermissionContext from .port_manifest import build_port_manifest from .query_engine import QueryEnginePort -from .remote_runtime import run_remote_mode, run_ssh_mode, run_teleport_mode +from .remote_runtime import ( + RemoteRuntime, + run_deep_link_mode, + run_direct_connect_mode, + run_remote_mode, + run_ssh_mode, + run_teleport_mode, +) +from .search_runtime import SearchRuntime from .runtime import PortRuntime from .session_store import ( StoredAgentSession, @@ -248,6 +258,58 @@ def _add_agent_resume_args(parser: argparse.ArgumentParser) -> None: parser.add_argument('--scratchpad-root') +def _launch_background_agent(args: argparse.Namespace) -> int: + background_runtime = BackgroundSessionRuntime() + background_id = background_runtime.create_id() + forwarded_args: list[str] = [] + _append_agent_forwarded_args(forwarded_args, args, include_backend=True) + forwarded_args.extend(['--background-root', str(background_runtime.root)]) + command = build_background_worker_command( + background_id=background_id, + prompt=args.prompt, + forwarded_args=forwarded_args, + ) + record = background_runtime.launch( + command, + prompt=args.prompt, + workspace_cwd=Path(args.cwd).resolve(), + model=args.model, + background_id=background_id, + process_cwd=Path(__file__).resolve().parent.parent, + ) + print('# Background Session') + print(f'background_id={record.background_id}') + print(f'pid={record.pid}') + print(f'log_path={record.log_path}') + print(f'record_path={record.record_path}') + return 0 + + +def _run_background_worker(args: argparse.Namespace) -> int: + background_runtime = BackgroundSessionRuntime(Path(args.background_root)) + exit_code = 1 + stop_reason = 'worker_failed' + session_id = None + session_path = None + try: + agent = _build_agent(args) + result = agent.run(args.prompt) + _print_agent_result(result, show_transcript=args.show_transcript) + exit_code = 0 + stop_reason = result.stop_reason or 'completed' + session_id = result.session_id + session_path = result.session_path + return 0 + finally: + background_runtime.mark_finished( + args.background_id, + exit_code=exit_code, + stop_reason=stop_reason, + session_id=session_id, + session_path=session_path, + ) + + def _build_resumed_agent(args: argparse.Namespace) -> tuple[LocalCodingAgent, StoredAgentSession]: stored_session = load_agent_session(args.session_id) model_config = deserialize_model_config(stored_session.model_config) @@ -519,14 +581,86 @@ def build_parser() -> argparse.ArgumentParser: remote_parser = subparsers.add_parser('remote-mode', help='simulate remote-control runtime branching') remote_parser.add_argument('target') + remote_parser.add_argument('--cwd', default='.') ssh_parser = subparsers.add_parser('ssh-mode', help='simulate SSH runtime branching') ssh_parser.add_argument('target') + ssh_parser.add_argument('--cwd', default='.') teleport_parser = subparsers.add_parser('teleport-mode', help='simulate teleport runtime branching') teleport_parser.add_argument('target') + teleport_parser.add_argument('--cwd', default='.') direct_parser = subparsers.add_parser('direct-connect-mode', help='simulate direct-connect runtime branching') direct_parser.add_argument('target') + direct_parser.add_argument('--cwd', default='.') deep_link_parser = subparsers.add_parser('deep-link-mode', help='simulate deep-link runtime branching') deep_link_parser.add_argument('target') + deep_link_parser.add_argument('--cwd', default='.') + remote_status_parser = subparsers.add_parser('remote-status', help='show local remote runtime status') + remote_status_parser.add_argument('--cwd', default='.') + remote_profiles_parser = subparsers.add_parser('remote-profiles', help='list configured local remote profiles') + remote_profiles_parser.add_argument('--cwd', default='.') + remote_profiles_parser.add_argument('--query') + remote_disconnect_parser = subparsers.add_parser('remote-disconnect', help='disconnect the active local remote target') + remote_disconnect_parser.add_argument('--cwd', default='.') + account_status_parser = subparsers.add_parser('account-status', help='show local account runtime status') + account_status_parser.add_argument('--cwd', default='.') + account_profiles_parser = subparsers.add_parser('account-profiles', help='list configured local account profiles') + account_profiles_parser.add_argument('--cwd', default='.') + account_profiles_parser.add_argument('--query') + account_login_parser = subparsers.add_parser('account-login', help='activate a local account profile or ephemeral identity') + account_login_parser.add_argument('target') + account_login_parser.add_argument('--provider') + account_login_parser.add_argument('--auth-mode') + account_login_parser.add_argument('--cwd', default='.') + account_logout_parser = subparsers.add_parser('account-logout', help='clear the active local account session') + account_logout_parser.add_argument('--cwd', default='.') + search_status_parser = subparsers.add_parser('search-status', help='show local search runtime status') + search_status_parser.add_argument('--cwd', default='.') + search_status_parser.add_argument('--provider') + search_providers_parser = subparsers.add_parser('search-providers', help='list configured local search providers') + search_providers_parser.add_argument('--cwd', default='.') + search_providers_parser.add_argument('--query') + search_activate_parser = subparsers.add_parser('search-activate', help='set the active local search provider') + search_activate_parser.add_argument('provider') + search_activate_parser.add_argument('--cwd', default='.') + search_parser = subparsers.add_parser('search', help='run a real web search against the configured local search runtime') + search_parser.add_argument('query') + search_parser.add_argument('--cwd', default='.') + search_parser.add_argument('--provider') + search_parser.add_argument('--max-results', type=int, default=5) + search_parser.add_argument('--domain', action='append', default=[]) + mcp_status_parser = subparsers.add_parser('mcp-status', help='show local MCP runtime status') + mcp_status_parser.add_argument('--cwd', default='.') + mcp_resources_parser = subparsers.add_parser('mcp-resources', help='list MCP resources discovered through local manifests and transport-backed servers') + mcp_resources_parser.add_argument('--cwd', default='.') + mcp_resources_parser.add_argument('--query') + mcp_resource_parser = subparsers.add_parser('mcp-resource', help='read an MCP resource by URI') + mcp_resource_parser.add_argument('uri') + mcp_resource_parser.add_argument('--cwd', default='.') + mcp_tools_parser = subparsers.add_parser('mcp-tools', help='list MCP tools exposed by configured MCP servers') + mcp_tools_parser.add_argument('--cwd', default='.') + mcp_tools_parser.add_argument('--query') + mcp_tools_parser.add_argument('--server') + mcp_call_tool_parser = subparsers.add_parser('mcp-call-tool', help='call an MCP tool exposed by a configured MCP server') + mcp_call_tool_parser.add_argument('tool_name') + mcp_call_tool_parser.add_argument('--arguments-json', default='{}') + mcp_call_tool_parser.add_argument('--server') + mcp_call_tool_parser.add_argument('--cwd', default='.') + config_status_parser = subparsers.add_parser('config-status', help='show local workspace config runtime summary') + config_status_parser.add_argument('--cwd', default='.') + config_effective_parser = subparsers.add_parser('config-effective', help='render the merged effective local workspace config') + config_effective_parser.add_argument('--cwd', default='.') + config_source_parser = subparsers.add_parser('config-source', help='render a specific local config source') + config_source_parser.add_argument('source') + config_source_parser.add_argument('--cwd', default='.') + config_get_parser = subparsers.add_parser('config-get', help='read a local config value by dotted key path') + config_get_parser.add_argument('key_path') + config_get_parser.add_argument('--source') + config_get_parser.add_argument('--cwd', default='.') + config_set_parser = subparsers.add_parser('config-set', help='write a local config value by dotted key path') + config_set_parser.add_argument('key_path') + config_set_parser.add_argument('value_json') + config_set_parser.add_argument('--source', default='local') + config_set_parser.add_argument('--cwd', default='.') show_command = subparsers.add_parser('show-command', help='show one mirrored command entry by exact name') show_command.add_argument('name') @@ -575,6 +709,38 @@ def build_parser() -> argparse.ArgumentParser: kill_parser = subparsers.add_parser('agent-kill', help='stop a local background agent session') kill_parser.add_argument('background_id') + daemon_parser = subparsers.add_parser('daemon', help='manage local daemon-style background agent sessions') + daemon_subparsers = daemon_parser.add_subparsers(dest='daemon_command') + daemon_subparsers.required = True + + daemon_start_parser = daemon_subparsers.add_parser('start', help='launch a local daemon-style background agent session') + daemon_start_parser.add_argument('prompt') + daemon_start_parser.add_argument('--max-turns', type=int, default=12) + daemon_start_parser.add_argument('--show-transcript', action='store_true') + _add_agent_common_args(daemon_start_parser, include_backend=True) + + daemon_worker_parser = daemon_subparsers.add_parser('worker', help=argparse.SUPPRESS) + daemon_worker_parser.add_argument('background_id') + daemon_worker_parser.add_argument('prompt') + daemon_worker_parser.add_argument('--background-root', required=True) + daemon_worker_parser.add_argument('--max-turns', type=int, default=12) + daemon_worker_parser.add_argument('--show-transcript', action='store_true') + _add_agent_common_args(daemon_worker_parser, include_backend=True) + + daemon_ps_parser = daemon_subparsers.add_parser('ps', help='list local daemon-style background sessions') + daemon_ps_parser.add_argument('--tail', type=int, default=None) + + daemon_logs_parser = daemon_subparsers.add_parser('logs', help='show logs for a local daemon-style background session') + daemon_logs_parser.add_argument('background_id') + daemon_logs_parser.add_argument('--tail', type=int, default=None) + + daemon_attach_parser = daemon_subparsers.add_parser('attach', help='show the current output snapshot for a local daemon-style background session') + daemon_attach_parser.add_argument('background_id') + daemon_attach_parser.add_argument('--tail', type=int, default=None) + + daemon_kill_parser = daemon_subparsers.add_parser('kill', help='stop a local daemon-style background session') + daemon_kill_parser.add_argument('background_id') + chat_parser = subparsers.add_parser('agent-chat', help='run an interactive Python local-model chat loop') chat_parser.add_argument('prompt', nargs='?') chat_parser.add_argument('--resume-session-id') @@ -687,19 +853,155 @@ def main(argv: list[str] | None = None) -> int: print(f'{session.session_id}\n{len(session.messages)} messages\nin={session.input_tokens} out={session.output_tokens}') return 0 if args.command == 'remote-mode': - print(run_remote_mode(args.target).as_text()) + print(run_remote_mode(args.target, cwd=Path(args.cwd).resolve()).as_text()) return 0 if args.command == 'ssh-mode': - print(run_ssh_mode(args.target).as_text()) + print(run_ssh_mode(args.target, cwd=Path(args.cwd).resolve()).as_text()) return 0 if args.command == 'teleport-mode': - print(run_teleport_mode(args.target).as_text()) + print(run_teleport_mode(args.target, cwd=Path(args.cwd).resolve()).as_text()) return 0 if args.command == 'direct-connect-mode': - print(run_direct_connect(args.target).as_text()) + print(run_direct_connect_mode(args.target, cwd=Path(args.cwd).resolve()).as_text()) return 0 if args.command == 'deep-link-mode': - print(run_deep_link(args.target).as_text()) + print(run_deep_link_mode(args.target, cwd=Path(args.cwd).resolve()).as_text()) + return 0 + if args.command == 'remote-status': + runtime = RemoteRuntime.from_workspace(Path(args.cwd).resolve()) + print('# Remote') + print() + print(runtime.render_summary()) + return 0 + if args.command == 'remote-profiles': + runtime = RemoteRuntime.from_workspace(Path(args.cwd).resolve()) + print(runtime.render_profiles_index(query=args.query)) + return 0 + if args.command == 'remote-disconnect': + runtime = RemoteRuntime.from_workspace(Path(args.cwd).resolve()) + print(runtime.disconnect().as_text()) + return 0 + if args.command == 'account-status': + runtime = AccountRuntime.from_workspace(Path(args.cwd).resolve()) + print('# Account') + print() + print(runtime.render_summary()) + return 0 + if args.command == 'account-profiles': + runtime = AccountRuntime.from_workspace(Path(args.cwd).resolve()) + print(runtime.render_profiles_index(query=args.query)) + return 0 + if args.command == 'account-login': + runtime = AccountRuntime.from_workspace(Path(args.cwd).resolve()) + print( + runtime.login( + args.target, + provider=args.provider, + auth_mode=args.auth_mode, + ).as_text() + ) + return 0 + if args.command == 'account-logout': + runtime = AccountRuntime.from_workspace(Path(args.cwd).resolve()) + print(runtime.logout().as_text()) + return 0 + if args.command == 'search-status': + runtime = SearchRuntime.from_workspace(Path(args.cwd).resolve()) + if args.provider: + print(runtime.render_provider(args.provider)) + else: + print('# Search') + print() + print(runtime.render_summary()) + return 0 + if args.command == 'search-providers': + runtime = SearchRuntime.from_workspace(Path(args.cwd).resolve()) + print(runtime.render_providers_index(query=args.query)) + return 0 + if args.command == 'search-activate': + runtime = SearchRuntime.from_workspace(Path(args.cwd).resolve()) + try: + report = runtime.activate_provider(args.provider) + except KeyError: + print(f'Unknown search provider: {args.provider}') + return 1 + print(report.as_text()) + return 0 + if args.command == 'search': + runtime = SearchRuntime.from_workspace(Path(args.cwd).resolve()) + try: + output = runtime.render_search_results( + args.query, + provider_name=args.provider, + max_results=args.max_results, + domains=tuple(args.domain), + ) + except (KeyError, LookupError, OSError, ValueError) as exc: + print(f'Search failed: {exc}') + return 1 + print(output) + return 0 + if args.command == 'mcp-status': + runtime = MCPRuntime.from_workspace(Path(args.cwd).resolve()) + print('# MCP') + print() + print(runtime.render_summary()) + return 0 + if args.command == 'mcp-resources': + runtime = MCPRuntime.from_workspace(Path(args.cwd).resolve()) + print(runtime.render_resource_index(query=args.query)) + return 0 + if args.command == 'mcp-resource': + runtime = MCPRuntime.from_workspace(Path(args.cwd).resolve()) + print(runtime.render_resource(args.uri)) + return 0 + if args.command == 'mcp-tools': + runtime = MCPRuntime.from_workspace(Path(args.cwd).resolve()) + print(runtime.render_tool_index(query=args.query, server_name=args.server)) + return 0 + if args.command == 'mcp-call-tool': + runtime = MCPRuntime.from_workspace(Path(args.cwd).resolve()) + arguments = json.loads(args.arguments_json) + if not isinstance(arguments, dict): + print('arguments-json must decode to a JSON object') + return 1 + print( + runtime.render_tool_call( + args.tool_name, + arguments=arguments, + server_name=args.server, + ) + ) + return 0 + if args.command == 'config-status': + runtime = ConfigRuntime.from_workspace(Path(args.cwd).resolve()) + print('# Config') + print() + print(runtime.render_summary()) + return 0 + if args.command == 'config-effective': + runtime = ConfigRuntime.from_workspace(Path(args.cwd).resolve()) + print(runtime.render_effective_config()) + return 0 + if args.command == 'config-source': + runtime = ConfigRuntime.from_workspace(Path(args.cwd).resolve()) + print(runtime.render_source(args.source)) + return 0 + if args.command == 'config-get': + runtime = ConfigRuntime.from_workspace(Path(args.cwd).resolve()) + print(runtime.render_value(args.key_path, source=args.source)) + return 0 + if args.command == 'config-set': + runtime = ConfigRuntime.from_workspace(Path(args.cwd).resolve()) + value = json.loads(args.value_json) + mutation = runtime.set_value(args.key_path, value, source=args.source) + print('# Config') + print() + print(f'source={mutation.source_name}') + print(f'key_path={mutation.key_path}') + print(f'store_path={mutation.store_path}') + print(f'effective_key_count={mutation.effective_key_count}') + print(runtime.render_value(args.key_path)) return 0 if args.command == 'show-command': module = get_command(args.name) @@ -729,53 +1031,9 @@ def main(argv: list[str] | None = None) -> int: _print_agent_result(result, show_transcript=args.show_transcript) return 0 if args.command == 'agent-bg': - background_runtime = BackgroundSessionRuntime() - background_id = background_runtime.create_id() - forwarded_args: list[str] = [] - _append_agent_forwarded_args(forwarded_args, args, include_backend=True) - forwarded_args.extend(['--background-root', str(background_runtime.root)]) - command = build_background_worker_command( - background_id=background_id, - prompt=args.prompt, - forwarded_args=forwarded_args, - ) - record = background_runtime.launch( - command, - prompt=args.prompt, - workspace_cwd=Path(args.cwd).resolve(), - model=args.model, - background_id=background_id, - process_cwd=Path(__file__).resolve().parent.parent, - ) - print('# Background Session') - print(f'background_id={record.background_id}') - print(f'pid={record.pid}') - print(f'log_path={record.log_path}') - print(f'record_path={record.record_path}') - return 0 + return _launch_background_agent(args) if args.command == 'agent-bg-worker': - background_runtime = BackgroundSessionRuntime(Path(args.background_root)) - exit_code = 1 - stop_reason = 'worker_failed' - session_id = None - session_path = None - try: - agent = _build_agent(args) - result = agent.run(args.prompt) - _print_agent_result(result, show_transcript=args.show_transcript) - exit_code = 0 - stop_reason = result.stop_reason or 'completed' - session_id = result.session_id - session_path = result.session_path - return 0 - finally: - background_runtime.mark_finished( - args.background_id, - exit_code=exit_code, - stop_reason=stop_reason, - session_id=session_id, - session_path=session_path, - ) + return _run_background_worker(args) if args.command == 'agent-ps': print(BackgroundSessionRuntime().render_ps()) return 0 @@ -804,6 +1062,39 @@ def main(argv: list[str] | None = None) -> int: if record.exit_code is not None: print(f'exit_code={record.exit_code}') return 0 + if args.command == 'daemon': + if args.daemon_command == 'start': + return _launch_background_agent(args) + if args.daemon_command == 'worker': + return _run_background_worker(args) + if args.daemon_command == 'ps': + print(BackgroundSessionRuntime().render_ps()) + return 0 + if args.daemon_command == 'logs': + print( + BackgroundSessionRuntime().render_logs( + args.background_id, + tail=args.tail, + ) + ) + return 0 + if args.daemon_command == 'attach': + print( + BackgroundSessionRuntime().render_attach( + args.background_id, + tail=args.tail, + ) + ) + return 0 + if args.daemon_command == 'kill': + record = BackgroundSessionRuntime().kill(args.background_id) + print('# Background Session') + print(f'background_id={record.background_id}') + print(f'status={record.status}') + print(f'pid={record.pid}') + if record.exit_code is not None: + print(f'exit_code={record.exit_code}') + return 0 if args.command == 'agent-chat': agent = _build_agent(args) return _run_agent_chat_loop( diff --git a/src/mcp_runtime.py b/src/mcp_runtime.py index b628511..a8c617e 100644 --- a/src/mcp_runtime.py +++ b/src/mcp_runtime.py @@ -1,11 +1,18 @@ from __future__ import annotations import json +import os +import selectors +import subprocess +import time from dataclasses import dataclass, field from pathlib import Path from typing import Any +MCP_PROTOCOL_VERSION = '2025-11-25' + + @dataclass(frozen=True) class MCPResource: uri: str @@ -19,9 +26,33 @@ class MCPResource: metadata: dict[str, Any] = field(default_factory=dict) +@dataclass(frozen=True) +class MCPTool: + name: str + server_name: str + source_manifest: str + description: str | None = None + input_schema: dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class MCPServerProfile: + name: str + source_manifest: str + transport: str + command: str | None = None + args: tuple[str, ...] = () + env: dict[str, str] = field(default_factory=dict) + cwd: str | None = None + description: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + @dataclass class MCPRuntime: resources: tuple[MCPResource, ...] = field(default_factory=tuple) + servers: tuple[MCPServerProfile, ...] = field(default_factory=tuple) @classmethod def from_workspace( @@ -30,77 +61,165 @@ class MCPRuntime: additional_working_directories: tuple[str, ...] = (), ) -> 'MCPRuntime': resources: list[MCPResource] = [] + servers: list[MCPServerProfile] = [] for path in _discover_manifest_paths(cwd, additional_working_directories): - resources.extend(_load_resources_from_manifest(path)) - return cls(resources=tuple(resources)) + manifest_resources, manifest_servers = _load_manifest(path) + resources.extend(manifest_resources) + servers.extend(manifest_servers) + return cls( + resources=tuple(resources), + servers=tuple(_dedupe_servers(servers)), + ) @property def manifests(self) -> tuple[str, ...]: seen: list[str] = [] - for resource in self.resources: - if resource.source_manifest not in seen: - seen.append(resource.source_manifest) + for entry in [*self.resources, *self.servers]: + source_manifest = entry.source_manifest + if source_manifest not in seen: + seen.append(source_manifest) return tuple(seen) + def has_transport_servers(self) -> bool: + return any(server.transport == 'stdio' for server in self.servers) + def list_resources( self, *, query: str | None = None, limit: int | None = None, ) -> tuple[MCPResource, ...]: - resources = self.resources - if query: - needle = query.lower() - resources = tuple( - resource - for resource in resources - if needle in resource.uri.lower() - or needle in resource.server_name.lower() - or needle in (resource.name or '').lower() - or needle in (resource.description or '').lower() - ) + resources = list(self.resources) + resources.extend(self._list_remote_resources()) + filtered = _filter_resources(tuple(resources), query=query) if limit is not None and limit >= 0: - resources = resources[:limit] - return resources + filtered = filtered[:limit] + return filtered def get_resource(self, uri: str) -> MCPResource | None: for resource in self.resources: if resource.uri == uri: return resource + for resource in self._list_remote_resources(): + if resource.uri == uri: + return resource return None def read_resource(self, uri: str, *, max_chars: int = 12000) -> str: - resource = self.get_resource(uri) - if resource is None: - raise FileNotFoundError(f'Unknown MCP resource: {uri}') - if resource.inline_text is not None: - return _truncate(resource.inline_text, max_chars) - if resource.resolved_path is None: - raise FileNotFoundError(f'MCP resource has no readable content: {uri}') - path = Path(resource.resolved_path) - if not path.exists() or not path.is_file(): - raise FileNotFoundError(f'MCP resource file not found: {path}') - text = path.read_text(encoding='utf-8', errors='replace') - return _truncate(text, max_chars) + for resource in self.resources: + if resource.uri != uri: + continue + if resource.inline_text is not None: + return _truncate(resource.inline_text, max_chars) + if resource.resolved_path is not None: + path = Path(resource.resolved_path) + if not path.exists() or not path.is_file(): + raise FileNotFoundError(f'MCP resource file not found: {path}') + text = path.read_text(encoding='utf-8', errors='replace') + return _truncate(text, max_chars) + last_error: Exception | None = None + candidate_servers: list[MCPServerProfile] = [] + discovered = self.get_resource(uri) + if discovered is not None: + server = self.get_server(discovered.server_name) + if server is not None: + candidate_servers.append(server) + for server in self.servers: + if server.transport != 'stdio': + continue + if all(existing.name != server.name for existing in candidate_servers): + candidate_servers.append(server) + for server in candidate_servers: + try: + result = _request_stdio(server, 'resources/read', {'uri': uri}) + except Exception as exc: + last_error = exc + continue + rendered = _render_resource_contents(result.get('contents')) + if rendered: + return _truncate(rendered, max_chars) + if last_error is not None: + raise FileNotFoundError(f'Unable to read MCP resource {uri}: {last_error}') from last_error + raise FileNotFoundError(f'Unknown MCP resource: {uri}') + + def list_tools( + self, + *, + query: str | None = None, + server_name: str | None = None, + limit: int | None = None, + ) -> tuple[MCPTool, ...]: + tools = self._list_remote_tools(server_name=server_name) + if query: + needle = query.lower() + tools = tuple( + tool + for tool in tools + if needle in tool.name.lower() + or needle in (tool.description or '').lower() + or needle in tool.server_name.lower() + ) + if limit is not None and limit >= 0: + tools = tools[:limit] + return tools + + def call_tool( + self, + tool_name: str, + *, + arguments: dict[str, Any] | None = None, + server_name: str | None = None, + max_chars: int = 12000, + ) -> tuple[str, dict[str, Any]]: + tool = self._resolve_tool(tool_name, server_name=server_name) + server = self.get_server(tool.server_name) + if server is None: + raise FileNotFoundError(f'Unknown MCP server: {tool.server_name}') + payload = { + 'name': tool.name, + 'arguments': dict(arguments or {}), + } + result = _request_stdio(server, 'tools/call', payload) + rendered = _truncate(_render_tool_call_result(result), max_chars) + metadata = { + 'server_name': tool.server_name, + 'tool_name': tool.name, + 'is_error': bool(result.get('isError')), + } + return rendered, metadata + + def get_server(self, name: str) -> MCPServerProfile | None: + needle = name.strip().lower() + if not needle: + return None + for server in self.servers: + if server.name.lower() == needle: + return server + return None def render_summary(self) -> str: - if not self.resources: - return 'No local MCP manifests or resources discovered.' + if not self.resources and not self.servers: + return 'No local MCP manifests, servers, or resources discovered.' lines = [ f'Local MCP manifests: {len(self.manifests)}', f'Local MCP resources: {len(self.resources)}', + f'Configured MCP servers: {len(self.servers)}', ] + transport_counts: dict[str, int] = {} + for server in self.servers: + transport_counts[server.transport] = transport_counts.get(server.transport, 0) + 1 + for transport, count in sorted(transport_counts.items()): + lines.append(f'- {transport}: {count} server(s)') by_server: dict[str, int] = {} for resource in self.resources: by_server[resource.server_name] = by_server.get(resource.server_name, 0) + 1 for server_name, count in sorted(by_server.items()): - lines.append(f'- {server_name}: {count} resource(s)') - for manifest in self.manifests[:10]: - manifest_name = Path(manifest).name - manifest_count = sum( - 1 for resource in self.resources if resource.source_manifest == manifest - ) - lines.append(f'- {manifest_name}: {manifest_count} resource(s)') + lines.append(f'- local resources for {server_name}: {count}') + for server in self.servers[:10]: + details = [server.name, server.transport] + if server.command: + details.append(server.command) + lines.append('- Server: ' + ' ; '.join(details)) return '\n'.join(lines) def render_resource_index( @@ -122,6 +241,10 @@ class MCPRuntime: details.append(f'mime={resource.mime_type}') if resource.resolved_path: details.append(f'path={resource.resolved_path}') + elif resource.inline_text is not None: + details.append('source=inline') + else: + details.append('source=transport') lines.append('- ' + '; '.join(details)) return '\n'.join(lines) @@ -144,6 +267,93 @@ class MCPRuntime: lines.extend(['', self.read_resource(uri, max_chars=max_chars)]) return '\n'.join(lines) + def render_tool_index( + self, + *, + query: str | None = None, + server_name: str | None = None, + limit: int = 50, + ) -> str: + tools = self.list_tools(query=query, server_name=server_name, limit=limit) + if not tools: + return '# MCP Tools\n\nNo matching MCP tools discovered.' + lines = ['# MCP Tools', ''] + for tool in tools: + details = [tool.name, f'server={tool.server_name}'] + if tool.description: + details.append(tool.description) + lines.append('- ' + ' ; '.join(details)) + return '\n'.join(lines) + + def render_tool_call( + self, + tool_name: str, + *, + arguments: dict[str, Any] | None = None, + server_name: str | None = None, + max_chars: int = 12000, + ) -> str: + content, metadata = self.call_tool( + tool_name, + arguments=arguments, + server_name=server_name, + max_chars=max_chars, + ) + lines = [ + '# MCP Tool Result', + '', + f'- Tool: {tool_name}', + f'- Server: {metadata["server_name"]}', + f'- is_error: {metadata["is_error"]}', + '', + content, + ] + return '\n'.join(lines) + + def _list_remote_resources(self) -> tuple[MCPResource, ...]: + discovered: list[MCPResource] = [] + for server in self.servers: + if server.transport != 'stdio': + continue + try: + result = _request_stdio(server, 'resources/list', {}) + except OSError: + continue + for item in _extract_remote_resources(server, result): + discovered.append(item) + return tuple(discovered) + + def _list_remote_tools(self, *, server_name: str | None = None) -> tuple[MCPTool, ...]: + discovered: list[MCPTool] = [] + candidate_servers = ( + [self.get_server(server_name)] if server_name else list(self.servers) + ) + for server in candidate_servers: + if server is None or server.transport != 'stdio': + continue + try: + result = _request_stdio(server, 'tools/list', {}) + except OSError: + continue + for item in _extract_remote_tools(server, result): + discovered.append(item) + return tuple(discovered) + + def _resolve_tool(self, tool_name: str, server_name: str | None = None) -> MCPTool: + tools = self.list_tools(server_name=server_name) + matches = [tool for tool in tools if tool.name == tool_name] + if server_name: + if not matches: + raise FileNotFoundError(f'Unknown MCP tool: {tool_name} on server {server_name}') + return matches[0] + if not matches: + raise FileNotFoundError(f'Unknown MCP tool: {tool_name}') + if len(matches) > 1: + raise FileNotFoundError( + f'MCP tool {tool_name} exists on multiple servers. Pass server_name to disambiguate.' + ) + return matches[0] + def _discover_manifest_paths( cwd: Path, @@ -176,15 +386,17 @@ def _discover_manifest_paths( return tuple(candidates) -def _load_resources_from_manifest(path: Path) -> list[MCPResource]: +def _load_manifest(path: Path) -> tuple[list[MCPResource], list[MCPServerProfile]]: try: payload = json.loads(path.read_text(encoding='utf-8')) except (OSError, json.JSONDecodeError): - return [] + return [], [] if not isinstance(payload, dict): - return [] + return [], [] resources: list[MCPResource] = [] + servers: list[MCPServerProfile] = [] + if isinstance(payload.get('resources'), list): resources.extend( _extract_resources( @@ -193,21 +405,86 @@ def _load_resources_from_manifest(path: Path) -> list[MCPResource]: manifest_path=path, ) ) - servers = payload.get('servers') - if isinstance(servers, list): - for item in servers: + + raw_servers = payload.get('servers') + if isinstance(raw_servers, list): + for item in raw_servers: if not isinstance(item, dict): continue name = item.get('name') if not isinstance(name, str) or not name.strip(): continue + server_name = name.strip() raw_resources = item.get('resources') - if not isinstance(raw_resources, list): + if isinstance(raw_resources, list): + resources.extend( + _extract_resources(server_name, raw_resources, manifest_path=path) + ) + server = _extract_server_profile(server_name, item, manifest_path=path) + if server is not None: + servers.append(server) + + raw_mcp_servers = payload.get('mcpServers') + if isinstance(raw_mcp_servers, dict): + for server_name, item in raw_mcp_servers.items(): + if not isinstance(server_name, str) or not server_name.strip(): continue - resources.extend( - _extract_resources(name.strip(), raw_resources, manifest_path=path) - ) - return resources + if not isinstance(item, dict): + continue + server = _extract_server_profile(server_name.strip(), item, manifest_path=path) + if server is not None: + servers.append(server) + + return resources, servers + + +def _extract_server_profile( + server_name: str, + payload: dict[str, Any], + *, + manifest_path: Path, +) -> MCPServerProfile | None: + command = payload.get('command') + if not isinstance(command, str) or not command.strip(): + return None + args = payload.get('args', ()) + if not isinstance(args, list): + args = () + normalized_args = tuple( + item for item in args if isinstance(item, str) + ) + env = payload.get('env') + normalized_env = { + key: value + for key, value in (env.items() if isinstance(env, dict) else []) + if isinstance(key, str) and isinstance(value, str) + } + cwd = payload.get('cwd') + resolved_cwd: str | None = None + if isinstance(cwd, str) and cwd.strip(): + candidate = Path(cwd).expanduser() + if not candidate.is_absolute(): + candidate = manifest_path.parent / candidate + resolved_cwd = str(candidate.resolve()) + description = payload.get('description') if isinstance(payload.get('description'), str) else None + transport = payload.get('transport') + if not isinstance(transport, str) or not transport.strip(): + transport = 'stdio' + transport = transport.strip().lower() + if transport != 'stdio': + return None + metadata = payload.get('metadata') + return MCPServerProfile( + name=server_name, + source_manifest=str(manifest_path), + transport=transport, + command=command.strip(), + args=normalized_args, + env=normalized_env, + cwd=resolved_cwd, + description=description, + metadata=dict(metadata) if isinstance(metadata, dict) else {}, + ) def _extract_resources( @@ -267,6 +544,334 @@ def _extract_resources( return resources +def _extract_remote_resources( + server: MCPServerProfile, + payload: dict[str, Any], +) -> tuple[MCPResource, ...]: + raw_resources = payload.get('resources') + if not isinstance(raw_resources, list): + return () + resources: list[MCPResource] = [] + for item in raw_resources: + if not isinstance(item, dict): + continue + uri = item.get('uri') + if not isinstance(uri, str) or not uri.strip(): + continue + resources.append( + MCPResource( + uri=uri.strip(), + server_name=server.name, + source_manifest=server.source_manifest, + name=item.get('name') if isinstance(item.get('name'), str) else None, + description=( + item.get('description') + if isinstance(item.get('description'), str) + else None + ), + mime_type=( + item.get('mimeType') + if isinstance(item.get('mimeType'), str) + else item.get('mime_type') + if isinstance(item.get('mime_type'), str) + else None + ), + metadata={ + 'transport': server.transport, + 'server_command': server.command, + }, + ) + ) + return tuple(resources) + + +def _extract_remote_tools( + server: MCPServerProfile, + payload: dict[str, Any], +) -> tuple[MCPTool, ...]: + raw_tools = payload.get('tools') + if not isinstance(raw_tools, list): + return () + tools: list[MCPTool] = [] + for item in raw_tools: + if not isinstance(item, dict): + continue + name = item.get('name') + if not isinstance(name, str) or not name.strip(): + continue + input_schema = item.get('inputSchema') + if not isinstance(input_schema, dict): + input_schema = item.get('input_schema') + tools.append( + MCPTool( + name=name.strip(), + server_name=server.name, + source_manifest=server.source_manifest, + description=( + item.get('description') + if isinstance(item.get('description'), str) + else None + ), + input_schema=dict(input_schema) if isinstance(input_schema, dict) else {}, + metadata={ + 'transport': server.transport, + 'server_command': server.command, + }, + ) + ) + return tuple(tools) + + +def _filter_resources( + resources: tuple[MCPResource, ...], + *, + query: str | None = None, +) -> tuple[MCPResource, ...]: + if not query: + return resources + needle = query.lower() + return tuple( + resource + for resource in resources + if needle in resource.uri.lower() + or needle in resource.server_name.lower() + or needle in (resource.name or '').lower() + or needle in (resource.description or '').lower() + ) + + +def _dedupe_servers(servers: list[MCPServerProfile]) -> list[MCPServerProfile]: + seen: set[tuple[str, str, str | None, tuple[str, ...]]] = set() + deduped: list[MCPServerProfile] = [] + for server in servers: + key = (server.name.lower(), server.transport, server.command, server.args) + if key in seen: + continue + seen.add(key) + deduped.append(server) + return deduped + + +def _request_stdio( + server: MCPServerProfile, + method: str, + params: dict[str, Any], + *, + timeout_seconds: float = 10.0, +) -> dict[str, Any]: + with _StdioMCPConnection(server, timeout_seconds=timeout_seconds) as connection: + return connection.request(method, params) + + +class _StdioMCPConnection: + def __init__(self, server: MCPServerProfile, *, timeout_seconds: float = 10.0) -> None: + self.server = server + self.timeout_seconds = timeout_seconds + self.process: subprocess.Popen[str] | None = None + self.selector: selectors.BaseSelector | None = None + self.stderr_lines: list[str] = [] + self._request_id = 0 + + def __enter__(self) -> '_StdioMCPConnection': + try: + command = [self.server.command or '', *self.server.args] + if not command[0]: + raise OSError(f'MCP server {self.server.name} has no executable command') + env = os.environ.copy() + env.update(self.server.env) + self.process = subprocess.Popen( + command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + cwd=self.server.cwd or None, + env=env, + ) + self.selector = selectors.DefaultSelector() + assert self.process.stdout is not None + assert self.process.stderr is not None + self.selector.register(self.process.stdout, selectors.EVENT_READ, data='stdout') + self.selector.register(self.process.stderr, selectors.EVENT_READ, data='stderr') + self._initialize() + return self + except Exception: + self.close() + raise + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + + def close(self) -> None: + process = self.process + if self.selector is not None: + try: + self.selector.close() + except Exception: + pass + self.selector = None + if process is None: + return + try: + if process.stdin is not None: + process.stdin.close() + except Exception: + pass + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=1.0) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=1.0) + for stream_name in ('stdout', 'stderr'): + stream = getattr(process, stream_name, None) + if stream is not None: + try: + stream.close() + except Exception: + pass + self.process = None + + def request(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + self._request_id += 1 + request_id = self._request_id + self._send( + { + 'jsonrpc': '2.0', + 'id': request_id, + 'method': method, + 'params': params, + } + ) + response = self._await_response(request_id) + error = response.get('error') + if isinstance(error, dict): + message = error.get('message') + raise OSError( + f'MCP {method} failed for server {self.server.name}: {message or error}' + ) + result = response.get('result') + if not isinstance(result, dict): + return {} + return result + + def _initialize(self) -> None: + self._request_id += 1 + request_id = self._request_id + self._send( + { + 'jsonrpc': '2.0', + 'id': request_id, + 'method': 'initialize', + 'params': { + 'protocolVersion': MCP_PROTOCOL_VERSION, + 'capabilities': {}, + 'clientInfo': { + 'name': 'claw-code-agent', + 'version': '0.1.0', + }, + }, + } + ) + response = self._await_response(request_id) + error = response.get('error') + if isinstance(error, dict): + raise OSError( + f'MCP initialize failed for server {self.server.name}: {error.get("message") or error}' + ) + self._send( + { + 'jsonrpc': '2.0', + 'method': 'notifications/initialized', + 'params': {}, + } + ) + + def _send(self, payload: dict[str, Any]) -> None: + if self.process is None or self.process.stdin is None: + raise OSError(f'MCP server {self.server.name} is not running') + self.process.stdin.write(json.dumps(payload, ensure_ascii=True) + '\n') + self.process.stdin.flush() + + def _await_response(self, request_id: int) -> dict[str, Any]: + deadline = time.monotonic() + self.timeout_seconds + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + stderr = '\n'.join(self.stderr_lines[-5:]) + raise TimeoutError( + f'Timed out waiting for MCP response from {self.server.name}' + + (f' stderr={stderr}' if stderr else '') + ) + if self.selector is None: + raise OSError(f'MCP selector is not available for {self.server.name}') + events = self.selector.select(timeout=remaining) + if not events: + continue + for key, _mask in events: + stream_name = key.data + line = key.fileobj.readline() + if not line: + continue + if stream_name == 'stderr': + self.stderr_lines.append(line.rstrip()) + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(payload, dict): + continue + if payload.get('id') == request_id: + return payload + + +def _render_resource_contents(contents: Any) -> str: + if not isinstance(contents, list): + return '' + parts: list[str] = [] + for item in contents: + if not isinstance(item, dict): + continue + text = item.get('text') + if isinstance(text, str): + parts.append(text) + continue + blob = item.get('blob') + if isinstance(blob, str): + mime_type = item.get('mimeType') if isinstance(item.get('mimeType'), str) else 'application/octet-stream' + parts.append(f'[blob:{mime_type}] {blob}') + continue + parts.append(json.dumps(item, ensure_ascii=True, indent=2)) + return '\n\n'.join(parts).strip() + + +def _render_tool_call_result(result: dict[str, Any]) -> str: + parts: list[str] = [] + content = result.get('content') + if isinstance(content, list): + for item in content: + if not isinstance(item, dict): + continue + text = item.get('text') + if isinstance(text, str): + parts.append(text) + continue + structured = item.get('structuredContent') + if structured is not None: + parts.append(json.dumps(structured, ensure_ascii=True, indent=2)) + continue + parts.append(json.dumps(item, ensure_ascii=True, indent=2)) + structured_content = result.get('structuredContent') + if structured_content is not None: + parts.append(json.dumps(structured_content, ensure_ascii=True, indent=2)) + if not parts: + parts.append(json.dumps(result, ensure_ascii=True, indent=2)) + return '\n\n'.join(part for part in parts if part).strip() + + def _truncate(text: str, limit: int) -> str: if len(text) <= limit: return text diff --git a/src/plan_runtime.py b/src/plan_runtime.py index 217c61f..1e9c416 100644 --- a/src/plan_runtime.py +++ b/src/plan_runtime.py @@ -11,7 +11,13 @@ from .task_runtime import TaskRuntime DEFAULT_PLAN_RUNTIME_PATH = Path('.port_sessions') / 'plan_runtime.json' -VALID_PLAN_STATUSES = ('pending', 'in_progress', 'completed') +VALID_PLAN_STATUSES = ( + 'pending', + 'in_progress', + 'completed', + 'blocked', + 'cancelled', +) @dataclass(frozen=True) @@ -21,6 +27,9 @@ class PlanStep: task_id: str | None = None description: str | None = None priority: str | None = None + active_form: str | None = None + owner: str | None = None + depends_on: tuple[str, ...] = () def to_dict(self) -> dict[str, Any]: return { @@ -29,6 +38,9 @@ class PlanStep: 'task_id': self.task_id, 'description': self.description, 'priority': self.priority, + 'active_form': self.active_form, + 'owner': self.owner, + 'depends_on': list(self.depends_on), } @classmethod @@ -53,6 +65,19 @@ class PlanStep: and payload.get('priority').strip() else None ), + active_form=( + str(payload.get('active_form')) + if isinstance(payload.get('active_form'), str) + and payload.get('active_form').strip() + else None + ), + owner=( + str(payload.get('owner')) + if isinstance(payload.get('owner'), str) + and payload.get('owner').strip() + else None + ), + depends_on=_normalize_id_list(payload.get('depends_on')), ) @@ -147,6 +172,19 @@ class PlanRuntime: and item.get('priority').strip() else None ), + active_form=( + item.get('active_form').strip() + if isinstance(item.get('active_form'), str) + and item.get('active_form').strip() + else None + ), + owner=( + item.get('owner').strip() + if isinstance(item.get('owner'), str) + and item.get('owner').strip() + else None + ), + depends_on=_normalize_id_list(item.get('depends_on')), ) ) mutation = self._persist( @@ -158,6 +196,10 @@ class PlanRuntime: ), ) if sync_tasks and task_runtime is not None: + dependents: dict[str, list[str]] = {} + for step in self.steps: + for dependency in step.depends_on: + dependents.setdefault(dependency, []).append(step.task_id or '') task_items = [ { 'task_id': step.task_id or f'plan_{index}', @@ -165,6 +207,14 @@ class PlanRuntime: 'description': step.description, 'status': _plan_status_to_task_status(step.status), 'priority': step.priority, + 'active_form': step.active_form, + 'owner': step.owner, + 'blocked_by': list(step.depends_on), + 'blocks': sorted( + dependency + for dependency in dependents.get(step.task_id or f'plan_{index}', []) + if dependency + ), } for index, step in enumerate(self.steps, start=1) ] @@ -238,6 +288,12 @@ class PlanRuntime: lines.append('- ' + '; '.join(details)) if step.description: lines.append(f' description: {step.description}') + if step.active_form: + lines.append(f' active_form: {step.active_form}') + if step.owner: + lines.append(f' owner: {step.owner}') + if step.depends_on: + lines.append(f" depends_on: {', '.join(step.depends_on)}") return '\n'.join(lines) def _persist( @@ -297,6 +353,7 @@ def _normalize_plan_status(value: Any) -> str: 'complete': 'completed', 'in-progress': 'in_progress', 'in progress': 'in_progress', + 'blocked_on': 'blocked', } lowered = aliases.get(lowered, lowered) if lowered in VALID_PLAN_STATUSES: @@ -306,10 +363,30 @@ def _normalize_plan_status(value: Any) -> str: def _plan_status_to_task_status(status: str) -> str: if status == 'completed': - return 'done' + return 'completed' if status == 'in_progress': return 'in_progress' - return 'todo' + if status == 'blocked': + return 'blocked' + if status == 'cancelled': + return 'cancelled' + return 'pending' + + +def _normalize_id_list(value: Any) -> tuple[str, ...]: + if not isinstance(value, list): + return () + normalized: list[str] = [] + seen: set[str] = set() + for item in value: + if not isinstance(item, str): + continue + text = item.strip() + if not text or text in seen: + continue + normalized.append(text) + seen.add(text) + return tuple(normalized) def _snapshot_text(text: str, limit: int = 240) -> str: diff --git a/src/remote_runtime.py b/src/remote_runtime.py index 357787c..bd45311 100644 --- a/src/remote_runtime.py +++ b/src/remote_runtime.py @@ -1,25 +1,571 @@ -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class RuntimeModeReport: - mode: str - connected: bool - detail: str - - def as_text(self) -> str: - return f'mode={self.mode}\nconnected={self.connected}\ndetail={self.detail}' - - -def run_remote_mode(target: str) -> RuntimeModeReport: - return RuntimeModeReport('remote', True, f'Remote control placeholder prepared for {target}') - - -def run_ssh_mode(target: str) -> RuntimeModeReport: - return RuntimeModeReport('ssh', True, f'SSH proxy placeholder prepared for {target}') - - -def run_teleport_mode(target: str) -> RuntimeModeReport: - return RuntimeModeReport('teleport', True, f'Teleport resume/create placeholder prepared for {target}') +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +DEFAULT_REMOTE_STATE_DIR = Path('.port_sessions') +DEFAULT_REMOTE_STATE_FILE = DEFAULT_REMOTE_STATE_DIR / 'remote_runtime.json' +SUPPORTED_REMOTE_MODES = ( + 'remote', + 'ssh', + 'teleport', + 'direct-connect', + 'deep-link', +) + + +@dataclass(frozen=True) +class RemoteProfile: + name: str + mode: str + target: str + source_manifest: str + description: str | None = None + workspace_cwd: str | None = None + session_url: str | None = None + env: dict[str, str] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class RemoteConnectionState: + mode: str + target: str + connected: bool + connected_at: str + profile_name: str | None = None + workspace_cwd: str | None = None + session_url: str | None = None + source_manifest: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class RuntimeModeReport: + mode: str + connected: bool + detail: str + target: str | None = None + profile_name: str | None = None + workspace_cwd: str | None = None + session_url: str | None = None + source_manifest: str | None = None + manifest_count: int = 0 + profile_count: int = 0 + metadata: dict[str, Any] = field(default_factory=dict) + + def as_text(self) -> str: + lines = [ + f'mode={self.mode}', + f'connected={self.connected}', + f'detail={self.detail}', + ] + if self.target: + lines.append(f'target={self.target}') + if self.profile_name: + lines.append(f'profile={self.profile_name}') + if self.workspace_cwd: + lines.append(f'workspace_cwd={self.workspace_cwd}') + if self.session_url: + lines.append(f'session_url={self.session_url}') + if self.source_manifest: + lines.append(f'source_manifest={self.source_manifest}') + lines.append(f'manifest_count={self.manifest_count}') + lines.append(f'profile_count={self.profile_count}') + if self.metadata: + for key, value in sorted(self.metadata.items()): + lines.append(f'metadata.{key}={value}') + return '\n'.join(lines) + + +@dataclass +class RemoteRuntime: + cwd: Path + profiles: tuple[RemoteProfile, ...] = field(default_factory=tuple) + manifests: tuple[str, ...] = field(default_factory=tuple) + state_path: Path = field(default_factory=lambda: DEFAULT_REMOTE_STATE_FILE.resolve()) + active_connection: RemoteConnectionState | None = None + history: tuple[dict[str, Any], ...] = field(default_factory=tuple) + + @classmethod + def from_workspace( + cls, + cwd: Path, + additional_working_directories: tuple[str, ...] = (), + ) -> 'RemoteRuntime': + manifest_paths = _discover_manifest_paths(cwd, additional_working_directories) + profiles: list[RemoteProfile] = [] + for manifest_path in manifest_paths: + profiles.extend(_load_profiles_from_manifest(manifest_path)) + state_path = cwd.resolve() / DEFAULT_REMOTE_STATE_FILE + payload = _load_state_payload(state_path) + active_connection = _connection_from_payload(payload.get('active_connection')) + history_payload = payload.get('history') + history = tuple( + item for item in history_payload if isinstance(item, dict) + ) if isinstance(history_payload, list) else () + return cls( + cwd=cwd.resolve(), + profiles=tuple(profiles), + manifests=tuple(str(path) for path in manifest_paths), + state_path=state_path, + active_connection=active_connection, + history=history, + ) + + def has_remote_config(self) -> bool: + return bool(self.profiles or self.active_connection is not None) + + def list_profiles( + self, + *, + query: str | None = None, + mode: str | None = None, + limit: int | None = None, + ) -> tuple[RemoteProfile, ...]: + profiles = self.profiles + if query: + needle = query.lower() + profiles = tuple( + profile + for profile in profiles + if needle in profile.name.lower() + or needle in profile.mode.lower() + or needle in profile.target.lower() + or needle in (profile.description or '').lower() + ) + if mode: + profiles = tuple(profile for profile in profiles if profile.mode == _normalize_mode(mode)) + if limit is not None and limit >= 0: + profiles = profiles[:limit] + return profiles + + def get_profile(self, name_or_target: str) -> RemoteProfile | None: + needle = name_or_target.strip().lower() + if not needle: + return None + for profile in self.profiles: + if profile.name.lower() == needle or profile.target.lower() == needle: + return profile + return None + + def connect( + self, + target: str, + *, + mode: str | None = None, + ) -> RuntimeModeReport: + normalized_mode = _normalize_mode(mode or 'remote') + profile = self.get_profile(target) + if profile is not None: + normalized_mode = _normalize_mode(profile.mode or normalized_mode) + connection = RemoteConnectionState( + mode=normalized_mode, + target=profile.target, + connected=True, + connected_at=_utc_now(), + profile_name=profile.name, + workspace_cwd=profile.workspace_cwd, + session_url=profile.session_url, + source_manifest=profile.source_manifest, + metadata=dict(profile.metadata), + ) + detail = f'Activated remote profile {profile.name}' + else: + connection = RemoteConnectionState( + mode=normalized_mode, + target=target.strip(), + connected=True, + connected_at=_utc_now(), + metadata={'ephemeral': True}, + ) + detail = f'Activated {normalized_mode} target {target.strip()}' + self.active_connection = connection + self._append_history( + { + 'action': 'connect', + 'mode': connection.mode, + 'target': connection.target, + 'profile_name': connection.profile_name, + 'connected_at': connection.connected_at, + } + ) + self._persist_state() + return self.current_report(detail=detail) + + def disconnect(self, *, reason: str = 'manual_disconnect') -> RuntimeModeReport: + previous = self.active_connection + detail = ( + f'Disconnected {previous.mode} target {previous.target}' + if previous is not None + else 'No active remote connection was present.' + ) + if previous is not None: + self._append_history( + { + 'action': 'disconnect', + 'mode': previous.mode, + 'target': previous.target, + 'profile_name': previous.profile_name, + 'reason': reason, + 'disconnected_at': _utc_now(), + } + ) + self.active_connection = None + self._persist_state() + return RuntimeModeReport( + mode=previous.mode if previous is not None else 'remote', + connected=False, + detail=detail, + manifest_count=len(self.manifests), + profile_count=len(self.profiles), + ) + + def current_report(self, *, detail: str | None = None) -> RuntimeModeReport: + if self.active_connection is None: + return RuntimeModeReport( + mode='remote', + connected=False, + detail=detail or 'No active remote connection.', + manifest_count=len(self.manifests), + profile_count=len(self.profiles), + ) + connection = self.active_connection + return RuntimeModeReport( + mode=connection.mode, + connected=connection.connected, + detail=detail or f'Active {connection.mode} connection for {connection.target}', + target=connection.target, + profile_name=connection.profile_name, + workspace_cwd=connection.workspace_cwd, + session_url=connection.session_url, + source_manifest=connection.source_manifest, + manifest_count=len(self.manifests), + profile_count=len(self.profiles), + metadata=dict(connection.metadata), + ) + + def render_summary(self) -> str: + lines = [ + f'Local remote manifests: {len(self.manifests)}', + f'Configured remote profiles: {len(self.profiles)}', + ] + if self.active_connection is None: + lines.append('- Active remote connection: none') + else: + connection = self.active_connection + active = f'- Active remote connection: {connection.mode} -> {connection.target}' + if connection.profile_name: + active += f' (profile={connection.profile_name})' + lines.append(active) + if connection.workspace_cwd: + lines.append(f'- Active remote workspace: {connection.workspace_cwd}') + if connection.session_url: + lines.append(f'- Active remote session URL: {connection.session_url}') + for profile in self.profiles[:10]: + parts = [profile.name, f'mode={profile.mode}', f'target={profile.target}'] + if profile.workspace_cwd: + parts.append(f'workspace={profile.workspace_cwd}') + if profile.session_url: + parts.append(f'session_url={profile.session_url}') + lines.append('- ' + '; '.join(parts)) + if self.history: + lines.append(f'- Runtime history entries: {len(self.history)}') + return '\n'.join(lines) + + def render_profiles_index( + self, + *, + query: str | None = None, + mode: str | None = None, + limit: int = 20, + ) -> str: + profiles = self.list_profiles(query=query, mode=mode, limit=limit) + if not profiles: + return '# Remote Profiles\n\nNo matching remote profiles discovered.' + lines = ['# Remote Profiles', ''] + for profile in profiles: + details = [profile.name, f'mode={profile.mode}', f'target={profile.target}'] + if profile.workspace_cwd: + details.append(f'workspace={profile.workspace_cwd}') + if profile.session_url: + details.append(f'session_url={profile.session_url}') + if profile.description: + details.append(f'description={profile.description}') + lines.append('- ' + '; '.join(details)) + return '\n'.join(lines) + + def render_profile(self, name_or_target: str) -> str: + profile = self.get_profile(name_or_target) + if profile is None: + return f'# Remote Profile\n\nUnknown remote profile: {name_or_target}' + lines = [ + '# Remote Profile', + '', + f'- Name: {profile.name}', + f'- Mode: {profile.mode}', + f'- Target: {profile.target}', + f'- Source manifest: {profile.source_manifest}', + ] + if profile.description: + lines.append(f'- Description: {profile.description}') + if profile.workspace_cwd: + lines.append(f'- Workspace: {profile.workspace_cwd}') + if profile.session_url: + lines.append(f'- Session URL: {profile.session_url}') + if profile.env: + lines.append('- Environment values:') + lines.extend(f' - {key}={value}' for key, value in sorted(profile.env.items())) + if profile.metadata: + lines.append('- Metadata:') + lines.extend(f' - {key}={value}' for key, value in sorted(profile.metadata.items())) + return '\n'.join(lines) + + def _persist_state(self) -> None: + payload = { + 'active_connection': ( + asdict(self.active_connection) if self.active_connection is not None else None + ), + 'history': list(self.history), + } + self.state_path.parent.mkdir(parents=True, exist_ok=True) + self.state_path.write_text( + json.dumps(payload, ensure_ascii=True, indent=2), + encoding='utf-8', + ) + + def _append_history(self, entry: dict[str, Any]) -> None: + merged = [*self.history, dict(entry)] + self.history = tuple(merged[-40:]) + + +def run_remote_mode( + target: str, + *, + cwd: Path | None = None, + additional_working_directories: tuple[str, ...] = (), +) -> RuntimeModeReport: + runtime = RemoteRuntime.from_workspace( + cwd or Path.cwd(), + additional_working_directories=additional_working_directories, + ) + return runtime.connect(target, mode='remote') + + +def run_ssh_mode( + target: str, + *, + cwd: Path | None = None, + additional_working_directories: tuple[str, ...] = (), +) -> RuntimeModeReport: + runtime = RemoteRuntime.from_workspace( + cwd or Path.cwd(), + additional_working_directories=additional_working_directories, + ) + return runtime.connect(target, mode='ssh') + + +def run_teleport_mode( + target: str, + *, + cwd: Path | None = None, + additional_working_directories: tuple[str, ...] = (), +) -> RuntimeModeReport: + runtime = RemoteRuntime.from_workspace( + cwd or Path.cwd(), + additional_working_directories=additional_working_directories, + ) + return runtime.connect(target, mode='teleport') + + +def run_direct_connect_mode( + target: str, + *, + cwd: Path | None = None, + additional_working_directories: tuple[str, ...] = (), +) -> RuntimeModeReport: + runtime = RemoteRuntime.from_workspace( + cwd or Path.cwd(), + additional_working_directories=additional_working_directories, + ) + return runtime.connect(target, mode='direct-connect') + + +def run_deep_link_mode( + target: str, + *, + cwd: Path | None = None, + additional_working_directories: tuple[str, ...] = (), +) -> RuntimeModeReport: + runtime = RemoteRuntime.from_workspace( + cwd or Path.cwd(), + additional_working_directories=additional_working_directories, + ) + return runtime.connect(target, mode='deep-link') + + +def _discover_manifest_paths( + cwd: Path, + additional_working_directories: tuple[str, ...], +) -> tuple[Path, ...]: + candidates: list[Path] = [] + seen: set[Path] = set() + + def remember(path: Path) -> None: + resolved = path.resolve() + if resolved in seen or not resolved.exists() or not resolved.is_file(): + return + seen.add(resolved) + candidates.append(resolved) + + roots: list[Path] = [] + current = cwd.resolve() + while True: + roots.append(current) + if current.parent == current: + break + current = current.parent + roots.extend(Path(path).resolve() for path in additional_working_directories) + + for root in roots: + remember(root / '.claw-remote.json') + remember(root / '.remote.json') + remember(root / '.codex-remote.json') + remember(root / 'remote.json') + return tuple(candidates) + + +def _load_profiles_from_manifest(path: Path) -> list[RemoteProfile]: + try: + payload = json.loads(path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError): + return [] + if not isinstance(payload, dict): + return [] + profiles: list[RemoteProfile] = [] + raw_profiles = payload.get('profiles') + if isinstance(raw_profiles, list): + profiles.extend(_extract_profiles(raw_profiles, manifest_path=path)) + elif _looks_like_profile(payload): + profile = _profile_from_item(payload, manifest_path=path) + if profile is not None: + profiles.append(profile) + remotes = payload.get('remotes') + if isinstance(remotes, list): + profiles.extend(_extract_profiles(remotes, manifest_path=path)) + return profiles + + +def _extract_profiles(raw_profiles: list[Any], *, manifest_path: Path) -> list[RemoteProfile]: + profiles: list[RemoteProfile] = [] + seen_names: set[str] = set() + for item in raw_profiles: + profile = _profile_from_item(item, manifest_path=manifest_path) + if profile is None or profile.name.lower() in seen_names: + continue + seen_names.add(profile.name.lower()) + profiles.append(profile) + return profiles + + +def _profile_from_item(item: Any, *, manifest_path: Path) -> RemoteProfile | None: + if not isinstance(item, dict): + return None + name = item.get('name') + target = item.get('target') + if not isinstance(name, str) or not name.strip(): + return None + if not isinstance(target, str) or not target.strip(): + return None + mode = _normalize_mode(str(item.get('mode', 'remote'))) + workspace_cwd = _optional_string( + item.get('workspaceCwd') + if item.get('workspaceCwd') is not None + else item.get('workspace_cwd') + ) + session_url = _optional_string( + item.get('sessionUrl') + if item.get('sessionUrl') is not None + else item.get('session_url') + ) + description = _optional_string(item.get('description')) + env = item.get('env') + metadata = item.get('metadata') + return RemoteProfile( + name=name.strip(), + mode=mode, + target=target.strip(), + source_manifest=str(manifest_path), + description=description, + workspace_cwd=workspace_cwd, + session_url=session_url, + env=( + { + str(key): str(value) + for key, value in env.items() + if isinstance(key, str) and isinstance(value, (str, int, float, bool)) + } + if isinstance(env, dict) + else {} + ), + metadata=dict(metadata) if isinstance(metadata, dict) else {}, + ) + + +def _looks_like_profile(payload: dict[str, Any]) -> bool: + return isinstance(payload.get('name'), str) and isinstance(payload.get('target'), str) + + +def _load_state_payload(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError): + return {} + return payload if isinstance(payload, dict) else {} + + +def _connection_from_payload(payload: Any) -> RemoteConnectionState | None: + if not isinstance(payload, dict): + return None + mode = _optional_string(payload.get('mode')) + target = _optional_string(payload.get('target')) + connected_at = _optional_string(payload.get('connected_at')) + if mode is None or target is None or connected_at is None: + return None + metadata = payload.get('metadata') + return RemoteConnectionState( + mode=_normalize_mode(mode), + target=target, + connected=bool(payload.get('connected', True)), + connected_at=connected_at, + profile_name=_optional_string(payload.get('profile_name')), + workspace_cwd=_optional_string(payload.get('workspace_cwd')), + session_url=_optional_string(payload.get('session_url')), + source_manifest=_optional_string(payload.get('source_manifest')), + metadata=dict(metadata) if isinstance(metadata, dict) else {}, + ) + + +def _normalize_mode(mode: str) -> str: + normalized = mode.strip().lower().replace('_', '-') + if normalized == 'direct': + normalized = 'direct-connect' + if normalized == 'deeplink': + normalized = 'deep-link' + if normalized not in SUPPORTED_REMOTE_MODES: + return 'remote' + return normalized + + +def _optional_string(value: Any) -> str | None: + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() diff --git a/src/search_runtime.py b/src/search_runtime.py new file mode 100644 index 0000000..6740e43 --- /dev/null +++ b/src/search_runtime.py @@ -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 diff --git a/src/task.py b/src/task.py index dd865d5..1c98136 100644 --- a/src/task.py +++ b/src/task.py @@ -5,16 +5,27 @@ from datetime import datetime, timezone from typing import Any -VALID_TASK_STATUSES = ('todo', 'in_progress', 'done', 'cancelled') +VALID_TASK_STATUSES = ( + 'pending', + 'in_progress', + 'completed', + 'blocked', + 'cancelled', +) @dataclass(frozen=True) class PortingTask: task_id: str title: str - status: str = 'todo' + status: str = 'pending' description: str | None = None priority: str | None = None + active_form: str | None = None + owner: str | None = None + blocks: tuple[str, ...] = () + blocked_by: tuple[str, ...] = () + metadata: dict[str, Any] = field(default_factory=dict) created_at: str = field( default_factory=lambda: datetime.now(timezone.utc).isoformat() ) @@ -29,6 +40,11 @@ class PortingTask: 'status': self.status, 'description': self.description, 'priority': self.priority, + 'active_form': self.active_form, + 'owner': self.owner, + 'blocks': list(self.blocks), + 'blocked_by': list(self.blocked_by), + 'metadata': dict(self.metadata), 'created_at': self.created_at, 'updated_at': self.updated_at, } @@ -49,6 +65,25 @@ class PortingTask: if isinstance(payload.get('priority'), str) else None ), + active_form=( + str(payload.get('active_form')) + if isinstance(payload.get('active_form'), str) + and payload.get('active_form') + else None + ), + owner=( + str(payload.get('owner')) + if isinstance(payload.get('owner'), str) + and payload.get('owner') + else None + ), + blocks=_normalize_string_tuple(payload.get('blocks')), + blocked_by=_normalize_string_tuple(payload.get('blocked_by')), + metadata=( + dict(payload.get('metadata')) + if isinstance(payload.get('metadata'), dict) + else {} + ), created_at=( str(payload.get('created_at')) if isinstance(payload.get('created_at'), str) @@ -68,11 +103,28 @@ def _normalize_task_status(value: Any) -> str: aliases = { 'in-progress': 'in_progress', 'in progress': 'in_progress', - 'complete': 'done', - 'completed': 'done', - 'open': 'todo', + 'complete': 'completed', + 'done': 'completed', + 'todo': 'pending', + 'open': 'pending', } lowered = aliases.get(lowered, lowered) if lowered in VALID_TASK_STATUSES: return lowered - return 'todo' + return 'pending' + + +def _normalize_string_tuple(value: Any) -> tuple[str, ...]: + if not isinstance(value, list): + return () + normalized: list[str] = [] + seen: set[str] = set() + for item in value: + if not isinstance(item, str): + continue + text = item.strip() + if not text or text in seen: + continue + normalized.append(text) + seen.add(text) + return tuple(normalized) diff --git a/src/task_runtime.py b/src/task_runtime.py index ea7cc41..f8fcdb9 100644 --- a/src/task_runtime.py +++ b/src/task_runtime.py @@ -12,6 +12,8 @@ from .task import PortingTask, VALID_TASK_STATUSES DEFAULT_TASK_RUNTIME_PATH = Path('.port_sessions') / 'task_runtime.json' +ACTIONABLE_TASK_STATUSES = ('pending', 'in_progress') +TERMINAL_TASK_STATUSES = ('completed', 'cancelled') @dataclass(frozen=True) @@ -57,16 +59,38 @@ class TaskRuntime: self, *, status: str | None = None, + owner: str | None = None, + actionable_only: bool = False, limit: int | None = None, ) -> tuple[PortingTask, ...]: tasks = self.tasks if status: normalized = _normalize_status(status) tasks = tuple(task for task in tasks if task.status == normalized) + if owner: + tasks = tuple(task for task in tasks if task.owner == owner) + if actionable_only: + actionable_ids = {task.task_id for task in self.next_tasks(limit=None)} + tasks = tuple(task for task in tasks if task.task_id in actionable_ids) + tasks = tuple(_sort_tasks(tasks)) if limit is not None and limit >= 0: tasks = tasks[:limit] return tasks + def next_tasks(self, *, limit: int | None = 10) -> tuple[PortingTask, ...]: + tasks_by_id = {task.task_id: task for task in self.tasks} + actionable: list[PortingTask] = [] + for task in self.tasks: + if task.status not in ACTIONABLE_TASK_STATUSES: + continue + unresolved = _unresolved_dependencies(task, tasks_by_id) + if task.status == 'in_progress' or not unresolved: + actionable.append(task) + actionable = list(_sort_tasks(actionable)) + if limit is not None and limit >= 0: + actionable = actionable[:limit] + return tuple(actionable) + def get_task(self, task_id: str) -> PortingTask | None: for task in self.tasks: if task.task_id == task_id: @@ -78,9 +102,14 @@ class TaskRuntime: *, title: str, description: str | None = None, - status: str = 'todo', + status: str = 'pending', priority: str | None = None, task_id: str | None = None, + active_form: str | None = None, + owner: str | None = None, + blocks: tuple[str, ...] | list[str] = (), + blocked_by: tuple[str, ...] | list[str] = (), + metadata: dict[str, Any] | None = None, ) -> TaskMutation: task = PortingTask( task_id=task_id or f'task_{uuid4().hex[:10]}', @@ -88,6 +117,15 @@ class TaskRuntime: description=description.strip() if isinstance(description, str) and description.strip() else None, status=_normalize_status(status), priority=priority.strip() if isinstance(priority, str) and priority.strip() else None, + active_form=( + active_form.strip() + if isinstance(active_form, str) and active_form.strip() + else None + ), + owner=owner.strip() if isinstance(owner, str) and owner.strip() else None, + blocks=_normalize_id_list(blocks), + blocked_by=_normalize_id_list(blocked_by), + metadata=dict(metadata or {}), ) return self._persist((*self.tasks, task), task=task) @@ -99,10 +137,19 @@ class TaskRuntime: description: str | None = None, status: str | None = None, priority: str | None = None, + active_form: str | None = None, + owner: str | None = None, + blocks: tuple[str, ...] | list[str] | None = None, + blocked_by: tuple[str, ...] | list[str] | None = None, + metadata: dict[str, Any] | None = None, + merge_metadata: bool = False, ) -> TaskMutation: existing = self.get_task(task_id) if existing is None: raise KeyError(task_id) + updated_metadata = dict(existing.metadata) + if metadata is not None: + updated_metadata = {**updated_metadata, **metadata} if merge_metadata else dict(metadata) updated = replace( existing, title=title.strip() if isinstance(title, str) and title.strip() else existing.title, @@ -119,11 +166,133 @@ class TaskRuntime: else None if priority == '' else existing.priority ), + active_form=( + active_form.strip() + if isinstance(active_form, str) and active_form.strip() + else None if active_form == '' + else existing.active_form + ), + owner=( + owner.strip() + if isinstance(owner, str) and owner.strip() + else None if owner == '' + else existing.owner + ), + blocks=( + _normalize_id_list(blocks) + if blocks is not None + else existing.blocks + ), + blocked_by=( + _normalize_id_list(blocked_by) + if blocked_by is not None + else existing.blocked_by + ), + metadata=updated_metadata, updated_at=datetime.now(timezone.utc).isoformat(), ) tasks = tuple(updated if task.task_id == task_id else task for task in self.tasks) return self._persist(tasks, task=updated) + def start_task( + self, + task_id: str, + *, + owner: str | None = None, + active_form: str | None = None, + ) -> TaskMutation: + existing = self.get_task(task_id) + if existing is None: + raise KeyError(task_id) + unresolved = _unresolved_dependencies(existing, {task.task_id: task for task in self.tasks}) + metadata = dict(existing.metadata) + if unresolved: + metadata['blocked_reason'] = f'waiting_on:{",".join(unresolved)}' + return self.update_task( + task_id, + status='blocked', + owner=owner if owner is not None else existing.owner, + active_form=active_form if active_form is not None else existing.active_form, + metadata=metadata, + ) + if 'blocked_reason' in metadata: + metadata.pop('blocked_reason') + return self.update_task( + task_id, + status='in_progress', + owner=owner if owner is not None else existing.owner, + active_form=active_form if active_form is not None else existing.active_form, + metadata=metadata, + ) + + def complete_task(self, task_id: str) -> TaskMutation: + mutation = self.update_task(task_id, status='completed') + completed_ids = {task.task_id for task in self.tasks if task.status in TERMINAL_TASK_STATUSES} + updated_tasks: list[PortingTask] = [] + changed = False + for task in self.tasks: + if task.status != 'blocked': + updated_tasks.append(task) + continue + unresolved = tuple( + dependency + for dependency in task.blocked_by + if dependency not in completed_ids + ) + if unresolved: + updated_tasks.append(task) + continue + metadata = dict(task.metadata) + metadata.pop('blocked_reason', None) + updated_tasks.append( + replace( + task, + status='pending', + metadata=metadata, + updated_at=datetime.now(timezone.utc).isoformat(), + ) + ) + changed = True + if changed: + return self._persist(tuple(updated_tasks), task=self.get_task(task_id)) + return mutation + + def block_task( + self, + task_id: str, + *, + blocked_by: tuple[str, ...] | list[str] | None = None, + reason: str | None = None, + ) -> TaskMutation: + existing = self.get_task(task_id) + if existing is None: + raise KeyError(task_id) + merged_blocked_by = tuple(existing.blocked_by) + if blocked_by is not None: + merged_blocked_by = _merge_ids(existing.blocked_by, _normalize_id_list(blocked_by)) + metadata = dict(existing.metadata) + if isinstance(reason, str) and reason.strip(): + metadata['blocked_reason'] = reason.strip() + return self.update_task( + task_id, + status='blocked', + blocked_by=merged_blocked_by, + metadata=metadata, + ) + + def cancel_task(self, task_id: str, *, reason: str | None = None) -> TaskMutation: + existing = self.get_task(task_id) + if existing is None: + raise KeyError(task_id) + metadata = dict(existing.metadata) + if isinstance(reason, str) and reason.strip(): + metadata['cancel_reason'] = reason.strip() + return self.update_task( + task_id, + status='cancelled', + metadata=metadata, + ) + def replace_tasks(self, items: list[dict[str, Any]]) -> TaskMutation: tasks: list[PortingTask] = [] now = datetime.now(timezone.utc).isoformat() @@ -157,6 +326,25 @@ class TaskRuntime: and item.get('priority').strip() else None ), + active_form=( + item.get('active_form').strip() + if isinstance(item.get('active_form'), str) + and item.get('active_form').strip() + else None + ), + owner=( + item.get('owner').strip() + if isinstance(item.get('owner'), str) + and item.get('owner').strip() + else None + ), + blocks=_normalize_id_list(item.get('blocks', [])), + blocked_by=_normalize_id_list(item.get('blocked_by', [])), + metadata=( + dict(item.get('metadata')) + if isinstance(item.get('metadata'), dict) + else {} + ), created_at=( str(item.get('created_at')) if isinstance(item.get('created_at'), str) @@ -181,26 +369,55 @@ class TaskRuntime: '- Status counts: ' + ', '.join(f'{name}={count}' for name, count in sorted(counts.items())) ) + actionable = self.next_tasks(limit=None) + if actionable: + lines.append(f'- Actionable tasks: {len(actionable)}') + blocked = [task for task in self.tasks if task.status == 'blocked'] + if blocked: + lines.append(f'- Blocked tasks: {len(blocked)}') if self.tasks: - preview = ', '.join(task.title for task in self.tasks[:4]) + preview = ', '.join(task.title for task in _sort_tasks(self.tasks)[:4]) if len(self.tasks) > 4: preview += f', ... (+{len(self.tasks) - 4} more)' lines.append(f'- Task preview: {preview}') return '\n'.join(lines) - def render_tasks(self, *, status: str | None = None, limit: int = 50) -> str: - tasks = self.list_tasks(status=status, limit=limit) + def render_tasks( + self, + *, + status: str | None = None, + owner: str | None = None, + actionable_only: bool = False, + limit: int = 50, + ) -> str: + tasks = self.list_tasks( + status=status, + owner=owner, + actionable_only=actionable_only, + limit=limit, + ) if not tasks: return '# Tasks\n\nNo tasks are currently stored.' lines = ['# Tasks', ''] + if actionable_only: + lines.append('Showing actionable tasks only.') + lines.append('') for task in tasks: details = [task.task_id, f'status={task.status}'] if task.priority: details.append(f'priority={task.priority}') + if task.owner: + details.append(f'owner={task.owner}') details.append(f'title={task.title}') lines.append('- ' + '; '.join(details)) if task.description: lines.append(f' description: {task.description}') + if task.active_form: + lines.append(f' active_form: {task.active_form}') + if task.blocked_by: + lines.append(f" blocked_by: {', '.join(task.blocked_by)}") + if task.blocks: + lines.append(f" blocks: {', '.join(task.blocks)}") return '\n'.join(lines) def render_task(self, task_id: str) -> str: @@ -216,22 +433,52 @@ class TaskRuntime: ] if task.priority: lines.append(f'- Priority: {task.priority}') + if task.owner: + lines.append(f'- Owner: {task.owner}') + if task.active_form: + lines.append(f'- Active Form: {task.active_form}') if task.description: lines.append(f'- Description: {task.description}') + if task.blocked_by: + lines.append(f"- Blocked By: {', '.join(task.blocked_by)}") + if task.blocks: + lines.append(f"- Blocks: {', '.join(task.blocks)}") + if task.metadata: + lines.append('- Metadata:') + for key, value in sorted(task.metadata.items()): + lines.append(f' - {key}={value}') lines.append(f'- Updated: {task.updated_at}') return '\n'.join(lines) + def render_next_tasks(self, *, limit: int = 10) -> str: + tasks = self.next_tasks(limit=limit) + if not tasks: + return '# Next Tasks\n\nNo actionable tasks are currently available.' + lines = ['# Next Tasks', ''] + for task in tasks: + details = [task.task_id, f'status={task.status}', f'title={task.title}'] + if task.owner: + details.append(f'owner={task.owner}') + lines.append('- ' + '; '.join(details)) + unresolved = _unresolved_dependencies(task, {item.task_id: item for item in self.tasks}) + if unresolved: + lines.append(f" unresolved_dependencies: {', '.join(unresolved)}") + if task.active_form: + lines.append(f' active_form: {task.active_form}') + return '\n'.join(lines) + def _persist( self, tasks: tuple[PortingTask, ...], *, task: PortingTask | None, ) -> TaskMutation: - before_text = self._serialize_payload(self.tasks) + before_tasks = self.tasks + before_text = self._serialize_payload(before_tasks) before_preview = _snapshot_text(before_text) before_sha256 = ( hashlib.sha256(before_text.encode('utf-8')).hexdigest() - if self.storage_path.exists() or self.tasks + if self.storage_path.exists() or before_tasks else None ) payload_text = self._serialize_payload(tasks) @@ -246,7 +493,7 @@ class TaskRuntime: after_sha256=after_sha256, before_preview=before_preview if before_text.strip() else None, after_preview=_snapshot_text(payload_text), - before_count=len(json.loads(before_text).get('tasks', [])) if before_text.strip() else 0, + before_count=len(before_tasks), after_count=len(tasks), ) @@ -268,11 +515,81 @@ def _normalize_status(value: Any) -> str: if isinstance(value, str): lowered = value.strip().lower().replace('-', '_').replace(' ', '_') aliases = { - 'complete': 'done', - 'completed': 'done', - 'open': 'todo', + 'complete': 'completed', + 'done': 'completed', + 'todo': 'pending', + 'open': 'pending', } lowered = aliases.get(lowered, lowered) if lowered in VALID_TASK_STATUSES: return lowered - return 'todo' + return 'pending' + + +def _normalize_id_list(value: Any) -> tuple[str, ...]: + if value is None: + return () + if isinstance(value, tuple): + items = list(value) + elif isinstance(value, list): + items = value + else: + return () + normalized: list[str] = [] + seen: set[str] = set() + for item in items: + if not isinstance(item, str): + continue + text = item.strip() + if not text or text in seen: + continue + normalized.append(text) + seen.add(text) + return tuple(normalized) + + +def _merge_ids(existing: tuple[str, ...], additions: tuple[str, ...]) -> tuple[str, ...]: + merged = list(existing) + seen = set(existing) + for item in additions: + if item in seen: + continue + merged.append(item) + seen.add(item) + return tuple(merged) + + +def _unresolved_dependencies( + task: PortingTask, + tasks_by_id: dict[str, PortingTask], +) -> tuple[str, ...]: + unresolved: list[str] = [] + for dependency_id in task.blocked_by: + dependency = tasks_by_id.get(dependency_id) + if dependency is None: + unresolved.append(dependency_id) + continue + if dependency.status not in TERMINAL_TASK_STATUSES: + unresolved.append(dependency_id) + return tuple(unresolved) + + +def _task_sort_key(task: PortingTask) -> tuple[int, int, str, str]: + status_rank = { + 'in_progress': 0, + 'pending': 1, + 'blocked': 2, + 'completed': 3, + 'cancelled': 4, + }.get(task.status, 9) + priority_rank = { + 'critical': 0, + 'high': 1, + 'medium': 2, + 'low': 3, + }.get((task.priority or '').lower(), 9) + return (status_rank, priority_rank, task.title.lower(), task.task_id) + + +def _sort_tasks(tasks: tuple[PortingTask, ...] | list[PortingTask]) -> tuple[PortingTask, ...]: + return tuple(sorted(tasks, key=_task_sort_key)) diff --git a/src/tokenizer_runtime.py b/src/tokenizer_runtime.py new file mode 100644 index 0000000..b52f8e9 --- /dev/null +++ b/src/tokenizer_runtime.py @@ -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)) diff --git a/tests/test_account_runtime.py b/tests/test_account_runtime.py new file mode 100644 index 0000000..f6e7b4a --- /dev/null +++ b/tests/test_account_runtime.py @@ -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) diff --git a/tests/test_agent_context.py b/tests/test_agent_context.py index 7688455..b537c3d 100644 --- a/tests/test_agent_context.py +++ b/tests/test_agent_context.py @@ -103,6 +103,71 @@ class AgentContextTests(unittest.TestCase): self.assertIn('mcpRuntime', snapshot.user_context) self.assertIn('Local MCP resources: 1', snapshot.user_context['mcpRuntime']) + def test_user_context_loads_search_runtime_summary(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) / 'repo' + workspace.mkdir(parents=True) + (workspace / '.claw-search.json').write_text( + '{"providers":[{"name":"local-search","provider":"searxng","baseUrl":"http://127.0.0.1:8080"}]}', + encoding='utf-8', + ) + + snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace)) + + self.assertIn('searchRuntime', snapshot.user_context) + self.assertIn('Configured search providers: 1', snapshot.user_context['searchRuntime']) + self.assertIn('local-search', snapshot.user_context['searchRuntime']) + + def test_user_context_loads_remote_runtime_summary(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) / 'repo' + workspace.mkdir(parents=True) + (workspace / '.claw-remote.json').write_text( + ( + '{"profiles":[{"name":"staging","mode":"ssh","target":"dev@staging",' + '"workspaceCwd":"/srv/app","sessionUrl":"wss://remote/session"}]}' + ), + encoding='utf-8', + ) + + snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace)) + + self.assertIn('remoteRuntime', snapshot.user_context) + self.assertIn('Configured remote profiles: 1', snapshot.user_context['remoteRuntime']) + self.assertIn('staging', snapshot.user_context['remoteRuntime']) + + def test_user_context_loads_account_runtime_summary(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) / 'repo' + workspace.mkdir(parents=True) + (workspace / '.claw-account.json').write_text( + '{"profiles":[{"name":"local","provider":"openai","identity":"dev@example.com"}]}', + encoding='utf-8', + ) + + snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace)) + + self.assertIn('accountRuntime', snapshot.user_context) + self.assertIn('Configured account profiles: 1', snapshot.user_context['accountRuntime']) + self.assertIn('dev@example.com', snapshot.user_context['accountRuntime']) + + def test_user_context_loads_config_runtime_summary(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) / 'repo' + workspace.mkdir(parents=True) + claude_dir = workspace / '.claude' + claude_dir.mkdir() + (claude_dir / 'settings.json').write_text( + '{"review":{"mode":"strict"}}', + encoding='utf-8', + ) + + snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace)) + + self.assertIn('configRuntime', snapshot.user_context) + self.assertIn('Config sources: 1', snapshot.user_context['configRuntime']) + self.assertIn('Effective keys: 2', snapshot.user_context['configRuntime']) + def test_user_context_loads_task_runtime_summary(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: workspace = Path(tmp_dir) / 'repo' diff --git a/tests/test_agent_context_usage.py b/tests/test_agent_context_usage.py index c619989..3636830 100644 --- a/tests/test_agent_context_usage.py +++ b/tests/test_agent_context_usage.py @@ -35,6 +35,7 @@ class AgentContextUsageTests(unittest.TestCase): self.assertGreater(report.total_tokens, 0) self.assertIn('## Context Usage', rendered) + self.assertIn('**Token counter:**', rendered) self.assertIn('### System Prompt Sections', rendered) self.assertIn('### Message Breakdown', rendered) self.assertIn('#### Top Tools', rendered) diff --git a/tests/test_agent_prompting.py b/tests/test_agent_prompting.py index 8b8f0ec..fda3079 100644 --- a/tests/test_agent_prompting.py +++ b/tests/test_agent_prompting.py @@ -125,6 +125,88 @@ class AgentPromptingTests(unittest.TestCase): prompt = render_system_prompt(parts) self.assertIn('# MCP', prompt) + def test_prompt_builder_mentions_search_when_runtime_is_loaded(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + (workspace / '.claw-search.json').write_text( + '{"providers":[{"name":"local-search","provider":"searxng","baseUrl":"http://127.0.0.1:8080"}]}', + encoding='utf-8', + ) + runtime_config = AgentRuntimeConfig(cwd=workspace) + model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct') + prompt_context = build_prompt_context(runtime_config, model_config) + parts = build_system_prompt_parts( + prompt_context=prompt_context, + runtime_config=runtime_config, + tools=default_tool_registry(), + ) + + prompt = render_system_prompt(parts) + self.assertIn('# Search', prompt) + self.assertIn('web_search', prompt) + + def test_prompt_builder_mentions_remote_when_manifest_is_loaded(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + (workspace / '.claw-remote.json').write_text( + ( + '{"profiles":[{"name":"staging","mode":"ssh","target":"dev@staging",' + '"workspaceCwd":"/srv/app"}]}' + ), + encoding='utf-8', + ) + runtime_config = AgentRuntimeConfig(cwd=workspace) + model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct') + prompt_context = build_prompt_context(runtime_config, model_config) + parts = build_system_prompt_parts( + prompt_context=prompt_context, + runtime_config=runtime_config, + tools=default_tool_registry(), + ) + + prompt = render_system_prompt(parts) + self.assertIn('# Remote', prompt) + + def test_prompt_builder_mentions_account_when_runtime_is_loaded(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + (workspace / '.claw-account.json').write_text( + '{"profiles":[{"name":"local","provider":"openai","identity":"dev@example.com"}]}', + encoding='utf-8', + ) + runtime_config = AgentRuntimeConfig(cwd=workspace) + model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct') + prompt_context = build_prompt_context(runtime_config, model_config) + parts = build_system_prompt_parts( + prompt_context=prompt_context, + runtime_config=runtime_config, + tools=default_tool_registry(), + ) + + prompt = render_system_prompt(parts) + self.assertIn('# Account', prompt) + + def test_prompt_builder_mentions_config_when_runtime_is_loaded(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + claude_dir = workspace / '.claude' + claude_dir.mkdir() + (claude_dir / 'settings.json').write_text( + '{"review":{"mode":"strict"}}', + encoding='utf-8', + ) + runtime_config = AgentRuntimeConfig(cwd=workspace) + model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct') + prompt_context = build_prompt_context(runtime_config, model_config) + parts = build_system_prompt_parts( + prompt_context=prompt_context, + runtime_config=runtime_config, + tools=default_tool_registry(), + ) + + prompt = render_system_prompt(parts) + self.assertIn('# Config', prompt) + def test_prompt_builder_mentions_tasks_when_runtime_is_loaded(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: workspace = Path(tmp_dir) diff --git a/tests/test_agent_slash_commands.py b/tests/test_agent_slash_commands.py index 939a340..c710a16 100644 --- a/tests/test_agent_slash_commands.py +++ b/tests/test_agent_slash_commands.py @@ -2,6 +2,8 @@ from __future__ import annotations import tempfile import unittest +import json +import sys from pathlib import Path from unittest.mock import patch @@ -12,6 +14,55 @@ from src.plan_runtime import PlanRuntime from src.task_runtime import TaskRuntime +class _FakeHTTPResponse: + def __init__(self, payload: str) -> None: + self.payload = payload + + def read(self) -> bytes: + return self.payload.encode('utf-8') + + def __enter__(self) -> '_FakeHTTPResponse': + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + +def _write_fake_mcp_server(workspace: Path) -> Path: + server_path = workspace / 'fake_mcp_server.py' + server_path.write_text( + ( + 'import json, sys\n' + 'TOOLS = [{"name": "echo", "description": "Echo text", "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}}}]\n' + 'for raw in sys.stdin:\n' + ' raw = raw.strip()\n' + ' if not raw:\n' + ' continue\n' + ' message = json.loads(raw)\n' + ' method = message.get("method")\n' + ' if method == "initialize":\n' + ' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"protocolVersion": "2025-11-25", "capabilities": {"resources": {}, "tools": {}}, "serverInfo": {"name": "fake-remote", "version": "1.0.0"}}}\n' + ' print(json.dumps(response), flush=True)\n' + ' continue\n' + ' if method == "notifications/initialized":\n' + ' continue\n' + ' if method == "tools/list":\n' + ' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"tools": TOOLS}}\n' + ' print(json.dumps(response), flush=True)\n' + ' continue\n' + ' if method == "tools/call":\n' + ' text = message.get("params", {}).get("arguments", {}).get("text", "")\n' + ' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"content": [{"type": "text", "text": "echo:" + text}], "isError": False}}\n' + ' print(json.dumps(response), flush=True)\n' + ' continue\n' + ' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"resources": []}}\n' + ' print(json.dumps(response), flush=True)\n' + ), + encoding='utf-8', + ) + return server_path + + class AgentSlashCommandTests(unittest.TestCase): def test_parse_slash_command(self) -> None: parsed = parse_slash_command('/context extra args') @@ -84,6 +135,142 @@ class AgentSlashCommandTests(unittest.TestCase): self.assertIn('mcp notes', resource_result.final_output) self.assertIn('# MCP', legacy_mcp_result.final_output) + def test_mcp_tools_command_renders_transport_backed_tools(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + server_path = _write_fake_mcp_server(workspace) + (workspace / '.claw-mcp.json').write_text( + json.dumps( + { + 'mcpServers': { + 'remote': { + 'command': sys.executable, + 'args': ['-u', str(server_path)], + } + } + } + ), + encoding='utf-8', + ) + agent = LocalCodingAgent( + model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'), + runtime_config=AgentRuntimeConfig(cwd=workspace), + ) + tools_result = agent.run('/mcp tools') + tool_result = agent.run('/mcp tool echo') + self.assertIn('# MCP Tools', tools_result.final_output) + self.assertIn('echo', tools_result.final_output) + self.assertIn('# MCP Tool Result', tool_result.final_output) + self.assertIn('echo:', tool_result.final_output) + + def test_search_commands_render_and_update_local_search_runtime(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + (workspace / '.claw-search.json').write_text( + ( + '{"providers":[' + '{"name":"local-search","provider":"searxng","baseUrl":"http://127.0.0.1:8080"},' + '{"name":"backup-search","provider":"searxng","baseUrl":"http://127.0.0.2:8080"}' + ']}' + ), + encoding='utf-8', + ) + agent = LocalCodingAgent( + model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'), + runtime_config=AgentRuntimeConfig(cwd=workspace), + ) + with patch( + 'src.search_runtime.request.urlopen', + return_value=_FakeHTTPResponse( + '{"results":[{"title":"Alpha","url":"https://example.com/alpha","content":"Search snippet"}]}' + ), + ): + search_result = agent.run('/search alpha query') + providers_result = agent.run('/search providers') + activate_result = agent.run('/search use backup-search') + provider_result = agent.run('/search provider backup-search') + self.assertIn('# Web Search', search_result.final_output) + self.assertIn('Alpha', search_result.final_output) + self.assertIn('# Search Providers', providers_result.final_output) + self.assertIn('local-search', providers_result.final_output) + self.assertIn('backup-search', providers_result.final_output) + self.assertIn('provider=backup-search', activate_result.final_output) + self.assertIn('# Search Provider', provider_result.final_output) + self.assertIn('backup-search', provider_result.final_output) + + def test_remote_commands_render_and_update_local_remote_runtime(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + (workspace / '.claw-remote.json').write_text( + ( + '{"profiles":[{"name":"staging","mode":"ssh","target":"dev@staging",' + '"workspaceCwd":"/srv/app","sessionUrl":"wss://remote/session"}]}' + ), + encoding='utf-8', + ) + agent = LocalCodingAgent( + model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'), + runtime_config=AgentRuntimeConfig(cwd=workspace), + ) + remotes_result = agent.run('/remotes') + remote_result = agent.run('/remote') + ssh_result = agent.run('/ssh staging') + disconnect_result = agent.run('/disconnect') + self.assertIn('# Remote Profiles', remotes_result.final_output) + self.assertIn('staging', remotes_result.final_output) + self.assertIn('# Remote', remote_result.final_output) + self.assertIn('Configured remote profiles: 1', remote_result.final_output) + self.assertIn('mode=ssh', ssh_result.final_output) + self.assertIn('profile=staging', ssh_result.final_output) + self.assertIn('connected=False', disconnect_result.final_output) + + def test_account_commands_render_and_update_local_account_runtime(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + (workspace / '.claw-account.json').write_text( + '{"profiles":[{"name":"local","provider":"openai","identity":"dev@example.com"}]}', + encoding='utf-8', + ) + agent = LocalCodingAgent( + model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'), + runtime_config=AgentRuntimeConfig(cwd=workspace), + ) + account_result = agent.run('/account') + profiles_result = agent.run('/account profiles') + login_result = agent.run('/login local') + logout_result = agent.run('/logout') + self.assertIn('# Account', account_result.final_output) + self.assertIn('Configured account profiles: 1', account_result.final_output) + self.assertIn('# Account Profiles', profiles_result.final_output) + self.assertIn('dev@example.com', profiles_result.final_output) + self.assertIn('profile=local', login_result.final_output) + self.assertIn('logged_in=False', logout_result.final_output) + + def test_config_commands_render_local_reports(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + claude_dir = workspace / '.claude' + claude_dir.mkdir() + (claude_dir / 'settings.json').write_text( + '{"review":{"mode":"strict"}}', + encoding='utf-8', + ) + agent = LocalCodingAgent( + model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'), + runtime_config=AgentRuntimeConfig(cwd=workspace), + ) + config_result = agent.run('/config') + effective_result = agent.run('/config effective') + value_result = agent.run('/config get review.mode') + source_result = agent.run('/settings source project') + self.assertIn('# Config', config_result.final_output) + self.assertIn('Config sources: 1', config_result.final_output) + self.assertIn('# Config Effective', effective_result.final_output) + self.assertIn('"review"', effective_result.final_output) + self.assertIn('# Config Value', value_result.final_output) + self.assertIn('"strict"', value_result.final_output) + self.assertIn('# Config Source', source_result.final_output) + def test_tasks_and_task_commands_render_local_reports(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: workspace = Path(tmp_dir) @@ -100,11 +287,14 @@ class AgentSlashCommandTests(unittest.TestCase): tasks_result = agent.run('/tasks') task_result = agent.run(f'/task {task_id}') todo_result = agent.run('/todo in_progress') + next_result = agent.run('/task-next') self.assertIn('# Tasks', tasks_result.final_output) self.assertIn(task_id, tasks_result.final_output) self.assertIn('# Task', task_result.final_output) self.assertIn('in_progress', task_result.final_output) self.assertIn('# Tasks', todo_result.final_output) + self.assertIn('# Next Tasks', next_result.final_output) + self.assertIn(task_id, next_result.final_output) def test_plan_command_renders_local_report(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: @@ -133,6 +323,7 @@ class AgentSlashCommandTests(unittest.TestCase): self.assertIn('# Tools', tools_result.final_output) self.assertIn('`read_file`', tools_result.final_output) self.assertIn('# Status', status_result.final_output) + self.assertIn('Token counter:', status_result.final_output) self.assertIn('Last run: none', status_result.final_output) def test_hooks_and_trust_commands_render_local_reports(self) -> None: diff --git a/tests/test_config_runtime.py b/tests/test_config_runtime.py new file mode 100644 index 0000000..32d96fb --- /dev/null +++ b/tests/test_config_runtime.py @@ -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) diff --git a/tests/test_extended_tools.py b/tests/test_extended_tools.py new file mode 100644 index 0000000..38fff13 --- /dev/null +++ b/tests/test_extended_tools.py @@ -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') diff --git a/tests/test_main.py b/tests/test_main.py index 6f840e4..de848c0 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -127,3 +127,43 @@ class MainCliTests(unittest.TestCase): self.assertEqual(recorded_results, ['First chat reply.', 'Second chat reply.']) self.assertIn('# Agent Chat', recorded_lines) self.assertIn('chat_ended=user_exit', recorded_lines) + + def test_parser_accepts_remote_runtime_commands(self) -> None: + parser = build_parser() + args = parser.parse_args(['remote-profiles', '--cwd', '.']) + self.assertEqual(args.command, 'remote-profiles') + self.assertEqual(args.cwd, '.') + + def test_parser_accepts_account_runtime_commands(self) -> None: + parser = build_parser() + args = parser.parse_args(['account-profiles', '--cwd', '.']) + self.assertEqual(args.command, 'account-profiles') + self.assertEqual(args.cwd, '.') + + def test_parser_accepts_search_runtime_commands(self) -> None: + parser = build_parser() + args = parser.parse_args(['search', 'repo query', '--cwd', '.', '--provider', 'local-search']) + self.assertEqual(args.command, 'search') + self.assertEqual(args.query, 'repo query') + self.assertEqual(args.provider, 'local-search') + self.assertEqual(args.cwd, '.') + + def test_parser_accepts_mcp_runtime_commands(self) -> None: + parser = build_parser() + args = parser.parse_args(['mcp-tools', '--cwd', '.', '--server', 'remote']) + self.assertEqual(args.command, 'mcp-tools') + self.assertEqual(args.server, 'remote') + self.assertEqual(args.cwd, '.') + + def test_parser_accepts_daemon_subcommands(self) -> None: + parser = build_parser() + args = parser.parse_args(['daemon', 'ps']) + self.assertEqual(args.command, 'daemon') + self.assertEqual(args.daemon_command, 'ps') + + def test_parser_accepts_config_runtime_commands(self) -> None: + parser = build_parser() + args = parser.parse_args(['config-get', 'review.mode', '--cwd', '.']) + self.assertEqual(args.command, 'config-get') + self.assertEqual(args.key_path, 'review.mode') + self.assertEqual(args.cwd, '.') diff --git a/tests/test_mcp_runtime.py b/tests/test_mcp_runtime.py index ba006ab..6322092 100644 --- a/tests/test_mcp_runtime.py +++ b/tests/test_mcp_runtime.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import sys import tempfile import unittest from pathlib import Path @@ -36,6 +37,52 @@ def make_urlopen_side_effect(responses: list[dict[str, object]]): class MCPRuntimeTests(unittest.TestCase): + def _write_fake_stdio_server(self, workspace: Path) -> Path: + server_path = workspace / 'fake_mcp_server.py' + server_path.write_text( + ( + 'import json, sys\n' + 'RESOURCES = [{"uri": "mcp://remote/notes", "name": "Remote Notes", "mimeType": "text/plain"}]\n' + 'TOOLS = [{"name": "echo", "description": "Echo text", "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}}}]\n' + 'for raw in sys.stdin:\n' + ' raw = raw.strip()\n' + ' if not raw:\n' + ' continue\n' + ' message = json.loads(raw)\n' + ' method = message.get("method")\n' + ' if method == "initialize":\n' + ' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"protocolVersion": "2025-11-25", "capabilities": {"resources": {}, "tools": {}}, "serverInfo": {"name": "fake-remote", "version": "1.0.0"}}}\n' + ' print(json.dumps(response), flush=True)\n' + ' continue\n' + ' if method == "notifications/initialized":\n' + ' continue\n' + ' if method == "resources/list":\n' + ' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"resources": RESOURCES}}\n' + ' print(json.dumps(response), flush=True)\n' + ' continue\n' + ' if method == "resources/read":\n' + ' uri = message.get("params", {}).get("uri")\n' + ' text = "remote notes via stdio" if uri == "mcp://remote/notes" else "unknown resource"\n' + ' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"contents": [{"uri": uri, "mimeType": "text/plain", "text": text}]}}\n' + ' print(json.dumps(response), flush=True)\n' + ' continue\n' + ' if method == "tools/list":\n' + ' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"tools": TOOLS}}\n' + ' print(json.dumps(response), flush=True)\n' + ' continue\n' + ' if method == "tools/call":\n' + ' params = message.get("params", {})\n' + ' text = params.get("arguments", {}).get("text", "")\n' + ' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"content": [{"type": "text", "text": "echo:" + text}], "isError": False}}\n' + ' print(json.dumps(response), flush=True)\n' + ' continue\n' + ' response = {"jsonrpc": "2.0", "id": message.get("id"), "error": {"code": -32601, "message": "Method not found"}}\n' + ' print(json.dumps(response), flush=True)\n' + ), + encoding='utf-8', + ) + return server_path + def test_runtime_discovers_and_reads_local_resources(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: workspace = Path(tmp_dir) @@ -55,6 +102,38 @@ class MCPRuntimeTests(unittest.TestCase): self.assertEqual(runtime.read_resource('mcp://workspace/inline'), 'inline body') self.assertIn('mcp notes', runtime.read_resource('mcp://workspace/notes')) + def test_runtime_discovers_stdio_server_and_remote_resources_and_tools(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + server_path = self._write_fake_stdio_server(workspace) + (workspace / '.claw-mcp.json').write_text( + json.dumps( + { + 'mcpServers': { + 'remote': { + 'command': sys.executable, + 'args': ['-u', str(server_path)], + } + } + } + ), + encoding='utf-8', + ) + runtime = MCPRuntime.from_workspace(workspace) + resources = runtime.list_resources() + tools = runtime.list_tools() + self.assertEqual(len(runtime.servers), 1) + self.assertTrue(runtime.has_transport_servers()) + self.assertIn('Configured MCP servers: 1', runtime.render_summary()) + self.assertEqual(len(resources), 1) + self.assertEqual(resources[0].uri, 'mcp://remote/notes') + self.assertIn('remote notes via stdio', runtime.read_resource('mcp://remote/notes')) + self.assertEqual(len(tools), 1) + self.assertEqual(tools[0].name, 'echo') + rendered, metadata = runtime.call_tool('echo', arguments={'text': 'hello'}) + self.assertIn('echo:hello', rendered) + self.assertEqual(metadata.get('server_name'), 'remote') + def test_mcp_tools_execute_against_runtime(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: workspace = Path(tmp_dir) @@ -90,6 +169,47 @@ class MCPRuntimeTests(unittest.TestCase): self.assertTrue(read_result.ok) self.assertIn('mcp notes', read_result.content) + def test_mcp_transport_tools_execute_against_runtime(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + server_path = self._write_fake_stdio_server(workspace) + (workspace / '.claw-mcp.json').write_text( + json.dumps( + { + 'mcpServers': { + 'remote': { + 'command': sys.executable, + 'args': ['-u', str(server_path)], + } + } + } + ), + encoding='utf-8', + ) + runtime = MCPRuntime.from_workspace(workspace) + context = build_tool_context( + AgentRuntimeConfig(cwd=workspace), + mcp_runtime=runtime, + ) + list_result = execute_tool( + default_tool_registry(), + 'mcp_list_tools', + {}, + context, + ) + call_result = execute_tool( + default_tool_registry(), + 'mcp_call_tool', + {'tool_name': 'echo', 'arguments': {'text': 'tool-run'}}, + context, + ) + + self.assertTrue(list_result.ok) + self.assertIn('echo', list_result.content) + self.assertTrue(call_result.ok) + self.assertIn('echo:tool-run', call_result.content) + self.assertEqual(call_result.metadata.get('action'), 'mcp_call_tool') + def test_agent_can_use_mcp_tools_in_model_loop(self) -> None: responses = [ { @@ -156,3 +276,75 @@ class MCPRuntimeTests(unittest.TestCase): if message.get('role') == 'tool' ) self.assertIn('mcp notes', tool_message.get('content', '')) + + def test_agent_can_use_transport_backed_mcp_call_tool_in_model_loop(self) -> None: + responses = [ + { + 'choices': [ + { + 'message': { + 'role': 'assistant', + 'content': 'I will call the remote MCP tool.', + 'tool_calls': [ + { + 'id': 'call_1', + 'type': 'function', + 'function': { + 'name': 'mcp_call_tool', + 'arguments': '{"tool_name": "echo", "server": "remote", "arguments": {"text": "agent-call"}}', + }, + } + ], + }, + 'finish_reason': 'tool_calls', + } + ], + 'usage': {'prompt_tokens': 8, 'completion_tokens': 3}, + }, + { + 'choices': [ + { + 'message': { + 'role': 'assistant', + 'content': 'The remote MCP tool replied with echo:agent-call.', + }, + 'finish_reason': 'stop', + } + ], + 'usage': {'prompt_tokens': 6, 'completion_tokens': 3}, + }, + ] + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + server_path = self._write_fake_stdio_server(workspace) + (workspace / '.claw-mcp.json').write_text( + json.dumps( + { + 'mcpServers': { + 'remote': { + 'command': sys.executable, + 'args': ['-u', str(server_path)], + } + } + } + ), + encoding='utf-8', + ) + with patch('src.openai_compat.request.urlopen', side_effect=make_urlopen_side_effect(responses)): + agent = LocalCodingAgent( + model_config=ModelConfig( + model='Qwen/Qwen3-Coder-30B-A3B-Instruct', + base_url='http://127.0.0.1:8000/v1', + ), + runtime_config=AgentRuntimeConfig(cwd=workspace), + ) + result = agent.run('Call the remote MCP echo tool') + + self.assertEqual(result.final_output, 'The remote MCP tool replied with echo:agent-call.') + self.assertEqual(result.tool_calls, 1) + tool_message = next( + message + for message in result.transcript + if message.get('role') == 'tool' + ) + self.assertIn('echo:agent-call', tool_message.get('content', '')) diff --git a/tests/test_plan_runtime.py b/tests/test_plan_runtime.py index d3eb176..4eeb322 100644 --- a/tests/test_plan_runtime.py +++ b/tests/test_plan_runtime.py @@ -51,7 +51,8 @@ class PlanRuntimeTests(unittest.TestCase): }, { 'step': 'Patch the tool registry', - 'status': 'pending', + 'status': 'blocked', + 'depends_on': ['plan_1'], }, ], explanation='Work through the runtime in two phases.', @@ -59,12 +60,15 @@ class PlanRuntimeTests(unittest.TestCase): ) rendered_plan = plan_runtime.render_plan() rendered_tasks = task_runtime.render_tasks() + rendered_task = task_runtime.render_task('plan_2') self.assertEqual(mutation.after_count, 2) self.assertEqual(mutation.synced_tasks, 2) self.assertIn('Inspect the runtime loop', rendered_plan) self.assertIn('Work through the runtime in two phases.', rendered_plan) + self.assertIn('depends_on: plan_1', rendered_plan) self.assertIn('Inspect the runtime loop', rendered_tasks) + self.assertIn('Blocked By: plan_1', rendered_task) def test_plan_tools_execute_against_runtime(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: diff --git a/tests/test_remote_runtime.py b/tests/test_remote_runtime.py new file mode 100644 index 0000000..78e221b --- /dev/null +++ b/tests/test_remote_runtime.py @@ -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) diff --git a/tests/test_search_runtime.py b/tests/test_search_runtime.py new file mode 100644 index 0000000..1a35c9e --- /dev/null +++ b/tests/test_search_runtime.py @@ -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) diff --git a/tests/test_task_runtime.py b/tests/test_task_runtime.py index 4cf5d56..bc4cd57 100644 --- a/tests/test_task_runtime.py +++ b/tests/test_task_runtime.py @@ -46,12 +46,12 @@ class TaskRuntimeTests(unittest.TestCase): status='in_progress', ) assert created.task is not None - runtime.update_task(created.task.task_id, status='done') + runtime.update_task(created.task.task_id, status='completed') rendered_tasks = runtime.render_tasks() rendered_task = runtime.render_task(created.task.task_id) self.assertIn('Implement task runtime', rendered_tasks) - self.assertIn('done', rendered_task) + self.assertIn('completed', rendered_task) def test_task_tools_execute_against_runtime(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: @@ -67,11 +67,17 @@ class TaskRuntimeTests(unittest.TestCase): create_result = execute_tool( default_tool_registry(), 'task_create', - {'title': 'Review task tools', 'status': 'todo'}, + {'title': 'Review task tools', 'status': 'pending'}, context, ) self.assertTrue(create_result.ok) task_id = str(create_result.metadata.get('task_id')) + next_result = execute_tool( + default_tool_registry(), + 'task_next', + {}, + context, + ) list_result = execute_tool( default_tool_registry(), 'task_list', @@ -87,7 +93,7 @@ class TaskRuntimeTests(unittest.TestCase): update_result = execute_tool( default_tool_registry(), 'task_update', - {'task_id': task_id, 'status': 'done'}, + {'task_id': task_id, 'status': 'completed'}, context, ) todo_result = execute_tool( @@ -97,13 +103,85 @@ class TaskRuntimeTests(unittest.TestCase): context, ) + self.assertIn('Review task tools', next_result.content) self.assertIn(task_id, list_result.content) self.assertIn('Review task tools', get_result.content) self.assertTrue(update_result.ok) - self.assertEqual(update_result.metadata.get('task_status'), 'done') + self.assertEqual(update_result.metadata.get('task_status'), 'completed') self.assertTrue(todo_result.ok) self.assertEqual(todo_result.metadata.get('total_tasks'), 1) + def test_next_tasks_respects_dependencies_and_completion(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + runtime = TaskRuntime.from_workspace(workspace) + runtime.replace_tasks( + [ + {'task_id': 'scan', 'title': 'Scan workspace', 'status': 'pending'}, + { + 'task_id': 'patch', + 'title': 'Patch files', + 'status': 'blocked', + 'blocked_by': ['scan'], + }, + ] + ) + first_next = runtime.render_next_tasks() + runtime.complete_task('scan') + second_next = runtime.render_next_tasks() + + self.assertIn('Scan workspace', first_next) + self.assertNotIn('Patch files', first_next) + self.assertIn('Patch files', second_next) + + def test_task_execution_tools_handle_block_start_and_complete(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + runtime = TaskRuntime.from_workspace(workspace) + runtime.replace_tasks( + [ + {'task_id': 'scan', 'title': 'Scan workspace', 'status': 'pending'}, + { + 'task_id': 'patch', + 'title': 'Patch files', + 'status': 'blocked', + 'blocked_by': ['scan'], + }, + ] + ) + context = build_tool_context( + AgentRuntimeConfig( + cwd=workspace, + permissions=AgentPermissions(allow_file_write=True), + ), + task_runtime=runtime, + ) + blocked_start = execute_tool( + default_tool_registry(), + 'task_start', + {'task_id': 'patch'}, + context, + ) + complete_scan = execute_tool( + default_tool_registry(), + 'task_complete', + {'task_id': 'scan'}, + context, + ) + start_patch = execute_tool( + default_tool_registry(), + 'task_start', + {'task_id': 'patch', 'owner': 'agent_1', 'active_form': 'Patching files'}, + context, + ) + + self.assertTrue(blocked_start.ok) + self.assertIn('[blocked]', blocked_start.content) + self.assertTrue(complete_scan.ok) + self.assertTrue(start_patch.ok) + self.assertIn('[in_progress]', start_patch.content) + self.assertEqual(runtime.get_task('patch').owner, 'agent_1') + def test_agent_can_use_task_tools_in_model_loop(self) -> None: responses = [ { @@ -118,7 +196,7 @@ class TaskRuntimeTests(unittest.TestCase): 'type': 'function', 'function': { 'name': 'task_create', - 'arguments': '{"title": "Review runtime tasks", "status": "todo"}', + 'arguments': '{"title": "Review runtime tasks", "status": "pending"}', }, } ], diff --git a/tests/test_tokenizer_runtime.py b/tests/test_tokenizer_runtime.py new file mode 100644 index 0000000..19ae060 --- /dev/null +++ b/tests/test_tokenizer_runtime.py @@ -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)