diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 18357dc..89defe0 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -61,6 +61,23 @@ Done: - [x] Manifest-defined plugin `beforeTool` guidance - [x] Manifest-defined plugin tool-result guidance injected back into the transcript - [x] Plugin runtime session-state persistence and resume restoration +- [x] Manifest-based hook/policy runtime discovery +- [x] Hook/policy before-prompt runtime injection +- [x] Hook/policy after-turn runtime events +- [x] Hook/policy tool preflight guidance +- [x] Hook/policy tool blocking +- [x] Hook/policy after-tool guidance +- [x] Hook/policy budget override loading +- [x] Hook/policy safe-environment overlay for shell tools +- [x] Local manifest-backed MCP resource discovery +- [x] Local MCP resource listing and reading +- [x] MCP-backed runtime tools for local resource access +- [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] 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 @@ -103,16 +120,16 @@ Done: - [x] `agent-prompt` command - [x] `agent-context` command - [x] `agent-context-raw` command +- [x] Local background session mode +- [x] Local background session listing (`agent-ps`) +- [x] Local background session logs (`agent-logs`) +- [x] Local background attach snapshot (`agent-attach`) +- [x] Local background kill flow (`agent-kill`) - [x] Inventory/helper commands such as `summary`, `manifest`, `commands`, and `tools` Missing: - [ ] Daemon worker mode -- [ ] Background session mode -- [ ] Session process listing (`ps`) -- [ ] Background session logs -- [ ] Background attach flow -- [ ] Background kill flow - [ ] Remote-control / bridge runtime mode - [ ] Browser/native-host runtime mode - [ ] Computer-use MCP mode @@ -133,6 +150,10 @@ Done: - [x] Environment-aware prompt sections - [x] User context reminder injection - [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] Local planning guidance section in the Python system prompt +- [x] Local task guidance section in the Python system prompt Missing: @@ -160,6 +181,11 @@ Done: - [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 plan runtime summary injection +- [x] Manifest-based task runtime summary injection Missing: @@ -182,9 +208,20 @@ Done: - [x] `/usage` - [x] `/context-raw` - [x] `/env` +- [x] `/mcp` +- [x] `/resources` +- [x] `/resource` +- [x] `/plan` +- [x] `/planner` +- [x] `/tasks` +- [x] `/todo` +- [x] `/task` - [x] `/prompt` - [x] `/system-prompt` - [x] `/permissions` +- [x] `/hooks` +- [x] `/policy` +- [x] `/trust` - [x] `/model` - [x] `/tools` - [x] `/memory` @@ -196,7 +233,7 @@ Missing: - [ ] Full npm slash-command surface - [ ] Slash commands backed by MCP integration -- [ ] Slash commands tied to task/plan systems +- [ ] 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 with richer interactive behavior - [ ] Slash commands tied to plugins and bundled skills @@ -213,22 +250,29 @@ Done: - [x] `glob_search` - [x] `grep_search` - [x] `bash` +- [x] `mcp_list_resources` +- [x] `mcp_read_resource` +- [x] `plan_get` +- [x] `update_plan` +- [x] `plan_clear` +- [x] `delegate_agent` +- [x] `task_list` +- [x] `task_get` +- [x] `task_create` +- [x] `task_update` +- [x] `todo_write` Missing: -- [ ] Agent spawning tool +- [ ] Agent spawning tool parity beyond the current `delegate_agent` runtime tool - [ ] Skill tool - [ ] Notebook edit tool - [ ] Web fetch tool - [ ] Web search tool -- [ ] Todo write tool - [ ] Ask-user-question tool - [ ] LSP tool -- [ ] MCP resource listing tool -- [ ] MCP resource read tool - [ ] Tool search tool - [ ] Config tool -- [ ] Task create/get/update/list tools - [ ] Team create/delete tools - [ ] Send-message tool - [ ] Terminal capture tool @@ -246,14 +290,18 @@ Done: - [x] Basic local command dispatch for the Python runtime - [x] Inventory view of mirrored command names +- [x] Local persistent task runtime with create/get/list/update flows +- [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 background task management for agent worker sessions Missing: - [ ] Real implementation of the larger upstream command tree -- [ ] Task orchestration system -- [ ] Planner / task execution parity +- [ ] Task orchestration system beyond the current local plan/task sync runtime +- [ ] Planner / task execution parity beyond the current local plan persistence and sync flow - [ ] Team / collaboration command flows -- [ ] Background task management - [ ] Command-specific session behaviors - [ ] Full `src/commands/*` parity - [ ] Full `src/tasks/*` parity @@ -266,16 +314,19 @@ Done: - [x] Write-gated mode - [x] Shell-gated mode - [x] Unsafe mode for destructive shell actions +- [x] Local hook/policy manifest discovery +- [x] Hook before-prompt and after-turn runtime handling +- [x] Hook/policy tool preflight, deny, and after-tool handling +- [x] Policy budget override loading +- [x] Managed settings loading and reporting +- [x] Safe environment loading for shell tool context +- [x] Trust reporting and hook/policy slash commands +- [x] Permission-denial runtime events for policy/tool blocks Missing: -- [ ] Hooks runtime - [ ] Tool-permission workflow parity -- [ ] Policy limit loading -- [ ] Managed settings loading - [ ] Trust-gated initialization -- [ ] Safe environment loading parity -- [ ] More exact denial tracking - [ ] Hook-config management - [ ] Full hooks and policy parity @@ -284,13 +335,15 @@ Missing: Done: - [x] Placeholder mirrored package layout for plugins, skills, services, and remote subsystems +- [x] Local manifest-backed MCP discovery +- [x] Local MCP resource listing and reading +- [x] MCP-backed runtime tools for local resource access Missing: - [ ] Real MCP client support - [ ] MCP server integration -- [ ] MCP resource listing and reading -- [ ] MCP-backed tools +- [ ] Full MCP-backed tool parity beyond the current local resource list/read tools - [ ] Plugin discovery and loading - [ ] Bundled plugin support - [ ] Plugin lifecycle management @@ -323,12 +376,12 @@ Missing: Done: - [x] Session save/resume on local disk +- [x] Local background agent processes +- [x] Local background attach/log/kill workflows Missing: - [ ] Remote execution modes -- [ ] Background agent processes -- [ ] Background attach/log/kill workflows - [ ] Team runtime features - [ ] Team messaging features - [ ] Shared remote state @@ -383,6 +436,13 @@ Working Python runtime today: - [x] `src/agent_session.py` - [x] `src/agent_slash_commands.py` - [x] `src/agent_types.py` +- [x] `src/mcp_runtime.py` +- [x] `src/plan_runtime.py` +- [x] `src/plugin_runtime.py` +- [x] `src/hook_policy.py` +- [x] `src/background_runtime.py` +- [x] `src/task.py` +- [x] `src/task_runtime.py` - [x] `src/openai_compat.py` - [x] `src/session_store.py` - [x] `src/permissions.py` @@ -405,9 +465,10 @@ 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 -- [ ] Implement hooks and policy flows +- [ ] Expand hooks and policy parity beyond the current manifest/runtime implementation - [ ] Build a real interactive REPL / TUI - [ ] Add tokenizer-accurate context accounting -- [ ] Add background and remote session modes +- [ ] Expand background session parity beyond the current local worker/log/attach model +- [ ] Add real remote session modes - [ ] Port more of the command/task system - [ ] Close the gap between the mirrored workspace and the working runtime diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 81680f6..e898df3 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -54,6 +54,8 @@ pip install -e . ```bash python3 -m src.main --help python3 -m src.main agent --help +python3 -m src.main agent-bg --help +python3 -m src.main agent-ps --help python3 -m src.main agent-chat --help python3 -m src.main agent-resume --help ``` @@ -73,8 +75,12 @@ 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 "/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 "/tools" --cwd .. python3 -m src.main agent "/memory" --cwd .. python3 -m src.main agent "/status" --cwd .. @@ -113,6 +119,67 @@ python3 -m src.main agent-context --cwd .. --add-dir /path/to/directory python3 -m src.main agent-context --cwd .. --disable-claude-md ``` +### 4.6 Hook/policy context and trust inspection + +Create a local policy file: + +```bash +mkdir -p ./test_cases +cat > ./test_cases/.claw-policy.json <<'EOF' +{ + "trusted": false, + "managedSettings": { + "reviewMode": "strict" + }, + "safeEnv": ["HOOK_SAFE_TOKEN"], + "hooks": { + "beforePrompt": ["Respect workspace policy before acting."], + "afterTurn": ["Persist the policy decision after each turn."], + "beforeTool": { + "read_file": ["Validate the path before reading."] + } + } +} +EOF +export HOOK_SAFE_TOKEN=demo-secret +``` + +Inspect the runtime view: + +```bash +python3 -m src.main agent "/hooks" --cwd ./test_cases +python3 -m src.main agent "/trust" --cwd ./test_cases +python3 -m src.main agent "/permissions" --cwd ./test_cases +python3 -m src.main agent "/tools" --cwd ./test_cases +python3 -m src.main agent-context-raw --cwd ./test_cases +python3 -m src.main agent-prompt --cwd ./test_cases +``` + +### 4.7 Safe environment values in shell tools + +```bash +python3 -m src.main agent \ + "Run bash and print HOOK_SAFE_TOKEN, then explain where it came from." \ + --cwd ./test_cases \ + --allow-shell \ + --show-transcript +``` + +### 4.8 Plan runtime context and prompt inspection + +```bash +python3 -m src.main agent \ + "Use update_plan to store a two-step plan for inspecting and editing the workspace." \ + --cwd ./test_cases \ + --allow-write \ + --show-transcript + +python3 -m src.main agent "/plan" --cwd ./test_cases +python3 -m src.main agent "/tasks" --cwd ./test_cases +python3 -m src.main agent-context-raw --cwd ./test_cases +python3 -m src.main agent-prompt --cwd ./test_cases +``` + ## 5. Core Agent Loop ### 5.1 Read-only run @@ -227,6 +294,52 @@ python3 -m src.main agent \ --unsafe ``` +### 6.7 Hook/policy tool blocking + +Update the policy to block `bash`: + +```bash +cat > ./test_cases/.claw-policy.json <<'EOF' +{ + "trusted": false, + "denyTools": ["bash"], + "hooks": { + "beforePrompt": ["Respect workspace policy before acting."], + "afterTurn": ["Persist the policy decision after each turn."] + } +} +EOF +``` + +Then test the block: + +```bash +python3 -m src.main agent \ + "Try to run bash and then explain what was blocked." \ + --cwd ./test_cases \ + --allow-shell \ + --show-transcript +``` + +Look for: + +- `hook_policy_tool_block` +- `tool_permission_denial` +- `plugin_tool_runtime` messages that now also include hook/policy guidance when present + +### 6.8 Plan tools + +```bash +python3 -m src.main agent \ + "Use update_plan to store these steps: inspect the repository, implement the change, run verification. Mark the first step in_progress and sync the plan to tasks." \ + --cwd ./test_cases \ + --allow-write \ + --show-transcript + +python3 -m src.main agent "/plan" --cwd ./test_cases +python3 -m src.main agent "/tasks" --cwd ./test_cases +``` + ## 7. Session Persistence And Resume ### 7.1 Create a saved session @@ -261,7 +374,50 @@ python3 -m src.main agent-resume \ ls -lt .port_sessions/agent ``` -## 8. Structured Output / JSON Schema +## 8. Background Sessions + +### 8.1 Launch a background session + +Use a local slash-command prompt first so you can verify the background workflow without depending on the model backend: + +```bash +python3 -m src.main agent-bg "/help" --cwd ./test_cases +``` + +This prints: + +- `background_id=...` +- `pid=...` +- `log_path=...` +- `record_path=...` + +### 8.2 List background sessions + +```bash +python3 -m src.main agent-ps +``` + +### 8.3 Read background logs + +```bash +python3 -m src.main agent-logs +python3 -m src.main agent-logs --tail 40 +``` + +### 8.4 Attach to the current output snapshot + +```bash +python3 -m src.main agent-attach +python3 -m src.main agent-attach --tail 40 +``` + +### 8.5 Kill a running background session + +```bash +python3 -m src.main agent-kill +``` + +## 9. Structured Output / JSON Schema Create a schema file: @@ -290,9 +446,9 @@ python3 -m src.main agent \ --response-schema-strict ``` -## 9. Budgets And Limits +## 10. Budgets And Limits -### 9.1 Total token budget +### 10.1 Total token budget ```bash python3 -m src.main agent \ @@ -301,7 +457,7 @@ python3 -m src.main agent \ --max-total-tokens 50 ``` -### 9.2 Input / output token budgets +### 10.2 Input / output token budgets ```bash python3 -m src.main agent \ @@ -311,7 +467,7 @@ python3 -m src.main agent \ --max-output-tokens 80 ``` -### 9.3 Reasoning-token budget +### 10.3 Reasoning-token budget ```bash python3 -m src.main agent \ @@ -320,7 +476,7 @@ python3 -m src.main agent \ --max-reasoning-tokens 10 ``` -### 9.4 Tool-call budget +### 10.4 Tool-call budget ```bash python3 -m src.main agent \ @@ -329,7 +485,7 @@ python3 -m src.main agent \ --max-tool-calls 1 ``` -### 9.5 Delegated-task budget +### 10.5 Delegated-task budget ```bash python3 -m src.main agent \ @@ -338,7 +494,7 @@ python3 -m src.main agent \ --max-delegated-tasks 1 ``` -### 9.6 Cost budget +### 10.6 Cost budget ```bash python3 -m src.main agent \ @@ -349,7 +505,7 @@ python3 -m src.main agent \ --max-budget-usd 0.000001 ``` -### 9.7 Model-call budget +### 10.7 Model-call budget ```bash python3 -m src.main agent \ @@ -358,7 +514,7 @@ python3 -m src.main agent \ --max-model-calls 1 ``` -### 9.8 Session-turn budget +### 10.8 Session-turn budget ```bash python3 -m src.main agent \ @@ -367,9 +523,27 @@ python3 -m src.main agent \ --max-session-turns 1 ``` -## 10. Streaming, Continuation, And Context Reduction +### 10.9 Budget overrides from local policy -### 10.1 Streaming assistant output +```bash +cat > ./test_cases/.claw-policy.json <<'EOF' +{ + "budget": { + "max_model_calls": 0 + } +} +EOF + +python3 -m src.main agent \ + "Say hello once." \ + --cwd ./test_cases +``` + +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 + +### 11.1 Streaming assistant output ```bash python3 -m src.main agent \ @@ -379,7 +553,7 @@ python3 -m src.main agent \ --show-transcript ``` -### 10.2 Automatic continuation after truncation +### 11.2 Automatic continuation after truncation Use a small output budget so the backend is more likely to stop early: @@ -391,7 +565,7 @@ python3 -m src.main agent \ --show-transcript ``` -### 10.3 Snipping older context +### 11.3 Snipping older context ```bash python3 -m src.main agent \ @@ -402,7 +576,7 @@ python3 -m src.main agent \ --show-transcript ``` -### 10.4 Compaction boundaries +### 11.4 Compaction boundaries ```bash python3 -m src.main agent \ @@ -413,9 +587,9 @@ python3 -m src.main agent \ --show-transcript ``` -## 11. File History Replay +## 12. File History Replay -### 11.1 Create file history +### 12.1 Create file history ```bash python3 -m src.main agent \ @@ -424,7 +598,7 @@ python3 -m src.main agent \ --allow-write ``` -### 11.2 Resume and inspect replay +### 12.2 Resume and inspect replay ```bash python3 -m src.main agent-resume \ @@ -436,9 +610,9 @@ python3 -m src.main agent-resume \ Look for `file_history_replay` messages in the transcript. -## 12. Nested Delegation +## 13. Nested Delegation -### 12.1 Basic delegated subtask +### 13.1 Basic delegated subtask ```bash python3 -m src.main agent \ @@ -447,7 +621,7 @@ python3 -m src.main agent \ --show-transcript ``` -### 12.2 Multiple delegated subtasks +### 13.2 Multiple delegated subtasks ```bash python3 -m src.main agent \ @@ -456,7 +630,7 @@ python3 -m src.main agent \ --show-transcript ``` -### 12.3 Resume a delegated child session +### 13.3 Resume a delegated child session 1. Seed a normal saved session: @@ -475,7 +649,7 @@ python3 -m src.main agent \ --show-transcript ``` -### 12.4 Topological dependency batches +### 13.4 Topological dependency batches ```bash python3 -m src.main agent \ @@ -490,7 +664,7 @@ Look for: - `delegate_group_result` - `batch_index=...` -## 13. Plugin Runtime +## 14. Plugin Runtime Create a local plugin manifest: @@ -531,14 +705,14 @@ cat > ./test_cases/plugins/demo/plugin.json <<'EOF' EOF ``` -### 13.1 Plugin prompt/context discovery +### 14.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 ``` -### 13.2 Plugin alias tool +### 14.2 Plugin alias tool ```bash echo "hello plugin" > ./test_cases/hello.txt @@ -548,7 +722,7 @@ python3 -m src.main agent \ --show-transcript ``` -### 13.3 Plugin virtual tool +### 14.3 Plugin virtual tool ```bash python3 -m src.main agent \ @@ -557,7 +731,7 @@ python3 -m src.main agent \ --show-transcript ``` -### 13.4 Plugin before/after tool guidance +### 14.4 Plugin before/after tool guidance ```bash python3 -m src.main agent \ @@ -566,7 +740,7 @@ python3 -m src.main agent \ --show-transcript ``` -### 13.5 Plugin lifecycle with resume/persist +### 14.5 Plugin lifecycle with resume/persist 1. Start a session: @@ -591,9 +765,138 @@ Look for: - `Plugin resume hooks:` - `Plugin runtime state:` -## 14. Query Engine And Workspace Commands +## 15. MCP Runtime -### 14.1 Workspace inventory +Create a local MCP manifest: + +```bash +mkdir -p ./test_cases_mcp +printf 'mcp notes\n' > ./test_cases_mcp/notes.txt +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" + } + ] + } + ] +} +EOF +``` + +### 15.1 MCP context and slash commands + +```bash +python3 -m src.main agent "/mcp" --cwd ./test_cases_mcp +python3 -m src.main agent "/resources" --cwd ./test_cases_mcp +python3 -m src.main agent "/resource mcp://workspace/notes" --cwd ./test_cases_mcp +python3 -m src.main agent "/mcp (MCP)" --cwd ./test_cases_mcp +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 + +```bash +python3 -m src.main agent \ + "List the available MCP resources, then read mcp://workspace/notes and summarize it." \ + --cwd ./test_cases_mcp \ + --show-transcript +``` + +### 15.3 Read inline MCP resources + +```bash +python3 -m src.main agent \ + "Read the MCP resource mcp://workspace/inline and repeat its content." \ + --cwd ./test_cases_mcp \ + --show-transcript +``` + +## 16. Task Runtime + +Create a clean task workspace: + +```bash +mkdir -p ./test_cases_tasks +rm -rf ./test_cases_tasks/.port_sessions +``` + +### 16.1 Task slash commands + +```bash +python3 -m src.main agent "/tasks" --cwd ./test_cases_tasks +python3 -m src.main agent "/todo" --cwd ./test_cases_tasks +python3 -m src.main agent "/task missing-task-id" --cwd ./test_cases_tasks +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 + +```bash +python3 -m src.main agent \ + "Create a task called Review runtime tasks, then list the current tasks." \ + --cwd ./test_cases_tasks \ + --allow-write \ + --show-transcript +``` + +Then inspect the stored task file: + +```bash +cat ./test_cases_tasks/.port_sessions/task_runtime.json +``` + +### 16.3 Replace the todo list + +```bash +python3 -m src.main agent \ + "Replace the current todo list with three tasks: inspect runtime, verify tests, and update docs. Mark inspect runtime as done and the others as todo." \ + --cwd ./test_cases_tasks \ + --allow-write \ + --show-transcript +``` + +### 16.4 Read back task state + +```bash +python3 -m src.main agent "/tasks" --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 + +```bash +python3 -m src.main agent \ + "Use update_plan to create three steps: inspect runtime, verify tests, update docs. Mark inspect runtime completed and sync to tasks." \ + --cwd ./test_cases_tasks \ + --allow-write \ + --show-transcript + +python3 -m src.main agent "/plan" --cwd ./test_cases_tasks +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.1 Workspace inventory ```bash python3 -m src.main summary @@ -603,7 +906,7 @@ python3 -m src.main commands --limit 20 python3 -m src.main tools --limit 20 ``` -### 14.2 Query routing and bootstrap reports +### 17.2 Query routing and bootstrap reports ```bash python3 -m src.main route "inspect the runtime and tools" --limit 10 @@ -611,14 +914,14 @@ 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 ``` -### 14.3 Session flushing for the mirrored workspace +### 17.3 Session flushing for the mirrored workspace ```bash python3 -m src.main flush-transcript "store a temporary transcript" python3 -m src.main load-session ``` -## 15. Remote/Direct Mode Simulations +## 18. Remote/Direct Mode Simulations These are mirrored workspace simulation commands, not the real agent runtime: @@ -630,7 +933,7 @@ python3 -m src.main direct-connect-mode demo-target python3 -m src.main deep-link-mode demo-target ``` -## 16. Parity Tracking Workflow +## 19. Parity Tracking Workflow Use this every time a new feature lands: diff --git a/src/__init__.py b/src/__init__.py index 464ba7a..a850444 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -13,14 +13,19 @@ from .agent_runtime import LocalCodingAgent from .agent_session import AgentMessage, AgentSessionState 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 .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 .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 .tools import PORTED_TOOLS, build_tool_backlog __all__ = [ @@ -31,15 +36,21 @@ __all__ = [ 'AgentRuntimeConfig', 'AgentMessage', 'AgentSessionState', + 'BackgroundSessionRuntime', 'LocalCodingAgent', + 'MCPRuntime', 'ModelConfig', 'ParityAuditResult', + 'PlanRuntime', + 'PlanStep', 'PortManifest', 'PortRuntime', 'PluginRuntime', + 'PortingTask', 'QueryEnginePort', 'RuntimeSession', 'StoredSession', + 'TaskRuntime', 'TurnResult', 'PORTED_COMMANDS', 'PORTED_TOOLS', diff --git a/src/agent_context.py b/src/agent_context.py index cdb15b8..d5965e9 100644 --- a/src/agent_context.py +++ b/src/agent_context.py @@ -9,7 +9,11 @@ from functools import lru_cache from pathlib import Path from .agent_plugin_cache import load_plugin_cache_summary +from .hook_policy import HookPolicyRuntime +from .mcp_runtime import MCPRuntime +from .plan_runtime import PlanRuntime from .plugin_runtime import PluginRuntime +from .task_runtime import TaskRuntime from .agent_types import AgentRuntimeConfig MAX_STATUS_CHARS = 2000 @@ -197,6 +201,35 @@ def _get_user_context_cached( plugin_runtime = PluginRuntime.from_workspace(Path(cwd), additional_working_directories) if plugin_runtime.manifests: context['pluginRuntime'] = plugin_runtime.render_summary() + hook_policy_runtime = HookPolicyRuntime.from_workspace(Path(cwd), additional_working_directories) + if hook_policy_runtime.manifests: + context['hookPolicy'] = hook_policy_runtime.render_summary() + managed_settings = hook_policy_runtime.managed_settings() + if managed_settings: + context['managedSettings'] = '\n'.join( + f'{key}={value}' + for key, value in sorted(managed_settings.items()) + ) + safe_env = hook_policy_runtime.safe_env() + if safe_env: + context['safeEnv'] = '\n'.join( + f'{key}={value}' + for key, value in sorted(safe_env.items()) + ) + context['trustMode'] = ( + 'Workspace trust mode: trusted' + if hook_policy_runtime.is_trusted() + else 'Workspace trust mode: untrusted' + ) + mcp_runtime = MCPRuntime.from_workspace(Path(cwd), additional_working_directories) + if mcp_runtime.resources: + context['mcpRuntime'] = mcp_runtime.render_summary() + plan_runtime = PlanRuntime.from_workspace(Path(cwd)) + if plan_runtime.steps: + context['planRuntime'] = plan_runtime.render_summary() + task_runtime = TaskRuntime.from_workspace(Path(cwd)) + if task_runtime.tasks: + context['taskRuntime'] = task_runtime.render_summary() return context diff --git a/src/agent_prompting.py b/src/agent_prompting.py index ad6b6f7..be77493 100644 --- a/src/agent_prompting.py +++ b/src/agent_prompting.py @@ -91,6 +91,10 @@ def build_system_prompt_parts( get_actions_section(), get_using_your_tools_section(enabled_tool_names), get_plugin_guidance_section(prompt_context), + get_mcp_guidance_section(prompt_context), + get_plan_guidance_section(prompt_context), + get_task_guidance_section(prompt_context), + get_hook_policy_guidance_section(prompt_context), get_tone_and_style_section(), get_output_efficiency_section(), SYSTEM_PROMPT_DYNAMIC_BOUNDARY, @@ -207,6 +211,56 @@ def get_plugin_guidance_section(prompt_context: PromptContext) -> str: return '\n'.join(['# Plugins', *prepend_bullets(items)]) +def get_hook_policy_guidance_section(prompt_context: PromptContext) -> str: + hook_policy = prompt_context.user_context.get('hookPolicy') + trust_mode = prompt_context.user_context.get('trustMode') + if not hook_policy and not trust_mode: + return '' + items = [ + 'Workspace hook and policy manifests may inject trust mode, safe environment values, tool deny rules, and managed settings.', + 'Treat workspace trust mode as high-priority local runtime guidance when deciding whether to edit files or run shell commands.', + 'If a workspace policy blocks a tool, do not retry it unchanged. Change approach or explain the limitation.', + ] + return '\n'.join(['# Hook Policy', *prepend_bullets(items)]) + + +def get_mcp_guidance_section(prompt_context: PromptContext) -> str: + mcp_runtime = prompt_context.user_context.get('mcpRuntime') + if not mcp_runtime: + return '' + items = [ + 'Local MCP manifests may expose additional resources 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.', + ] + return '\n'.join(['# MCP', *prepend_bullets(items)]) + + +def get_task_guidance_section(prompt_context: PromptContext) -> str: + task_runtime = prompt_context.user_context.get('taskRuntime') + if not task_runtime: + return '' + items = [ + '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.', + ] + return '\n'.join(['# Tasks', *prepend_bullets(items)]) + + +def get_plan_guidance_section(prompt_context: PromptContext) -> str: + plan_runtime = prompt_context.user_context.get('planRuntime') + if not plan_runtime: + return '' + items = [ + 'A local runtime plan may be available to track the active multi-step workflow.', + 'Use the update_plan tool to keep the stored plan current when the task spans multiple milestones.', + 'When the plan changes materially, update the stored plan rather than relying only on free-form progress text.', + 'Plan updates can sync into the local task runtime, so keep step statuses accurate.', + ] + return '\n'.join(['# Planning', *prepend_bullets(items)]) + + def get_output_efficiency_section() -> str: return """# Communicating with the user diff --git a/src/agent_runtime.py b/src/agent_runtime.py index 623681e..c2140e8 100644 --- a/src/agent_runtime.py +++ b/src/agent_runtime.py @@ -10,6 +10,8 @@ from uuid import uuid4 from .agent_manager import AgentManager 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 .hook_policy import HookPolicyRuntime +from .mcp_runtime import MCPRuntime from .agent_prompting import ( build_prompt_context, build_system_prompt_parts, @@ -38,7 +40,9 @@ from .agent_types import ( UsageStats, ) from .openai_compat import OpenAICompatClient, OpenAICompatError +from .plan_runtime import PlanRuntime from .plugin_runtime import PluginRuntime +from .task_runtime import TaskRuntime from .session_store import ( StoredAgentSession, load_agent_session, @@ -69,6 +73,10 @@ class LocalCodingAgent: managed_child_index: int | None = None managed_label: str | None = None plugin_runtime: PluginRuntime | None = None + hook_policy_runtime: HookPolicyRuntime | None = None + mcp_runtime: MCPRuntime | None = None + plan_runtime: PlanRuntime | None = None + task_runtime: TaskRuntime | None = None last_session: AgentSessionState | None = field(default=None, init=False, repr=False) last_run_result: AgentRunResult | None = field(default=None, init=False, repr=False) active_session_id: str | None = field(default=None, init=False, repr=False) @@ -86,6 +94,21 @@ class LocalCodingAgent: self.runtime_config.cwd, tuple(str(path) for path in self.runtime_config.additional_working_directories), ) + if self.hook_policy_runtime is None: + self.hook_policy_runtime = HookPolicyRuntime.from_workspace( + self.runtime_config.cwd, + tuple(str(path) for path in self.runtime_config.additional_working_directories), + ) + if self.mcp_runtime is None: + self.mcp_runtime = MCPRuntime.from_workspace( + self.runtime_config.cwd, + tuple(str(path) for path in self.runtime_config.additional_working_directories), + ) + if self.plan_runtime is None: + self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd) + if self.task_runtime is None: + self.task_runtime = TaskRuntime.from_workspace(self.runtime_config.cwd) + self.runtime_config = self._apply_hook_policy_budget_overrides(self.runtime_config) registry = dict(self.tool_registry) plugin_tools = self.plugin_runtime.register_tool_aliases(registry) if plugin_tools: @@ -95,7 +118,17 @@ class LocalCodingAgent: registry = {**registry, **virtual_tools} self.tool_registry = registry self.client = OpenAICompatClient(self.model_config) - self.tool_context = build_tool_context(self.runtime_config) + self.tool_context = build_tool_context( + self.runtime_config, + extra_env=( + self.hook_policy_runtime.safe_env() + if self.hook_policy_runtime is not None + else None + ), + mcp_runtime=self.mcp_runtime, + plan_runtime=self.plan_runtime, + task_runtime=self.task_runtime, + ) def set_model(self, model: str) -> None: self.model_config = replace(self.model_config, model=model) @@ -144,6 +177,67 @@ class LocalCodingAgent: system_context=prompt_context.system_context, ) + def _apply_hook_policy_budget_overrides( + self, + runtime_config: AgentRuntimeConfig, + ) -> AgentRuntimeConfig: + if self.hook_policy_runtime is None or not self.hook_policy_runtime.manifests: + return runtime_config + overrides = self.hook_policy_runtime.budget_overrides() + if not overrides: + return runtime_config + budget = runtime_config.budget_config + return replace( + runtime_config, + budget_config=BudgetConfig( + max_total_tokens=( + budget.max_total_tokens + if budget.max_total_tokens is not None + else _optional_policy_int(overrides.get('max_total_tokens')) + ), + max_input_tokens=( + budget.max_input_tokens + if budget.max_input_tokens is not None + else _optional_policy_int(overrides.get('max_input_tokens')) + ), + max_output_tokens=( + budget.max_output_tokens + if budget.max_output_tokens is not None + else _optional_policy_int(overrides.get('max_output_tokens')) + ), + max_reasoning_tokens=( + budget.max_reasoning_tokens + if budget.max_reasoning_tokens is not None + else _optional_policy_int(overrides.get('max_reasoning_tokens')) + ), + max_total_cost_usd=( + budget.max_total_cost_usd + if budget.max_total_cost_usd is not None + else _optional_policy_float(overrides.get('max_total_cost_usd')) + ), + max_tool_calls=( + budget.max_tool_calls + if budget.max_tool_calls is not None + else _optional_policy_int(overrides.get('max_tool_calls')) + ), + max_delegated_tasks=( + budget.max_delegated_tasks + if budget.max_delegated_tasks is not None + else _optional_policy_int(overrides.get('max_delegated_tasks')) + ), + max_model_calls=( + budget.max_model_calls + if budget.max_model_calls is not None + else _optional_policy_int(overrides.get('max_model_calls')) + ), + max_session_turns=( + budget.max_session_turns + if budget.max_session_turns is not None + else _optional_policy_int(overrides.get('max_session_turns')) + ), + ), + ) + def run(self, prompt: str) -> AgentRunResult: self.managed_agent_id = None self.resume_source_session_id = None @@ -220,7 +314,10 @@ class LocalCodingAgent: ), ) - effective_prompt = self._apply_plugin_before_prompt_hooks(slash_result.prompt or prompt) + effective_prompt = self._apply_hook_policy_before_prompt_hooks( + slash_result.prompt or prompt + ) + effective_prompt = self._apply_plugin_before_prompt_hooks(effective_prompt) effective_prompt = self._apply_plugin_resume_hooks( effective_prompt, resumed=base_session is not None, @@ -431,7 +528,7 @@ class LocalCodingAgent: str(scratchpad_directory) if scratchpad_directory is not None else None ), ) - result = self._append_plugin_after_turn_events( + result = self._append_runtime_after_turn_events( result, prompt=effective_prompt, turn_index=turn_index, @@ -512,7 +609,7 @@ class LocalCodingAgent: str(scratchpad_directory) if scratchpad_directory is not None else None ), ) - result = self._append_plugin_after_turn_events( + result = self._append_runtime_after_turn_events( result, prompt=effective_prompt, turn_index=turn_index, @@ -583,6 +680,9 @@ class LocalCodingAgent: if self.plugin_runtime is not None: self.plugin_runtime.record_tool_attempt(tool_call.name, blocked=False) plugin_preflight_messages = self._plugin_tool_preflight_messages(tool_call.name) + policy_preflight_messages = self._hook_policy_tool_preflight_messages( + tool_call.name + ) if plugin_preflight_messages: stream_events.append( { @@ -593,7 +693,18 @@ class LocalCodingAgent: 'message_count': len(plugin_preflight_messages), } ) + if policy_preflight_messages: + stream_events.append( + { + 'type': 'hook_policy_tool_preflight', + 'tool_name': tool_call.name, + 'tool_call_id': tool_call.id, + 'message_id': session.messages[tool_message_index].message_id, + 'message_count': len(policy_preflight_messages), + } + ) plugin_block_message = self._plugin_block_message(tool_call.name) + policy_block_message = self._hook_policy_block_message(tool_call.name) if plugin_block_message is not None: if self.plugin_runtime is not None: blocked_attempts = int( @@ -621,6 +732,27 @@ class LocalCodingAgent: 'message': plugin_block_message, } ) + if policy_block_message is not None: + tool_result = ToolExecutionResult( + name=tool_call.name, + ok=False, + content=policy_block_message, + metadata={ + 'action': 'hook_policy_block', + 'hook_policy_blocked': True, + 'hook_policy_block_message': policy_block_message, + 'error_kind': 'permission_denied', + }, + ) + stream_events.append( + { + 'type': 'hook_policy_tool_block', + 'tool_name': tool_call.name, + 'tool_call_id': tool_call.id, + 'message_id': session.messages[tool_message_index].message_id, + 'message': policy_block_message, + } + ) if tool_call.name == 'delegate_agent': if tool_result is None: tool_result = self._execute_delegate_agent(tool_call.arguments) @@ -658,6 +790,7 @@ class LocalCodingAgent: metadata=tool_result.metadata, ) plugin_messages = self._plugin_tool_result_messages(tool_call.name) + policy_messages = self._hook_policy_tool_result_messages(tool_call.name) if plugin_messages: merged_metadata = dict(tool_result.metadata) merged_metadata['plugin_messages'] = list(plugin_messages) @@ -677,12 +810,47 @@ class LocalCodingAgent: 'message': message, } ) + if policy_messages: + merged_metadata = dict(tool_result.metadata) + merged_metadata['hook_policy_messages'] = list(policy_messages) + tool_result = ToolExecutionResult( + name=tool_result.name, + ok=tool_result.ok, + content=tool_result.content, + metadata=merged_metadata, + ) + for message in policy_messages: + stream_events.append( + { + 'type': 'hook_policy_tool_hook', + 'tool_name': tool_call.name, + 'tool_call_id': tool_call.id, + 'message_id': session.messages[tool_message_index].message_id, + 'message': message, + } + ) + if tool_result.metadata.get('error_kind') == 'permission_denied': + stream_events.append( + { + 'type': 'tool_permission_denial', + 'tool_name': tool_call.name, + 'tool_call_id': tool_call.id, + 'message_id': session.messages[tool_message_index].message_id, + 'reason': tool_result.content, + 'source': ( + 'hook_policy' + if tool_result.metadata.get('action') == 'hook_policy_block' + else 'tool_runtime' + ), + } + ) session.finalize_tool( tool_message_index, content=serialize_tool_result(tool_result), metadata={ 'phase': 'completed', 'plugin_preflight_messages': list(plugin_preflight_messages), + 'hook_policy_preflight_messages': list(policy_preflight_messages), **dict(tool_result.metadata), }, stop_reason='tool_completed', @@ -707,6 +875,9 @@ class LocalCodingAgent: preflight_messages=plugin_preflight_messages, block_message=plugin_block_message, plugin_messages=plugin_messages, + hook_policy_preflight_messages=policy_preflight_messages, + hook_policy_block_message=policy_block_message, + hook_policy_messages=policy_messages, delegate_preflight_messages=tuple( message for message in tool_result.metadata.get( @@ -774,7 +945,7 @@ class LocalCodingAgent: str(scratchpad_directory) if scratchpad_directory is not None else None ), ) - result = self._append_plugin_after_turn_events( + result = self._append_runtime_after_turn_events( result, prompt=effective_prompt, turn_index=self.runtime_config.max_turns, @@ -2381,6 +2552,39 @@ class LocalCodingAgent: ) return '\n'.join(lines) + def _apply_hook_policy_before_prompt_hooks(self, prompt: str) -> str: + if self.hook_policy_runtime is None or not self.hook_policy_runtime.manifests: + return prompt + injections = self.hook_policy_runtime.before_prompt_messages() + managed_settings = self.hook_policy_runtime.managed_settings() + safe_env = self.hook_policy_runtime.safe_env() + trusted = self.hook_policy_runtime.is_trusted() + if not injections and not managed_settings and not safe_env and trusted: + return prompt + lines = ['', 'Workspace hook/policy guidance:'] + lines.append( + f'- Trust mode: {"trusted" if trusted else "untrusted"}' + ) + if not trusted: + lines.append( + '- Untrusted workspaces should favor inspection-first behavior. ' + 'Avoid unnecessary writes or shell actions unless the task clearly requires them.' + ) + for entry in injections: + lines.append(f'- Before prompt: {entry}') + if managed_settings: + lines.append( + '- Managed settings: ' + + ', '.join(f'{key}={value}' for key, value in sorted(managed_settings.items())) + ) + if safe_env: + lines.append( + '- Safe environment values loaded for tools: ' + + ', '.join(sorted(safe_env)) + ) + lines.extend(['', '', prompt]) + return '\n'.join(lines) + def _build_plugin_tool_runtime_message( self, *, @@ -2388,6 +2592,9 @@ class LocalCodingAgent: preflight_messages: tuple[str, ...], block_message: str | None, plugin_messages: tuple[str, ...], + hook_policy_preflight_messages: tuple[str, ...] = (), + hook_policy_block_message: str | None = None, + hook_policy_messages: tuple[str, ...] = (), delegate_preflight_messages: tuple[str, ...] = (), delegate_after_messages: tuple[str, ...] = (), ) -> str | None: @@ -2395,28 +2602,46 @@ class LocalCodingAgent: block_message is None and not plugin_messages and not preflight_messages + and hook_policy_block_message is None + and not hook_policy_preflight_messages + and not hook_policy_messages and not delegate_preflight_messages and not delegate_after_messages ): return None + plugin_only = ( + hook_policy_block_message is None + and not hook_policy_preflight_messages + and not hook_policy_messages + ) lines = [ '', - f'Plugin tool runtime guidance for `{tool_name}`:', + ( + f'Plugin tool runtime guidance for `{tool_name}`:' + if plugin_only + else f'Runtime tool guidance for `{tool_name}`:' + ), ] for message in preflight_messages: lines.append(f'- Before tool: {message}') + for message in hook_policy_preflight_messages: + lines.append(f'- Hook/policy before tool: {message}') for message in delegate_preflight_messages: lines.append(f'- Before delegate: {message}') if block_message is not None: lines.append(f'- Blocked: {block_message}') + if hook_policy_block_message is not None: + lines.append(f'- Hook/policy blocked: {hook_policy_block_message}') for message in plugin_messages: lines.append(f'- After result: {message}') + for message in hook_policy_messages: + lines.append(f'- Hook/policy after result: {message}') for message in delegate_after_messages: lines.append(f'- After delegate: {message}') lines.extend( [ '', - 'Use this plugin guidance when deciding the next tool call or assistant response.', + 'Use this runtime guidance when deciding the next tool call or assistant response.', '', ] ) @@ -2437,6 +2662,21 @@ class LocalCodingAgent: return () return self.plugin_runtime.tool_result_injections(tool_name) + def _hook_policy_tool_preflight_messages(self, tool_name: str) -> tuple[str, ...]: + if self.hook_policy_runtime is None: + return () + return self.hook_policy_runtime.before_tool_messages(tool_name) + + def _hook_policy_block_message(self, tool_name: str) -> str | None: + if self.hook_policy_runtime is None: + return None + return self.hook_policy_runtime.denied_tool_message(tool_name) + + def _hook_policy_tool_result_messages(self, tool_name: str) -> tuple[str, ...]: + if self.hook_policy_runtime is None: + return () + return self.hook_policy_runtime.after_tool_messages(tool_name) + def _persist_session( self, session: AgentSessionState, @@ -2546,15 +2786,27 @@ class LocalCodingAgent: def render_permissions_report(self) -> str: permissions = self.runtime_config.permissions - return '\n'.join( - [ - '# Permissions', - '', - f'- File write tools: {"enabled" if permissions.allow_file_write else "disabled"}', - f'- Shell commands: {"enabled" if permissions.allow_shell_commands else "disabled"}', - f'- Destructive shell commands: {"enabled" if permissions.allow_destructive_shell_commands else "disabled"}', - ] - ) + lines = [ + '# Permissions', + '', + f'- File write tools: {"enabled" if permissions.allow_file_write else "disabled"}', + f'- Shell commands: {"enabled" if permissions.allow_shell_commands else "disabled"}', + f'- Destructive shell commands: {"enabled" if permissions.allow_destructive_shell_commands else "disabled"}', + ] + if self.hook_policy_runtime is not None and self.hook_policy_runtime.manifests: + lines.append( + f'- Workspace trust mode: {"trusted" if self.hook_policy_runtime.is_trusted() else "untrusted"}' + ) + denied_tools = sorted( + { + name + for manifest in self.hook_policy_runtime.manifests + for name in manifest.deny_tools + } + ) + if denied_tools: + lines.append('- Policy-denied tools: ' + ', '.join(denied_tools)) + return '\n'.join(lines) def render_tools_report(self) -> str: permissions = self.runtime_config.permissions @@ -2565,6 +2817,11 @@ class LocalCodingAgent: state = 'blocked by permissions' if tool.name in {'write_file', 'edit_file'} and not permissions.allow_file_write: state = 'blocked by permissions' + if ( + self.hook_policy_runtime is not None + and self.hook_policy_runtime.denied_tool_message(tool.name) is not None + ): + state = 'blocked by hook policy' lines.append(f'- `{tool.name}`: {tool.description} [{state}]') return '\n'.join(lines) @@ -2575,6 +2832,64 @@ class LocalCodingAgent: return '# Memory\n\nNo CLAUDE.md memory files are currently loaded.' return '\n'.join(['# Memory', '', claude_md]) + 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.' + if query: + return self.mcp_runtime.render_resource_index(query=query) + return '\n'.join(['# MCP', '', self.mcp_runtime.render_summary()]) + + 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 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 self.mcp_runtime.render_resource(uri) + + 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_plan_report(self) -> str: + if self.plan_runtime is None: + return '# Plan\n\nNo local plan runtime is available.' + return self.plan_runtime.render_plan() + + def render_task_report(self, task_id: str) -> str: + if self.task_runtime is None: + return '# Task\n\nNo local task runtime is available.' + return self.task_runtime.render_task(task_id) + + def render_hook_policy_report(self) -> str: + if self.hook_policy_runtime is None: + return '# Hook Policy\n\nNo local hook or policy manifests discovered.' + return '\n'.join(['# Hook Policy', '', self.hook_policy_runtime.render_summary()]) + + def render_trust_report(self) -> str: + trusted = True + settings: dict[str, Any] = {} + env_values: dict[str, str] = {} + if self.hook_policy_runtime is not None: + trusted = self.hook_policy_runtime.is_trusted() + settings = self.hook_policy_runtime.managed_settings() + env_values = self.hook_policy_runtime.safe_env() + lines = [ + '# Trust', + '', + f'- Workspace trust mode: {"trusted" if trusted else "untrusted"}', + ] + if settings: + lines.append('- Managed settings:') + lines.extend(f' - {key}={value}' for key, value in sorted(settings.items())) + if env_values: + lines.append('- Safe environment values:') + lines.extend(f' - {key}={value}' for key, value in sorted(env_values.items())) + return '\n'.join(lines) + def render_status_report(self) -> str: lines = [ '# Status', @@ -2585,6 +2900,16 @@ class LocalCodingAgent: f'- Session ID: {self.active_session_id or "none"}', f'- Last session loaded: {"yes" if self.last_session is not None else "no"}', ] + if self.hook_policy_runtime is not None and self.hook_policy_runtime.manifests: + 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.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: + lines.append(f'- Local tasks: {len(self.task_runtime.tasks)}') if self.last_session_path is not None: lines.append(f'- Session path: {self.last_session_path}') if self.last_run_result is not None: @@ -2689,3 +3014,48 @@ class LocalCodingAgent: } ) return replace(result, events=tuple(appended)) + + def _append_runtime_after_turn_events( + self, + result: AgentRunResult, + *, + prompt: str, + turn_index: int, + ) -> AgentRunResult: + updated = self._append_plugin_after_turn_events( + result, + prompt=prompt, + turn_index=turn_index, + ) + if self.hook_policy_runtime is None: + return updated + injections = self.hook_policy_runtime.after_turn_messages() + if not injections: + return updated + appended = list(updated.events) + for entry in injections: + appended.append( + { + 'type': 'hook_policy_after_turn', + 'turn_index': turn_index, + 'message': entry, + 'prompt_preview': self._preview_text(prompt, 120), + 'stop_reason': updated.stop_reason, + 'trusted': self.hook_policy_runtime.is_trusted(), + } + ) + return replace(updated, events=tuple(appended)) + + +def _optional_policy_int(value: object) -> int | None: + if isinstance(value, bool) or not isinstance(value, int): + return None + return value + + +def _optional_policy_float(value: object) -> float | None: + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + return None diff --git a/src/agent_slash_commands.py b/src/agent_slash_commands.py index f0cdbc9..351bb6e 100644 --- a/src/agent_slash_commands.py +++ b/src/agent_slash_commands.py @@ -77,16 +77,16 @@ def preprocess_slash_command( 'Commands are in the form `/command [args]`.', ) - if parsed.is_mcp: - return _local_result( - input_text, - 'MCP slash commands are not implemented in the Python runtime yet.', - ) - - spec = find_slash_command(parsed.command_name) + normalized_name = ( + parsed.command_name[:-6] + if parsed.is_mcp and parsed.command_name.endswith(' (MCP)') + else parsed.command_name + ) + spec = find_slash_command(normalized_name) if spec is None: if looks_like_command(parsed.command_name): - return _local_result(input_text, f'Unknown skill: {parsed.command_name}') + label = normalized_name if parsed.is_mcp else parsed.command_name + return _local_result(input_text, f'Unknown skill: {label}') return SlashCommandResult(handled=False, should_query=True, prompt=input_text) return spec.handler(agent, parsed.args.strip(), input_text) @@ -109,6 +109,36 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]: description='Show the raw environment, user context, and system context snapshot.', handler=_handle_context_raw, ), + SlashCommandSpec( + names=('mcp',), + description='Show discovered local MCP manifests and resource counts.', + handler=_handle_mcp, + ), + SlashCommandSpec( + names=('resources',), + description='List local MCP resources, optionally filtered by a query string.', + handler=_handle_resources, + ), + SlashCommandSpec( + names=('resource',), + description='Render a local MCP resource by URI.', + handler=_handle_resource, + ), + SlashCommandSpec( + names=('tasks', 'todo'), + description='Show the local runtime task list, optionally filtered by status.', + handler=_handle_tasks, + ), + SlashCommandSpec( + names=('plan', 'planner'), + description='Show the current local runtime plan.', + handler=_handle_plan, + ), + SlashCommandSpec( + names=('task',), + description='Show a local runtime task by id.', + handler=_handle_task, + ), SlashCommandSpec( names=('prompt', 'system-prompt'), description='Render the effective Python system prompt.', @@ -119,6 +149,16 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]: description='Show the active tool permission mode.', handler=_handle_permissions, ), + SlashCommandSpec( + names=('hooks', 'policy'), + description='Show discovered local hook and policy manifests.', + handler=_handle_hooks, + ), + SlashCommandSpec( + names=('trust',), + description='Show workspace trust mode, managed settings, and safe environment values.', + handler=_handle_trust, + ), SlashCommandSpec( names=('model',), description='Show or update the active model for the current agent instance.', @@ -180,6 +220,37 @@ def _handle_context_raw(agent: 'LocalCodingAgent', _args: str, input_text: str) return _local_result(input_text, agent.render_context_snapshot_report()) +def _handle_mcp(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + query = args or None + return _local_result(input_text, agent.render_mcp_report(query)) + + +def _handle_resources(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + query = args or None + return _local_result(input_text, agent.render_mcp_resources_report(query)) + + +def _handle_resource(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + if not args: + return _local_result(input_text, 'Usage: /resource ') + return _local_result(input_text, agent.render_mcp_resource_report(args)) + + +def _handle_tasks(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + status = args or None + return _local_result(input_text, agent.render_tasks_report(status)) + + +def _handle_plan(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult: + return _local_result(input_text, agent.render_plan_report()) + + +def _handle_task(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + if not args: + return _local_result(input_text, 'Usage: /task ') + return _local_result(input_text, agent.render_task_report(args)) + + def _handle_prompt(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult: return _local_result(input_text, agent.render_system_prompt()) @@ -188,6 +259,14 @@ def _handle_permissions(agent: 'LocalCodingAgent', _args: str, input_text: str) return _local_result(input_text, agent.render_permissions_report()) +def _handle_hooks(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult: + return _local_result(input_text, agent.render_hook_policy_report()) + + +def _handle_trust(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult: + return _local_result(input_text, agent.render_trust_report()) + + def _handle_model(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: if not args: return _local_result(input_text, f'Current model: {agent.model_config.model}') diff --git a/src/agent_tools.py b/src/agent_tools.py index fc5c1eb..8fbd01a 100644 --- a/src/agent_tools.py +++ b/src/agent_tools.py @@ -2,16 +2,22 @@ from __future__ import annotations import hashlib import json +import os import re import selectors import subprocess import time from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Callable, Iterator, Union +from typing import TYPE_CHECKING, Any, Callable, Iterator, Union from .agent_types import AgentPermissions, AgentRuntimeConfig, ToolExecutionResult +if TYPE_CHECKING: + from .mcp_runtime import MCPRuntime + from .plan_runtime import PlanRuntime + from .task_runtime import TaskRuntime + class ToolPermissionError(RuntimeError): """Raised when the runtime configuration does not allow a tool action.""" @@ -27,6 +33,10 @@ class ToolExecutionContext: command_timeout_seconds: float max_output_chars: int permissions: AgentPermissions + extra_env: dict[str, str] = field(default_factory=dict) + mcp_runtime: 'MCPRuntime | None' = None + plan_runtime: 'PlanRuntime | None' = None + task_runtime: 'TaskRuntime | None' = None ToolHandler = Callable[ @@ -60,8 +70,20 @@ class AgentTool: else: content, metadata = result, {} return ToolExecutionResult(name=self.name, ok=True, content=content, metadata=metadata) - except (ToolPermissionError, ToolExecutionError, OSError, subprocess.SubprocessError) as exc: - return ToolExecutionResult(name=self.name, ok=False, content=str(exc)) + except ToolPermissionError as exc: + return ToolExecutionResult( + name=self.name, + ok=False, + content=str(exc), + metadata={'error_kind': 'permission_denied'}, + ) + except (ToolExecutionError, OSError, subprocess.SubprocessError) as exc: + return ToolExecutionResult( + name=self.name, + ok=False, + content=str(exc), + metadata={'error_kind': 'tool_execution_error'}, + ) @dataclass(frozen=True) @@ -73,12 +95,23 @@ class ToolStreamUpdate: metadata: dict[str, Any] = field(default_factory=dict) -def build_tool_context(config: AgentRuntimeConfig) -> ToolExecutionContext: +def build_tool_context( + config: AgentRuntimeConfig, + *, + extra_env: dict[str, str] | None = None, + mcp_runtime: 'MCPRuntime | None' = None, + plan_runtime: 'PlanRuntime | None' = None, + task_runtime: 'TaskRuntime | None' = None, +) -> ToolExecutionContext: return ToolExecutionContext( root=config.cwd.resolve(), command_timeout_seconds=config.command_timeout_seconds, max_output_chars=config.max_output_chars, permissions=config.permissions, + extra_env=dict(extra_env or {}), + mcp_runtime=mcp_runtime, + plan_runtime=plan_runtime, + task_runtime=task_runtime, ) @@ -222,6 +255,159 @@ def default_tool_registry() -> dict[str, AgentTool]: }, handler=_run_bash, ), + AgentTool( + name='mcp_list_resources', + description='List local MCP resources discovered from workspace MCP manifests.', + parameters={ + 'type': 'object', + 'properties': { + 'query': {'type': 'string'}, + 'max_resources': {'type': 'integer', 'minimum': 1, 'maximum': 200}, + }, + }, + handler=_mcp_list_resources, + ), + AgentTool( + name='mcp_read_resource', + description='Read a local MCP resource by URI from workspace MCP manifests.', + parameters={ + 'type': 'object', + 'properties': { + 'uri': {'type': 'string'}, + 'max_chars': {'type': 'integer', 'minimum': 1, 'maximum': 50000}, + }, + 'required': ['uri'], + }, + handler=_mcp_read_resource, + ), + AgentTool( + name='plan_get', + description='Show the current local runtime plan.', + parameters={ + 'type': 'object', + 'properties': {}, + }, + handler=_plan_get, + ), + AgentTool( + name='update_plan', + description='Replace the current local runtime plan with a structured multi-step plan and optionally sync it to tasks.', + parameters={ + 'type': 'object', + 'properties': { + 'explanation': {'type': 'string'}, + 'sync_tasks': {'type': 'boolean'}, + 'items': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'step': {'type': 'string'}, + 'status': {'type': 'string'}, + 'task_id': {'type': 'string'}, + 'description': {'type': 'string'}, + 'priority': {'type': 'string'}, + }, + 'required': ['step'], + }, + }, + }, + 'required': ['items'], + }, + handler=_update_plan, + ), + AgentTool( + name='plan_clear', + description='Clear the current local runtime plan and optionally sync the task runtime.', + parameters={ + 'type': 'object', + 'properties': { + 'sync_tasks': {'type': 'boolean'}, + }, + }, + handler=_plan_clear, + ), + AgentTool( + name='task_list', + description='List locally stored runtime tasks.', + parameters={ + 'type': 'object', + 'properties': { + 'status': {'type': 'string'}, + 'max_tasks': {'type': 'integer', 'minimum': 1, 'maximum': 200}, + }, + }, + handler=_task_list, + ), + AgentTool( + name='task_get', + description='Show a locally stored runtime task by id.', + parameters={ + 'type': 'object', + 'properties': { + 'task_id': {'type': 'string'}, + }, + 'required': ['task_id'], + }, + handler=_task_get, + ), + AgentTool( + name='task_create', + description='Create a locally stored runtime task.', + parameters={ + 'type': 'object', + 'properties': { + 'title': {'type': 'string'}, + 'description': {'type': 'string'}, + 'status': {'type': 'string'}, + 'priority': {'type': 'string'}, + 'task_id': {'type': 'string'}, + }, + 'required': ['title'], + }, + handler=_task_create, + ), + AgentTool( + name='task_update', + description='Update a locally stored runtime task by id.', + parameters={ + 'type': 'object', + 'properties': { + 'task_id': {'type': 'string'}, + 'title': {'type': 'string'}, + 'description': {'type': 'string'}, + 'status': {'type': 'string'}, + 'priority': {'type': 'string'}, + }, + 'required': ['task_id'], + }, + handler=_task_update, + ), + AgentTool( + name='todo_write', + description='Replace the current local runtime task list with a structured todo list.', + parameters={ + 'type': 'object', + 'properties': { + 'items': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'task_id': {'type': 'string'}, + 'title': {'type': 'string'}, + 'description': {'type': 'string'}, + 'status': {'type': 'string'}, + 'priority': {'type': 'string'}, + }, + 'required': ['title'], + }, + }, + }, + 'required': ['items'], + }, + handler=_todo_write, + ), AgentTool( name='delegate_agent', description='Delegate a subtask to a nested Python coding agent and return its summary.', @@ -531,6 +717,7 @@ def _run_bash(arguments: dict[str, Any], context: ToolExecutionContext) -> str: capture_output=True, text=True, timeout=context.command_timeout_seconds, + env=_build_subprocess_env(context), ) stdout = completed.stdout or '' stderr = completed.stderr or '' @@ -554,6 +741,204 @@ def _run_bash(arguments: dict[str, Any], context: ToolExecutionContext) -> str: ) +def _mcp_list_resources(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + runtime = _require_mcp_runtime(context) + query = arguments.get('query') + if query is not None and not isinstance(query, str): + raise ToolExecutionError('query must be a string') + max_resources = _coerce_int(arguments, 'max_resources', 50) + resources = runtime.list_resources(query=query, limit=max_resources) + if not resources: + return '(no MCP resources)' + lines: list[str] = [] + for resource in resources: + details = [resource.uri, f'server={resource.server_name}'] + if resource.name: + details.append(f'name={resource.name}') + if resource.mime_type: + details.append(f'mime={resource.mime_type}') + if resource.resolved_path: + details.append(f'path={resource.resolved_path}') + lines.append(' ; '.join(details)) + return '\n'.join(lines) + + +def _mcp_read_resource(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + runtime = _require_mcp_runtime(context) + uri = _require_string(arguments, 'uri') + max_chars = _coerce_int(arguments, 'max_chars', context.max_output_chars) + try: + content = runtime.read_resource(uri, max_chars=max_chars) + except FileNotFoundError as exc: + raise ToolExecutionError(str(exc)) from exc + return content + + +def _task_list(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + runtime = _require_task_runtime(context) + status = arguments.get('status') + if status is not None and not isinstance(status, str): + raise ToolExecutionError('status must be a string') + max_tasks = _coerce_int(arguments, 'max_tasks', 50) + return runtime.render_tasks(status=status, limit=max_tasks) + + +def _task_get(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + runtime = _require_task_runtime(context) + return runtime.render_task(_require_string(arguments, 'task_id')) + + +def _plan_get(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + del arguments + runtime = _require_plan_runtime(context) + return runtime.render_plan() + + +def _update_plan(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + _ensure_write_allowed(context) + runtime = _require_plan_runtime(context) + items = arguments.get('items') + if not isinstance(items, list): + raise ToolExecutionError('items must be an array of plan step objects') + explanation = arguments.get('explanation') + if explanation is not None and not isinstance(explanation, str): + raise ToolExecutionError('explanation must be a string') + sync_tasks = arguments.get('sync_tasks', True) + if not isinstance(sync_tasks, bool): + raise ToolExecutionError('sync_tasks must be a boolean') + mutation = runtime.update_plan( + [item for item in items if isinstance(item, dict)], + explanation=explanation, + task_runtime=context.task_runtime, + sync_tasks=sync_tasks, + ) + return ( + f'updated plan with {mutation.after_count} step(s)', + _plan_mutation_metadata( + action='update_plan', + mutation=mutation, + total_steps=mutation.after_count, + sync_tasks=sync_tasks, + ), + ) + + +def _plan_clear(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + _ensure_write_allowed(context) + runtime = _require_plan_runtime(context) + sync_tasks = arguments.get('sync_tasks', True) + if not isinstance(sync_tasks, bool): + raise ToolExecutionError('sync_tasks must be a boolean') + mutation = runtime.clear_plan( + task_runtime=context.task_runtime if sync_tasks else None, + ) + return ( + 'cleared local plan', + _plan_mutation_metadata( + action='plan_clear', + mutation=mutation, + total_steps=0, + sync_tasks=sync_tasks, + ), + ) + + +def _task_create(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + _ensure_write_allowed(context) + runtime = _require_task_runtime(context) + title = _require_string(arguments, 'title') + description = arguments.get('description') + status = arguments.get('status', 'todo') + priority = arguments.get('priority') + task_id = arguments.get('task_id') + 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): + raise ToolExecutionError('status must be a string') + if priority is not None and not isinstance(priority, str): + 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') + mutation = runtime.create_task( + title=title, + description=description, + status=status or 'todo', + priority=priority, + task_id=task_id, + ) + task = mutation.task + assert task is not None + return ( + f'created task {task.task_id}: {task.title} [{task.status}]', + _task_mutation_metadata( + action='task_create', + mutation=mutation, + task_id=task.task_id, + task_status=task.status, + total_tasks=mutation.after_count, + ), + ) + + +def _task_update(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + _ensure_write_allowed(context) + runtime = _require_task_runtime(context) + task_id = _require_string(arguments, 'task_id') + title = arguments.get('title') + description = arguments.get('description') + status = arguments.get('status') + priority = arguments.get('priority') + for key, value in ( + ('title', title), + ('description', description), + ('status', status), + ('priority', priority), + ): + if value is not None and not isinstance(value, str): + raise ToolExecutionError(f'{key} must be a string') + try: + mutation = runtime.update_task( + task_id, + title=title, + description=description, + status=status, + priority=priority, + ) + except KeyError as exc: + raise ToolExecutionError(f'Unknown task id: {task_id}') from exc + task = mutation.task + assert task is not None + return ( + f'updated task {task.task_id}: {task.title} [{task.status}]', + _task_mutation_metadata( + action='task_update', + 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) + items = arguments.get('items') + if not isinstance(items, list): + raise ToolExecutionError('items must be an array of task objects') + mutation = runtime.replace_tasks( + [item for item in items if isinstance(item, dict)] + ) + return ( + f'replaced todo list with {mutation.after_count} task(s)', + _task_mutation_metadata( + action='todo_write', + mutation=mutation, + total_tasks=mutation.after_count, + ), + ) + + def _stream_bash( arguments: dict[str, Any], context: ToolExecutionContext, @@ -570,6 +955,7 @@ def _stream_bash( stderr=subprocess.PIPE, text=True, bufsize=1, + env=_build_subprocess_env(context), ) except (ToolPermissionError, ToolExecutionError, OSError, subprocess.SubprocessError) as exc: yield ToolStreamUpdate( @@ -687,6 +1073,95 @@ def _delegate_agent_placeholder( ) +def _require_mcp_runtime(context: ToolExecutionContext): + if context.mcp_runtime is None or not context.mcp_runtime.resources: + raise ToolExecutionError( + 'No local MCP resources are available. Add a .claw-mcp.json or .mcp.json manifest first.' + ) + return context.mcp_runtime + + +def _require_plan_runtime(context: ToolExecutionContext): + if context.plan_runtime is None: + raise ToolExecutionError('Local plan runtime is not available.') + return context.plan_runtime + + +def _require_task_runtime(context: ToolExecutionContext): + if context.task_runtime is None: + raise ToolExecutionError('Local task runtime is not available.') + return context.task_runtime + + +def _task_mutation_metadata( + *, + action: str, + mutation, + task_id: str | None = None, + task_status: str | None = None, + total_tasks: int, +) -> dict[str, Any]: + try: + relative_path = str(Path(mutation.store_path).relative_to(Path.cwd())) + except ValueError: + relative_path = str(mutation.store_path) + payload: dict[str, Any] = { + 'action': action, + 'path': relative_path, + 'before_sha256': mutation.before_sha256, + 'after_sha256': mutation.after_sha256, + 'before_preview': mutation.before_preview, + 'after_preview': mutation.after_preview, + 'before_task_count': mutation.before_count, + 'after_task_count': mutation.after_count, + 'total_tasks': total_tasks, + } + if task_id is not None: + payload['task_id'] = task_id + if task_status is not None: + payload['task_status'] = task_status + return payload + + +def _plan_mutation_metadata( + *, + action: str, + mutation, + total_steps: int, + sync_tasks: bool, +) -> dict[str, Any]: + try: + relative_path = str(Path(mutation.store_path).relative_to(Path.cwd())) + except ValueError: + relative_path = str(mutation.store_path) + payload: dict[str, Any] = { + 'action': action, + 'path': relative_path, + 'before_sha256': mutation.before_sha256, + 'after_sha256': mutation.after_sha256, + 'before_preview': mutation.before_preview, + 'after_preview': mutation.after_preview, + 'before_plan_count': mutation.before_count, + 'after_plan_count': mutation.after_count, + 'total_steps': total_steps, + 'sync_tasks': sync_tasks, + } + if mutation.explanation is not None: + payload['explanation'] = mutation.explanation + if mutation.synced_task_store_path is not None: + try: + payload['synced_task_store_path'] = str( + Path(mutation.synced_task_store_path).relative_to(Path.cwd()) + ) + except ValueError: + payload['synced_task_store_path'] = mutation.synced_task_store_path + if mutation.synced_task_sha256 is not None: + payload['synced_task_sha256'] = mutation.synced_task_sha256 + if mutation.synced_tasks: + payload['synced_tasks'] = mutation.synced_tasks + return payload + + def _drain_registered_streams( selector: selectors.BaseSelector, stdout_chunks: list[str], @@ -721,6 +1196,12 @@ def _drain_registered_streams( pass +def _build_subprocess_env(context: ToolExecutionContext) -> dict[str, str]: + env = os.environ.copy() + env.update(context.extra_env) + return env + + def _stream_static_text_result( result: ToolExecutionResult, *, diff --git a/src/background_runtime.py b/src/background_runtime.py new file mode 100644 index 0000000..cb554fb --- /dev/null +++ b/src/background_runtime.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +import json +import os +import signal +import subprocess +import sys +import time +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from uuid import uuid4 + + +DEFAULT_BACKGROUND_DIR = Path('.port_sessions') / 'background' +_DETACHED_PROCESSES: dict[int, subprocess.Popen[Any]] = {} + + +@dataclass(frozen=True) +class BackgroundSessionRecord: + background_id: str + pid: int + prompt: str + workspace_cwd: str + model: str + mode: str + status: str + log_path: str + record_path: str + started_at: str + command: tuple[str, ...] + finished_at: str | None = None + exit_code: int | None = None + stop_reason: str | None = None + session_id: str | None = None + session_path: str | None = None + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> 'BackgroundSessionRecord': + return cls( + background_id=str(payload.get('background_id') or ''), + pid=int(payload.get('pid') or 0), + prompt=str(payload.get('prompt') or ''), + workspace_cwd=str(payload.get('workspace_cwd') or ''), + model=str(payload.get('model') or ''), + mode=str(payload.get('mode') or 'agent'), + status=str(payload.get('status') or 'unknown'), + log_path=str(payload.get('log_path') or ''), + record_path=str(payload.get('record_path') or ''), + started_at=str(payload.get('started_at') or ''), + command=tuple( + str(item) + for item in payload.get('command', []) + if isinstance(item, (str, int, float)) + ), + finished_at=( + str(payload.get('finished_at')) + if isinstance(payload.get('finished_at'), str) and payload.get('finished_at') + else None + ), + exit_code=( + int(payload.get('exit_code')) + if isinstance(payload.get('exit_code'), int) + else None + ), + stop_reason=( + str(payload.get('stop_reason')) + if isinstance(payload.get('stop_reason'), str) and payload.get('stop_reason') + else None + ), + session_id=( + str(payload.get('session_id')) + if isinstance(payload.get('session_id'), str) and payload.get('session_id') + else None + ), + session_path=( + str(payload.get('session_path')) + if isinstance(payload.get('session_path'), str) and payload.get('session_path') + else None + ), + ) + + +class BackgroundSessionRuntime: + def __init__(self, root: Path | None = None) -> None: + self.root = (root or DEFAULT_BACKGROUND_DIR).resolve() + self.root.mkdir(parents=True, exist_ok=True) + + def create_id(self) -> str: + return f'bg_{uuid4().hex[:12]}' + + def record_path(self, background_id: str) -> Path: + return self.root / f'{background_id}.json' + + def log_path(self, background_id: str) -> Path: + return self.root / f'{background_id}.log' + + def launch( + self, + command: list[str], + *, + prompt: str, + workspace_cwd: Path, + model: str, + mode: str = 'agent', + background_id: str | None = None, + process_cwd: Path | None = None, + ) -> BackgroundSessionRecord: + background_id = background_id or self.create_id() + log_path = self.log_path(background_id) + record_path = self.record_path(background_id) + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open('a', encoding='utf-8') as handle: + process = subprocess.Popen( + command, + stdout=handle, + stderr=subprocess.STDOUT, + cwd=str(process_cwd or Path.cwd()), + start_new_session=True, + ) + _DETACHED_PROCESSES[process.pid] = process + record = BackgroundSessionRecord( + background_id=background_id, + pid=process.pid, + prompt=prompt, + workspace_cwd=str(workspace_cwd), + model=model, + mode=mode, + status='running', + log_path=str(log_path), + record_path=str(record_path), + started_at=_utc_now(), + command=tuple(command), + ) + self.save_record(record) + return record + + def save_record(self, record: BackgroundSessionRecord) -> Path: + path = Path(record.record_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(asdict(record), ensure_ascii=True, indent=2), encoding='utf-8') + return path + + def load_record(self, background_id: str) -> BackgroundSessionRecord: + data = json.loads(self.record_path(background_id).read_text(encoding='utf-8')) + record = BackgroundSessionRecord.from_dict(data) + return self.refresh_record(record) + + def list_records(self) -> tuple[BackgroundSessionRecord, ...]: + records: list[BackgroundSessionRecord] = [] + for path in sorted(self.root.glob('bg_*.json')): + try: + payload = json.loads(path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError): + continue + records.append(self.refresh_record(BackgroundSessionRecord.from_dict(payload))) + return tuple(sorted(records, key=lambda item: item.started_at, reverse=True)) + + def refresh_record(self, record: BackgroundSessionRecord) -> BackgroundSessionRecord: + if record.status != 'running': + return record + if _is_process_running(record.pid): + return record + updated = BackgroundSessionRecord( + background_id=record.background_id, + pid=record.pid, + prompt=record.prompt, + workspace_cwd=record.workspace_cwd, + model=record.model, + mode=record.mode, + status='exited', + log_path=record.log_path, + record_path=record.record_path, + started_at=record.started_at, + command=record.command, + finished_at=record.finished_at or _utc_now(), + exit_code=record.exit_code, + stop_reason=record.stop_reason, + session_id=record.session_id, + session_path=record.session_path, + ) + self.save_record(updated) + process = _DETACHED_PROCESSES.pop(record.pid, None) + if process is not None and process.returncode is None: + process.returncode = updated.exit_code + return updated + + def mark_finished( + self, + background_id: str, + *, + exit_code: int, + stop_reason: str | None = None, + session_id: str | None = None, + session_path: str | None = None, + status: str | None = None, + ) -> BackgroundSessionRecord: + record = self.load_record(background_id) + final_status = status or ('completed' if exit_code == 0 else 'failed') + updated = BackgroundSessionRecord( + background_id=record.background_id, + pid=record.pid, + prompt=record.prompt, + workspace_cwd=record.workspace_cwd, + model=record.model, + mode=record.mode, + status=final_status, + log_path=record.log_path, + record_path=record.record_path, + started_at=record.started_at, + command=record.command, + finished_at=_utc_now(), + exit_code=exit_code, + stop_reason=stop_reason, + session_id=session_id, + session_path=session_path, + ) + self.save_record(updated) + process = _DETACHED_PROCESSES.pop(record.pid, None) + if process is not None and process.returncode is None: + process.returncode = updated.exit_code + return updated + + def kill(self, background_id: str) -> BackgroundSessionRecord: + record = self.load_record(background_id) + if record.status != 'running': + return record + try: + os.killpg(record.pid, signal.SIGTERM) + except OSError: + try: + os.kill(record.pid, signal.SIGTERM) + except OSError: + pass + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline: + try: + waited_pid, _ = os.waitpid(record.pid, os.WNOHANG) + if waited_pid == record.pid: + break + except ChildProcessError: + break + except OSError: + break + if not _is_process_running(record.pid): + break + time.sleep(0.05) + updated = BackgroundSessionRecord( + background_id=record.background_id, + pid=record.pid, + prompt=record.prompt, + workspace_cwd=record.workspace_cwd, + model=record.model, + mode=record.mode, + status='killed', + log_path=record.log_path, + record_path=record.record_path, + started_at=record.started_at, + command=record.command, + finished_at=_utc_now(), + exit_code=-signal.SIGTERM, + stop_reason='killed', + session_id=record.session_id, + session_path=record.session_path, + ) + self.save_record(updated) + process = _DETACHED_PROCESSES.pop(record.pid, None) + if process is not None and process.returncode is None: + process.returncode = updated.exit_code + return updated + + def read_logs(self, background_id: str, *, tail: int | None = None) -> str: + record = self.load_record(background_id) + path = Path(record.log_path) + if not path.exists(): + return '' + text = path.read_text(encoding='utf-8', errors='replace') + if tail is None or tail <= 0: + return text + lines = text.splitlines() + return '\n'.join(lines[-tail:]) + + def render_ps(self) -> str: + records = self.list_records() + lines = ['# Background Sessions', ''] + if not records: + lines.append('No local background sessions are currently recorded.') + return '\n'.join(lines) + for record in records: + parts = [ + record.background_id, + f'status={record.status}', + f'pid={record.pid}', + f'model={record.model}', + f'cwd={record.workspace_cwd}', + ] + if record.exit_code is not None: + parts.append(f'exit_code={record.exit_code}') + lines.append('- ' + '; '.join(parts)) + lines.append(f' prompt: {_snapshot_text(record.prompt)}') + return '\n'.join(lines) + + def render_logs(self, background_id: str, *, tail: int | None = None) -> str: + record = self.load_record(background_id) + log_text = self.read_logs(background_id, tail=tail) + lines = [ + '# Background Logs', + '', + f'- Background session: {record.background_id}', + f'- Status: {record.status}', + f'- PID: {record.pid}', + f'- Log path: {record.log_path}', + '', + log_text.rstrip() or '(empty log)', + ] + return '\n'.join(lines) + + def render_attach(self, background_id: str, *, tail: int | None = None) -> str: + record = self.load_record(background_id) + lines = [ + '# Background Attach', + '', + f'- Background session: {record.background_id}', + f'- Status: {record.status}', + f'- Workspace cwd: {record.workspace_cwd}', + ] + if record.session_id: + lines.append(f'- Agent session id: {record.session_id}') + if record.session_path: + lines.append(f'- Agent session path: {record.session_path}') + lines.extend(['', self.read_logs(background_id, tail=tail).rstrip() or '(empty log)']) + return '\n'.join(lines) + + +def build_background_worker_command( + *, + background_id: str, + prompt: str, + forwarded_args: list[str], +) -> list[str]: + return [ + sys.executable, + '-m', + 'src.main', + 'agent-bg-worker', + background_id, + prompt, + *forwarded_args, + ] + + +def _is_process_running(pid: int) -> bool: + if pid <= 0: + return False + try: + os.kill(pid, 0) + except OSError: + return False + return True + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _snapshot_text(text: str, limit: int = 140) -> str: + normalized = ' '.join(text.split()) + if len(normalized) <= limit: + return normalized + return normalized[: limit - 3] + '...' diff --git a/src/hook_policy.py b/src/hook_policy.py new file mode 100644 index 0000000..7a5a0ea --- /dev/null +++ b/src/hook_policy.py @@ -0,0 +1,339 @@ +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +@dataclass(frozen=True) +class HookPolicyManifest: + path: str + trusted: bool | None = None + managed_settings: dict[str, Any] = field(default_factory=dict) + safe_env_names: tuple[str, ...] = () + deny_tools: tuple[str, ...] = () + deny_tool_prefixes: tuple[str, ...] = () + before_prompt: tuple[str, ...] = () + after_turn: tuple[str, ...] = () + before_tool: dict[str, tuple[str, ...]] = field(default_factory=dict) + after_tool: dict[str, tuple[str, ...]] = field(default_factory=dict) + budget_overrides: dict[str, int | float] = field(default_factory=dict) + + +@dataclass +class HookPolicyRuntime: + manifests: tuple[HookPolicyManifest, ...] = field(default_factory=tuple) + + @classmethod + def from_workspace( + cls, + cwd: Path, + additional_working_directories: tuple[str, ...] = (), + ) -> 'HookPolicyRuntime': + manifests: list[HookPolicyManifest] = [] + for path in _discover_policy_paths(cwd, additional_working_directories): + manifest = _load_policy_manifest(path) + if manifest is not None: + manifests.append(manifest) + return cls(manifests=tuple(manifests)) + + def is_trusted(self) -> bool: + explicit = [ + manifest.trusted + for manifest in self.manifests + if manifest.trusted is not None + ] + if not explicit: + return True + return explicit[-1] + + def managed_settings(self) -> dict[str, Any]: + merged: dict[str, Any] = {} + for manifest in self.manifests: + merged.update(manifest.managed_settings) + return merged + + def safe_env(self) -> dict[str, str]: + values: dict[str, str] = {} + for manifest in self.manifests: + for name in manifest.safe_env_names: + value = os.environ.get(name) + if value is not None: + values[name] = value + return values + + def budget_overrides(self) -> dict[str, int | float]: + merged: dict[str, int | float] = {} + for manifest in self.manifests: + merged.update(manifest.budget_overrides) + return merged + + def before_prompt_messages(self) -> tuple[str, ...]: + return tuple( + message + for manifest in self.manifests + for message in manifest.before_prompt + ) + + def after_turn_messages(self) -> tuple[str, ...]: + return tuple( + message + for manifest in self.manifests + for message in manifest.after_turn + ) + + def before_tool_messages(self, tool_name: str) -> tuple[str, ...]: + return self._tool_messages(tool_name, attr='before_tool') + + def after_tool_messages(self, tool_name: str) -> tuple[str, ...]: + return self._tool_messages(tool_name, attr='after_tool') + + def denied_tool_message(self, tool_name: str) -> str | None: + lowered = tool_name.lower() + for manifest in self.manifests: + if lowered in manifest.deny_tools: + return ( + f'Workspace hook policy blocked tool {tool_name}. ' + f'Policy file: {manifest.path}' + ) + if any(lowered.startswith(prefix) for prefix in manifest.deny_tool_prefixes): + return ( + f'Workspace hook policy blocked tool prefix match for {tool_name}. ' + f'Policy file: {manifest.path}' + ) + return None + + def render_summary(self) -> str: + if not self.manifests: + return 'No local hook or policy manifests discovered.' + lines = [f'Local hook/policy manifests: {len(self.manifests)}'] + lines.append(f'- trusted={self.is_trusted()}') + settings = self.managed_settings() + if settings: + lines.append( + '- managed_settings=' + + ', '.join(f'{key}={value}' for key, value in sorted(settings.items())) + ) + env_values = self.safe_env() + if env_values: + lines.append( + '- safe_env=' + + ', '.join(f'{key}={value}' for key, value in sorted(env_values.items())) + ) + for manifest in self.manifests: + details = [Path(manifest.path).name] + if manifest.trusted is not None: + details.append(f'trusted={manifest.trusted}') + if manifest.deny_tools: + details.append(f'deny_tools={len(manifest.deny_tools)}') + if manifest.deny_tool_prefixes: + details.append(f'deny_prefixes={len(manifest.deny_tool_prefixes)}') + if manifest.before_prompt: + details.append(f'before_prompt={len(manifest.before_prompt)}') + if manifest.after_turn: + details.append(f'after_turn={len(manifest.after_turn)}') + if manifest.before_tool: + details.append(f'before_tool={len(manifest.before_tool)}') + if manifest.after_tool: + details.append(f'after_tool={len(manifest.after_tool)}') + if manifest.budget_overrides: + details.append(f'budget_overrides={len(manifest.budget_overrides)}') + lines.append(f"- {'; '.join(details)}") + return '\n'.join(lines) + + def _tool_messages(self, tool_name: str, *, attr: str) -> tuple[str, ...]: + lowered = tool_name.lower() + messages: list[str] = [] + for manifest in self.manifests: + mapping = getattr(manifest, attr) + if not isinstance(mapping, dict): + continue + wildcard = mapping.get('*', ()) + exact = mapping.get(lowered, ()) + messages.extend(message for message in wildcard if message) + messages.extend(message for message in exact if message) + return tuple(messages) + + +def _discover_policy_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-policy.json') + remember(root / '.codex-policy.json') + remember(root / '.claw-hooks.json') + return tuple(candidates) + + +def _load_policy_manifest(path: Path) -> HookPolicyManifest | None: + try: + payload = json.loads(path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(payload, dict): + return None + hooks = payload.get('hooks') + if not isinstance(hooks, dict): + hooks = {} + return HookPolicyManifest( + path=str(path), + trusted=( + payload.get('trusted') + if isinstance(payload.get('trusted'), bool) + else None + ), + managed_settings=( + dict(payload.get('managedSettings')) + if isinstance(payload.get('managedSettings'), dict) + else dict(payload.get('managed_settings', {})) + if isinstance(payload.get('managed_settings'), dict) + else {} + ), + safe_env_names=_extract_string_tuple( + payload.get('safeEnv') + if payload.get('safeEnv') is not None + else payload.get('safe_env') + ), + deny_tools=tuple( + item.lower() + for item in _extract_string_tuple( + payload.get('denyTools') + if payload.get('denyTools') is not None + else payload.get('deny_tools') + ) + ), + deny_tool_prefixes=tuple( + item.lower() + for item in _extract_string_tuple( + payload.get('denyToolPrefixes') + if payload.get('denyToolPrefixes') is not None + else payload.get('deny_tool_prefixes') + ) + ), + before_prompt=_extract_hook_messages( + hooks.get('beforePrompt') + if hooks.get('beforePrompt') is not None + else hooks.get('before_prompt') + ), + after_turn=_extract_hook_messages( + hooks.get('afterTurn') + if hooks.get('afterTurn') is not None + else hooks.get('after_turn') + ), + before_tool=_extract_tool_hook_messages( + hooks.get('beforeTool') + if hooks.get('beforeTool') is not None + else hooks.get('before_tool') + ), + after_tool=_extract_tool_hook_messages( + hooks.get('afterTool') + if hooks.get('afterTool') is not None + else hooks.get('after_tool') + ), + budget_overrides=_extract_budget_overrides(payload.get('budget')), + ) + + +def _extract_string_tuple(value: Any) -> tuple[str, ...]: + if not isinstance(value, list): + return () + return tuple( + item.strip() + for item in value + if isinstance(item, str) and item.strip() + ) + + +def _extract_hook_messages(value: Any) -> tuple[str, ...]: + if isinstance(value, str) and value.strip(): + return (value.strip(),) + if isinstance(value, list): + return tuple( + item.strip() + for item in value + if isinstance(item, str) and item.strip() + ) + return () + + +def _extract_tool_hook_messages(value: Any) -> dict[str, tuple[str, ...]]: + if not isinstance(value, dict): + return {} + extracted: dict[str, tuple[str, ...]] = {} + for key, raw in value.items(): + if not isinstance(key, str) or not key.strip(): + continue + messages = _extract_hook_messages(raw) + if messages: + extracted[key.strip().lower()] = messages + return extracted + + +def _extract_budget_overrides(value: Any) -> dict[str, int | float]: + if not isinstance(value, dict): + return {} + allowed_keys = { + 'max_total_tokens', + 'max_input_tokens', + 'max_output_tokens', + 'max_reasoning_tokens', + 'max_total_cost_usd', + 'max_tool_calls', + 'max_delegated_tasks', + 'max_model_calls', + 'max_session_turns', + 'maxTotalTokens', + 'maxInputTokens', + 'maxOutputTokens', + 'maxReasoningTokens', + 'maxTotalCostUsd', + 'maxToolCalls', + 'maxDelegatedTasks', + 'maxModelCalls', + 'maxSessionTurns', + } + normalized: dict[str, int | float] = {} + key_map = { + 'maxTotalTokens': 'max_total_tokens', + 'maxInputTokens': 'max_input_tokens', + 'maxOutputTokens': 'max_output_tokens', + 'maxReasoningTokens': 'max_reasoning_tokens', + 'maxTotalCostUsd': 'max_total_cost_usd', + 'maxToolCalls': 'max_tool_calls', + 'maxDelegatedTasks': 'max_delegated_tasks', + 'maxModelCalls': 'max_model_calls', + 'maxSessionTurns': 'max_session_turns', + } + for key, raw in value.items(): + if key not in allowed_keys: + continue + normalized_key = key_map.get(key, key) + if isinstance(raw, bool): + continue + if isinstance(raw, int): + normalized[normalized_key] = raw + elif isinstance(raw, float): + normalized[normalized_key] = raw + return normalized diff --git a/src/main.py b/src/main.py index 7462f02..84cdf14 100644 --- a/src/main.py +++ b/src/main.py @@ -2,11 +2,13 @@ from __future__ import annotations import argparse import os +import sys from pathlib import Path from dataclasses import replace import json from typing import Callable +from .background_runtime import BackgroundSessionRuntime, build_background_worker_command from .agent_runtime import LocalCodingAgent from .agent_types import ( AgentPermissions, @@ -154,6 +156,64 @@ def _build_agent(args: argparse.Namespace) -> LocalCodingAgent: ) +def _append_agent_forwarded_args( + command: list[str], + args: argparse.Namespace, + *, + include_backend: bool, +) -> None: + command.extend(['--cwd', str(args.cwd)]) + command.extend(['--max-turns', str(getattr(args, 'max_turns', 12))]) + if include_backend: + command.extend(['--model', str(args.model)]) + command.extend(['--base-url', str(args.base_url)]) + command.extend(['--api-key', str(args.api_key)]) + command.extend(['--temperature', str(args.temperature)]) + command.extend(['--timeout-seconds', str(args.timeout_seconds)]) + command.extend(['--input-cost-per-million', str(args.input_cost_per_million)]) + command.extend(['--output-cost-per-million', str(args.output_cost_per_million)]) + else: + command.extend(['--model', str(args.model)]) + for path in getattr(args, 'add_dir', []): + command.extend(['--add-dir', str(path)]) + for flag in ( + ('--disable-claude-md', getattr(args, 'disable_claude_md', False)), + ('--allow-write', getattr(args, 'allow_write', False)), + ('--allow-shell', getattr(args, 'allow_shell', False)), + ('--unsafe', getattr(args, 'unsafe', False)), + ('--stream', getattr(args, 'stream', False)), + ('--show-transcript', getattr(args, 'show_transcript', False)), + ( + '--response-schema-strict', + getattr(args, 'response_schema_strict', False), + ), + ): + if flag[1]: + command.append(flag[0]) + for name, value in ( + ('--auto-snip-threshold', getattr(args, 'auto_snip_threshold', None)), + ('--auto-compact-threshold', getattr(args, 'auto_compact_threshold', None)), + ('--compact-preserve-messages', getattr(args, 'compact_preserve_messages', None)), + ('--max-total-tokens', getattr(args, 'max_total_tokens', None)), + ('--max-input-tokens', getattr(args, 'max_input_tokens', None)), + ('--max-output-tokens', getattr(args, 'max_output_tokens', None)), + ('--max-reasoning-tokens', getattr(args, 'max_reasoning_tokens', None)), + ('--max-budget-usd', getattr(args, 'max_budget_usd', None)), + ('--max-tool-calls', getattr(args, 'max_tool_calls', None)), + ('--max-delegated-tasks', getattr(args, 'max_delegated_tasks', None)), + ('--max-model-calls', getattr(args, 'max_model_calls', None)), + ('--max-session-turns', getattr(args, 'max_session_turns', None)), + ('--response-schema-file', getattr(args, 'response_schema_file', None)), + ('--response-schema-name', getattr(args, 'response_schema_name', None)), + ('--scratchpad-root', getattr(args, 'scratchpad_root', None)), + ('--system-prompt', getattr(args, 'system_prompt', None)), + ('--append-system-prompt', getattr(args, 'append_system_prompt', None)), + ('--override-system-prompt', getattr(args, 'override_system_prompt', None)), + ): + if value is not None: + command.extend([name, str(value)]) + + def _add_agent_resume_args(parser: argparse.ArgumentParser) -> None: parser.add_argument('session_id') parser.add_argument('prompt') @@ -487,6 +547,34 @@ def build_parser() -> argparse.ArgumentParser: agent_parser.add_argument('--show-transcript', action='store_true') _add_agent_common_args(agent_parser, include_backend=True) + background_parser = subparsers.add_parser('agent-bg', help='run the Python local-model agent as a local background session') + background_parser.add_argument('prompt') + background_parser.add_argument('--max-turns', type=int, default=12) + background_parser.add_argument('--show-transcript', action='store_true') + _add_agent_common_args(background_parser, include_backend=True) + + background_worker_parser = subparsers.add_parser('agent-bg-worker', help=argparse.SUPPRESS) + background_worker_parser.add_argument('background_id') + background_worker_parser.add_argument('prompt') + background_worker_parser.add_argument('--background-root', required=True) + background_worker_parser.add_argument('--max-turns', type=int, default=12) + background_worker_parser.add_argument('--show-transcript', action='store_true') + _add_agent_common_args(background_worker_parser, include_backend=True) + + ps_parser = subparsers.add_parser('agent-ps', help='list local background agent sessions') + ps_parser.add_argument('--tail', type=int, default=None) + + logs_parser = subparsers.add_parser('agent-logs', help='show logs for a local background agent session') + logs_parser.add_argument('background_id') + logs_parser.add_argument('--tail', type=int, default=None) + + attach_parser = subparsers.add_parser('agent-attach', help='show the current output snapshot for a local background agent session') + attach_parser.add_argument('background_id') + attach_parser.add_argument('--tail', type=int, default=None) + + kill_parser = subparsers.add_parser('agent-kill', help='stop a local background agent session') + 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') @@ -640,6 +728,82 @@ def main(argv: list[str] | None = None) -> int: result = agent.run(args.prompt) _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 + 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, + ) + if args.command == 'agent-ps': + print(BackgroundSessionRuntime().render_ps()) + return 0 + if args.command == 'agent-logs': + print( + BackgroundSessionRuntime().render_logs( + args.background_id, + tail=args.tail, + ) + ) + return 0 + if args.command == 'agent-attach': + print( + BackgroundSessionRuntime().render_attach( + args.background_id, + tail=args.tail, + ) + ) + return 0 + if args.command == 'agent-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 new file mode 100644 index 0000000..b628511 --- /dev/null +++ b/src/mcp_runtime.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +@dataclass(frozen=True) +class MCPResource: + uri: str + server_name: str + source_manifest: str + name: str | None = None + description: str | None = None + mime_type: str | None = None + resolved_path: str | None = None + inline_text: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class MCPRuntime: + resources: tuple[MCPResource, ...] = field(default_factory=tuple) + + @classmethod + def from_workspace( + cls, + cwd: Path, + additional_working_directories: tuple[str, ...] = (), + ) -> 'MCPRuntime': + resources: list[MCPResource] = [] + for path in _discover_manifest_paths(cwd, additional_working_directories): + resources.extend(_load_resources_from_manifest(path)) + return cls(resources=tuple(resources)) + + @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) + return tuple(seen) + + 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() + ) + if limit is not None and limit >= 0: + resources = resources[:limit] + return resources + + def get_resource(self, uri: str) -> MCPResource | None: + for resource in self.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) + + def render_summary(self) -> str: + if not self.resources: + return 'No local MCP manifests or resources discovered.' + lines = [ + f'Local MCP manifests: {len(self.manifests)}', + f'Local MCP resources: {len(self.resources)}', + ] + 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)') + return '\n'.join(lines) + + def render_resource_index( + self, + *, + query: str | None = None, + limit: int = 20, + ) -> str: + resources = self.list_resources(query=query, limit=limit) + if not resources: + return '# MCP Resources\n\nNo matching MCP resources discovered.' + lines = ['# MCP Resources', ''] + for resource in resources: + details = [resource.uri] + details.append(f'server={resource.server_name}') + if resource.name: + details.append(f'name={resource.name}') + if resource.mime_type: + details.append(f'mime={resource.mime_type}') + if resource.resolved_path: + details.append(f'path={resource.resolved_path}') + lines.append('- ' + '; '.join(details)) + return '\n'.join(lines) + + def render_resource(self, uri: str, *, max_chars: int = 12000) -> str: + resource = self.get_resource(uri) + if resource is None: + return f'# MCP Resource\n\nUnknown MCP resource: {uri}' + lines = [ + '# MCP Resource', + '', + f'- URI: {resource.uri}', + f'- Server: {resource.server_name}', + ] + if resource.name: + lines.append(f'- Name: {resource.name}') + if resource.mime_type: + lines.append(f'- MIME Type: {resource.mime_type}') + if resource.resolved_path: + lines.append(f'- Path: {resource.resolved_path}') + lines.extend(['', self.read_resource(uri, max_chars=max_chars)]) + return '\n'.join(lines) + + +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-mcp.json') + remember(root / '.mcp.json') + remember(root / '.codex-mcp.json') + remember(root / 'mcp.json') + return tuple(candidates) + + +def _load_resources_from_manifest(path: Path) -> list[MCPResource]: + try: + payload = json.loads(path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError): + return [] + if not isinstance(payload, dict): + return [] + + resources: list[MCPResource] = [] + if isinstance(payload.get('resources'), list): + resources.extend( + _extract_resources( + payload.get('name') if isinstance(payload.get('name'), str) else 'local', + payload['resources'], + manifest_path=path, + ) + ) + servers = payload.get('servers') + if isinstance(servers, list): + for item in servers: + if not isinstance(item, dict): + continue + name = item.get('name') + if not isinstance(name, str) or not name.strip(): + continue + raw_resources = item.get('resources') + if not isinstance(raw_resources, list): + continue + resources.extend( + _extract_resources(name.strip(), raw_resources, manifest_path=path) + ) + return resources + + +def _extract_resources( + server_name: str, + raw_resources: list[Any], + *, + manifest_path: Path, +) -> list[MCPResource]: + resources: list[MCPResource] = [] + seen_uris: set[str] = set() + 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 + uri = uri.strip() + if uri in seen_uris: + continue + seen_uris.add(uri) + raw_path = item.get('path') + if raw_path is None: + raw_path = item.get('file') + resolved_path: str | None = None + if isinstance(raw_path, str) and raw_path.strip(): + candidate = Path(raw_path).expanduser() + if not candidate.is_absolute(): + candidate = manifest_path.parent / candidate + resolved_path = str(candidate.resolve()) + inline_text = item.get('text') + if not isinstance(inline_text, str): + inline_text = None + metadata = item.get('metadata') + resources.append( + MCPResource( + uri=uri, + server_name=server_name, + source_manifest=str(manifest_path), + 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 + ), + resolved_path=resolved_path, + inline_text=inline_text, + metadata=dict(metadata) if isinstance(metadata, dict) else {}, + ) + ) + return resources + + +def _truncate(text: str, limit: int) -> str: + if len(text) <= limit: + return text + head = text[: limit // 2] + tail = text[-(limit // 2) :] + return f'{head}\n...[truncated]...\n{tail}' diff --git a/src/plan_runtime.py b/src/plan_runtime.py new file mode 100644 index 0000000..217c61f --- /dev/null +++ b/src/plan_runtime.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from .task_runtime import TaskRuntime + + +DEFAULT_PLAN_RUNTIME_PATH = Path('.port_sessions') / 'plan_runtime.json' +VALID_PLAN_STATUSES = ('pending', 'in_progress', 'completed') + + +@dataclass(frozen=True) +class PlanStep: + step: str + status: str = 'pending' + task_id: str | None = None + description: str | None = None + priority: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + 'step': self.step, + 'status': self.status, + 'task_id': self.task_id, + 'description': self.description, + 'priority': self.priority, + } + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> 'PlanStep': + return cls( + step=str(payload.get('step') or ''), + status=_normalize_plan_status(payload.get('status')), + task_id=( + str(payload.get('task_id')) + if isinstance(payload.get('task_id'), str) and payload.get('task_id') + else None + ), + description=( + str(payload.get('description')) + if isinstance(payload.get('description'), str) + and payload.get('description').strip() + else None + ), + priority=( + str(payload.get('priority')) + if isinstance(payload.get('priority'), str) + and payload.get('priority').strip() + else None + ), + ) + + +@dataclass(frozen=True) +class PlanMutation: + explanation: str | None + store_path: str + before_sha256: str | None + after_sha256: str + before_preview: str | None + after_preview: str + before_count: int + after_count: int + synced_tasks: int = 0 + synced_task_store_path: str | None = None + synced_task_sha256: str | None = None + + +@dataclass +class PlanRuntime: + steps: tuple[PlanStep, ...] = field(default_factory=tuple) + explanation: str | None = None + updated_at: str | None = None + storage_path: Path = field(default_factory=lambda: DEFAULT_PLAN_RUNTIME_PATH.resolve()) + + @classmethod + def from_workspace(cls, cwd: Path) -> 'PlanRuntime': + storage_path = (cwd.resolve() / DEFAULT_PLAN_RUNTIME_PATH).resolve() + if not storage_path.exists(): + return cls(storage_path=storage_path) + try: + payload = json.loads(storage_path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError): + return cls(storage_path=storage_path) + raw_steps = payload.get('steps') + if not isinstance(raw_steps, list): + return cls(storage_path=storage_path) + steps: list[PlanStep] = [] + for item in raw_steps: + if not isinstance(item, dict): + continue + step = PlanStep.from_dict(item) + if step.step: + steps.append(step) + explanation = payload.get('explanation') + updated_at = payload.get('updated_at') + return cls( + steps=tuple(steps), + explanation=( + explanation.strip() + if isinstance(explanation, str) and explanation.strip() + else None + ), + updated_at=str(updated_at) if isinstance(updated_at, str) and updated_at else None, + storage_path=storage_path, + ) + + def update_plan( + self, + items: list[dict[str, Any]], + *, + explanation: str | None = None, + task_runtime: TaskRuntime | None = None, + sync_tasks: bool = True, + ) -> PlanMutation: + normalized_steps: list[PlanStep] = [] + for index, item in enumerate(items, start=1): + if not isinstance(item, dict): + continue + step_text = item.get('step') + if not isinstance(step_text, str) or not step_text.strip(): + continue + task_id = item.get('task_id') + normalized_steps.append( + PlanStep( + step=step_text.strip(), + status=_normalize_plan_status(item.get('status')), + task_id=( + task_id.strip() + if isinstance(task_id, str) and task_id.strip() + else f'plan_{index}' + ), + description=( + item.get('description').strip() + if isinstance(item.get('description'), str) + and item.get('description').strip() + else None + ), + priority=( + item.get('priority').strip() + if isinstance(item.get('priority'), str) + and item.get('priority').strip() + else None + ), + ) + ) + mutation = self._persist( + tuple(normalized_steps), + explanation=( + explanation.strip() + if isinstance(explanation, str) and explanation.strip() + else None + ), + ) + if sync_tasks and task_runtime is not None: + task_items = [ + { + 'task_id': step.task_id or f'plan_{index}', + 'title': step.step, + 'description': step.description, + 'status': _plan_status_to_task_status(step.status), + 'priority': step.priority, + } + for index, step in enumerate(self.steps, start=1) + ] + task_mutation = task_runtime.replace_tasks(task_items) + return PlanMutation( + explanation=mutation.explanation, + store_path=mutation.store_path, + before_sha256=mutation.before_sha256, + after_sha256=mutation.after_sha256, + before_preview=mutation.before_preview, + after_preview=mutation.after_preview, + before_count=mutation.before_count, + after_count=mutation.after_count, + synced_tasks=task_mutation.after_count, + synced_task_store_path=task_mutation.store_path, + synced_task_sha256=task_mutation.after_sha256, + ) + return mutation + + def clear_plan(self, *, task_runtime: TaskRuntime | None = None) -> PlanMutation: + mutation = self._persist((), explanation=None) + if task_runtime is not None: + task_mutation = task_runtime.replace_tasks([]) + return PlanMutation( + explanation=None, + store_path=mutation.store_path, + before_sha256=mutation.before_sha256, + after_sha256=mutation.after_sha256, + before_preview=mutation.before_preview, + after_preview=mutation.after_preview, + before_count=mutation.before_count, + after_count=mutation.after_count, + synced_tasks=task_mutation.after_count, + synced_task_store_path=task_mutation.store_path, + synced_task_sha256=task_mutation.after_sha256, + ) + return mutation + + def render_summary(self) -> str: + lines = [ + f'Local plan runtime file: {self.storage_path}', + f'Total plan steps: {len(self.steps)}', + ] + if self.explanation: + lines.append(f'- Explanation: {self.explanation}') + counts: dict[str, int] = {} + for step in self.steps: + counts[step.status] = counts.get(step.status, 0) + 1 + if counts: + lines.append( + '- Status counts: ' + + ', '.join(f'{name}={count}' for name, count in sorted(counts.items())) + ) + if self.updated_at: + lines.append(f'- Updated: {self.updated_at}') + return '\n'.join(lines) + + def render_plan(self) -> str: + if not self.steps: + return '# Plan\n\nNo stored plan is currently available.' + lines = ['# Plan', ''] + if self.explanation: + lines.extend(['## Explanation', self.explanation, '']) + lines.append('## Steps') + for index, step in enumerate(self.steps, start=1): + details = [f'{index}. {step.step}', f'status={step.status}'] + if step.task_id: + details.append(f'task_id={step.task_id}') + if step.priority: + details.append(f'priority={step.priority}') + lines.append('- ' + '; '.join(details)) + if step.description: + lines.append(f' description: {step.description}') + return '\n'.join(lines) + + def _persist( + self, + steps: tuple[PlanStep, ...], + *, + explanation: str | None, + ) -> PlanMutation: + before_count = len(self.steps) + before_text = self._serialize_payload(self.steps, self.explanation, self.updated_at) + before_preview = _snapshot_text(before_text) + before_sha256 = ( + hashlib.sha256(before_text.encode('utf-8')).hexdigest() + if self.storage_path.exists() or self.steps or self.explanation + else None + ) + updated_at = datetime.now(timezone.utc).isoformat() + payload_text = self._serialize_payload(steps, explanation, updated_at) + self.storage_path.parent.mkdir(parents=True, exist_ok=True) + self.storage_path.write_text(payload_text, encoding='utf-8') + self.steps = steps + self.explanation = explanation + self.updated_at = updated_at + after_sha256 = hashlib.sha256(payload_text.encode('utf-8')).hexdigest() + return PlanMutation( + explanation=explanation, + store_path=str(self.storage_path), + before_sha256=before_sha256, + after_sha256=after_sha256, + before_preview=before_preview if before_text.strip() else None, + after_preview=_snapshot_text(payload_text), + before_count=before_count if before_text.strip() else 0, + after_count=len(steps), + ) + + def _serialize_payload( + self, + steps: tuple[PlanStep, ...], + explanation: str | None, + updated_at: str | None, + ) -> str: + payload = { + 'explanation': explanation, + 'updated_at': updated_at, + 'steps': [step.to_dict() for step in steps], + } + return json.dumps(payload, ensure_ascii=True, indent=2) + + +def _normalize_plan_status(value: Any) -> str: + if isinstance(value, str): + lowered = value.strip().lower() + aliases = { + 'todo': 'pending', + 'open': 'pending', + 'done': 'completed', + 'complete': 'completed', + 'in-progress': 'in_progress', + 'in progress': 'in_progress', + } + lowered = aliases.get(lowered, lowered) + if lowered in VALID_PLAN_STATUSES: + return lowered + return 'pending' + + +def _plan_status_to_task_status(status: str) -> str: + if status == 'completed': + return 'done' + if status == 'in_progress': + return 'in_progress' + return 'todo' + + +def _snapshot_text(text: str, limit: int = 240) -> str: + normalized = ' '.join(text.split()) + if len(normalized) <= limit: + return normalized + return normalized[: limit - 3] + '...' diff --git a/src/task.py b/src/task.py index ac89585..dd865d5 100644 --- a/src/task.py +++ b/src/task.py @@ -1,5 +1,78 @@ -from __future__ import annotations - -from .task import PortingTask - -__all__ = ['PortingTask'] +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + + +VALID_TASK_STATUSES = ('todo', 'in_progress', 'done', 'cancelled') + + +@dataclass(frozen=True) +class PortingTask: + task_id: str + title: str + status: str = 'todo' + description: str | None = None + priority: str | None = None + created_at: str = field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) + updated_at: str = field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) + + def to_dict(self) -> dict[str, Any]: + return { + 'task_id': self.task_id, + 'title': self.title, + 'status': self.status, + 'description': self.description, + 'priority': self.priority, + 'created_at': self.created_at, + 'updated_at': self.updated_at, + } + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> 'PortingTask': + return cls( + task_id=str(payload.get('task_id') or payload.get('id') or ''), + title=str(payload.get('title') or ''), + status=_normalize_task_status(payload.get('status')), + description=( + str(payload.get('description')) + if isinstance(payload.get('description'), str) + else None + ), + priority=( + str(payload.get('priority')) + if isinstance(payload.get('priority'), str) + else None + ), + created_at=( + str(payload.get('created_at')) + if isinstance(payload.get('created_at'), str) + else datetime.now(timezone.utc).isoformat() + ), + updated_at=( + str(payload.get('updated_at')) + if isinstance(payload.get('updated_at'), str) + else datetime.now(timezone.utc).isoformat() + ), + ) + + +def _normalize_task_status(value: Any) -> str: + if isinstance(value, str): + lowered = value.strip().lower() + aliases = { + 'in-progress': 'in_progress', + 'in progress': 'in_progress', + 'complete': 'done', + 'completed': 'done', + 'open': 'todo', + } + lowered = aliases.get(lowered, lowered) + if lowered in VALID_TASK_STATUSES: + return lowered + return 'todo' diff --git a/src/task_runtime.py b/src/task_runtime.py new file mode 100644 index 0000000..ea7cc41 --- /dev/null +++ b/src/task_runtime.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, field, replace +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from .task import PortingTask, VALID_TASK_STATUSES + + +DEFAULT_TASK_RUNTIME_PATH = Path('.port_sessions') / 'task_runtime.json' + + +@dataclass(frozen=True) +class TaskMutation: + task: PortingTask | None + store_path: str + before_sha256: str | None + after_sha256: str + before_preview: str | None + after_preview: str + before_count: int + after_count: int + + +@dataclass +class TaskRuntime: + tasks: tuple[PortingTask, ...] = field(default_factory=tuple) + storage_path: Path = field(default_factory=lambda: DEFAULT_TASK_RUNTIME_PATH.resolve()) + + @classmethod + def from_workspace(cls, cwd: Path) -> 'TaskRuntime': + storage_path = (cwd.resolve() / DEFAULT_TASK_RUNTIME_PATH).resolve() + if not storage_path.exists(): + return cls(tasks=(), storage_path=storage_path) + try: + payload = json.loads(storage_path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError): + return cls(tasks=(), storage_path=storage_path) + raw_tasks = payload.get('tasks') + if not isinstance(raw_tasks, list): + return cls(tasks=(), storage_path=storage_path) + tasks: list[PortingTask] = [] + for item in raw_tasks: + if not isinstance(item, dict): + continue + task = PortingTask.from_dict(item) + if not task.task_id or not task.title: + continue + tasks.append(task) + return cls(tasks=tuple(tasks), storage_path=storage_path) + + def list_tasks( + self, + *, + status: str | None = None, + 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 limit is not None and limit >= 0: + tasks = tasks[:limit] + return tasks + + def get_task(self, task_id: str) -> PortingTask | None: + for task in self.tasks: + if task.task_id == task_id: + return task + return None + + def create_task( + self, + *, + title: str, + description: str | None = None, + status: str = 'todo', + priority: str | None = None, + task_id: str | None = None, + ) -> TaskMutation: + task = PortingTask( + task_id=task_id or f'task_{uuid4().hex[:10]}', + title=title.strip(), + 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, + ) + return self._persist((*self.tasks, task), task=task) + + def update_task( + self, + task_id: str, + *, + title: str | None = None, + description: str | None = None, + status: str | None = None, + priority: str | None = None, + ) -> TaskMutation: + existing = self.get_task(task_id) + if existing is None: + raise KeyError(task_id) + updated = replace( + existing, + title=title.strip() if isinstance(title, str) and title.strip() else existing.title, + description=( + description.strip() + if isinstance(description, str) and description.strip() + else None if description == '' + else existing.description + ), + status=_normalize_status(status) if status is not None else existing.status, + priority=( + priority.strip() + if isinstance(priority, str) and priority.strip() + else None if priority == '' + else existing.priority + ), + 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 replace_tasks(self, items: list[dict[str, Any]]) -> TaskMutation: + tasks: list[PortingTask] = [] + now = datetime.now(timezone.utc).isoformat() + for index, item in enumerate(items, start=1): + if not isinstance(item, dict): + continue + title = item.get('title') + if not isinstance(title, str) or not title.strip(): + continue + task_id = item.get('task_id') + if not isinstance(task_id, str) or not task_id.strip(): + task_id = item.get('id') + tasks.append( + PortingTask( + task_id=( + task_id.strip() + if isinstance(task_id, str) and task_id.strip() + else f'task_{index}_{uuid4().hex[:6]}' + ), + title=title.strip(), + description=( + item.get('description').strip() + if isinstance(item.get('description'), str) + and item.get('description').strip() + else None + ), + status=_normalize_status(item.get('status')), + priority=( + item.get('priority').strip() + if isinstance(item.get('priority'), str) + and item.get('priority').strip() + else None + ), + created_at=( + str(item.get('created_at')) + if isinstance(item.get('created_at'), str) + else now + ), + updated_at=now, + ) + ) + mutation = self._persist(tuple(tasks), task=None) + return mutation + + def render_summary(self) -> str: + lines = [ + f'Local task runtime file: {self.storage_path}', + f'Total tasks: {len(self.tasks)}', + ] + counts: dict[str, int] = {} + for task in self.tasks: + counts[task.status] = counts.get(task.status, 0) + 1 + if counts: + lines.append( + '- Status counts: ' + + ', '.join(f'{name}={count}' for name, count in sorted(counts.items())) + ) + if self.tasks: + preview = ', '.join(task.title for task in 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) + if not tasks: + return '# Tasks\n\nNo tasks are currently stored.' + lines = ['# Tasks', ''] + for task in tasks: + details = [task.task_id, f'status={task.status}'] + if task.priority: + details.append(f'priority={task.priority}') + details.append(f'title={task.title}') + lines.append('- ' + '; '.join(details)) + if task.description: + lines.append(f' description: {task.description}') + return '\n'.join(lines) + + def render_task(self, task_id: str) -> str: + task = self.get_task(task_id) + if task is None: + return f'# Task\n\nUnknown task id: {task_id}' + lines = [ + '# Task', + '', + f'- ID: {task.task_id}', + f'- Status: {task.status}', + f'- Title: {task.title}', + ] + if task.priority: + lines.append(f'- Priority: {task.priority}') + if task.description: + lines.append(f'- Description: {task.description}') + lines.append(f'- Updated: {task.updated_at}') + return '\n'.join(lines) + + def _persist( + self, + tasks: tuple[PortingTask, ...], + *, + task: PortingTask | None, + ) -> TaskMutation: + before_text = self._serialize_payload(self.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 + else None + ) + payload_text = self._serialize_payload(tasks) + self.storage_path.parent.mkdir(parents=True, exist_ok=True) + self.storage_path.write_text(payload_text, encoding='utf-8') + self.tasks = tasks + after_sha256 = hashlib.sha256(payload_text.encode('utf-8')).hexdigest() + return TaskMutation( + task=task, + store_path=str(self.storage_path), + before_sha256=before_sha256, + 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, + after_count=len(tasks), + ) + + def _serialize_payload(self, tasks: tuple[PortingTask, ...]) -> str: + payload = { + 'tasks': [task.to_dict() for task in tasks], + } + return json.dumps(payload, ensure_ascii=True, indent=2) + + +def _snapshot_text(text: str, limit: int = 240) -> str: + normalized = ' '.join(text.split()) + if len(normalized) <= limit: + return normalized + return normalized[: limit - 3] + '...' + + +def _normalize_status(value: Any) -> str: + if isinstance(value, str): + lowered = value.strip().lower().replace('-', '_').replace(' ', '_') + aliases = { + 'complete': 'done', + 'completed': 'done', + 'open': 'todo', + } + lowered = aliases.get(lowered, lowered) + if lowered in VALID_TASK_STATUSES: + return lowered + return 'todo' diff --git a/tests/test_agent_context.py b/tests/test_agent_context.py index 7ed34f7..7688455 100644 --- a/tests/test_agent_context.py +++ b/tests/test_agent_context.py @@ -5,13 +5,16 @@ import subprocess import tempfile import unittest from pathlib import Path +from unittest.mock import patch from src.agent_context import ( build_context_snapshot, clear_context_caches, set_system_prompt_injection, ) +from src.plan_runtime import PlanRuntime from src.agent_types import AgentRuntimeConfig +from src.task_runtime import TaskRuntime class AgentContextTests(unittest.TestCase): @@ -57,6 +60,76 @@ class AgentContextTests(unittest.TestCase): self.assertIn('demo-plugin', snapshot.user_context['pluginCache']) self.assertIn('1.2.3', snapshot.user_context['pluginCache']) + def test_user_context_loads_hook_policy_manifest(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) / 'repo' + workspace.mkdir(parents=True) + (workspace / '.claw-policy.json').write_text( + ( + '{"trusted": false, ' + '"managedSettings": {"reviewMode": "strict"}, ' + '"safeEnv": ["HOOK_SAFE_TOKEN"], ' + '"hooks": {"beforePrompt": ["Respect workspace policy."]}}' + ), + encoding='utf-8', + ) + with patch.dict('os.environ', {'HOOK_SAFE_TOKEN': 'demo-secret'}, clear=False): + snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace)) + + self.assertIn('hookPolicy', snapshot.user_context) + self.assertIn('managedSettings', snapshot.user_context) + self.assertIn('safeEnv', snapshot.user_context) + self.assertIn('trustMode', snapshot.user_context) + self.assertIn('reviewMode=strict', snapshot.user_context['managedSettings']) + self.assertIn('HOOK_SAFE_TOKEN=demo-secret', snapshot.user_context['safeEnv']) + self.assertIn('untrusted', snapshot.user_context['trustMode']) + + def test_user_context_loads_mcp_runtime_summary(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) / 'repo' + workspace.mkdir(parents=True) + (workspace / 'notes.txt').write_text('mcp notes\n', encoding='utf-8') + (workspace / '.claw-mcp.json').write_text( + ( + '{"servers":[{"name":"workspace","resources":[' + '{"uri":"mcp://workspace/notes","name":"Notes","path":"notes.txt"}' + ']}]}' + ), + encoding='utf-8', + ) + + snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace)) + + self.assertIn('mcpRuntime', snapshot.user_context) + self.assertIn('Local MCP resources: 1', snapshot.user_context['mcpRuntime']) + + def test_user_context_loads_task_runtime_summary(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) / 'repo' + workspace.mkdir(parents=True) + runtime = TaskRuntime.from_workspace(workspace) + runtime.create_task(title='Review task runtime') + + snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace)) + + self.assertIn('taskRuntime', snapshot.user_context) + self.assertIn('Total tasks: 1', snapshot.user_context['taskRuntime']) + + def test_user_context_loads_plan_runtime_summary(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) / 'repo' + workspace.mkdir(parents=True) + plan_runtime = PlanRuntime.from_workspace(workspace) + plan_runtime.update_plan( + [{'step': 'Inspect the runtime', 'status': 'in_progress'}], + explanation='Use a stored plan.', + ) + + snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace)) + + self.assertIn('planRuntime', snapshot.user_context) + self.assertIn('Total plan steps: 1', snapshot.user_context['planRuntime']) + @unittest.skipIf(shutil.which('git') is None, 'git is required for git context tests') def test_git_status_snapshot_contains_branch_and_status(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: diff --git a/tests/test_agent_prompting.py b/tests/test_agent_prompting.py index 327e5f5..8b8f0ec 100644 --- a/tests/test_agent_prompting.py +++ b/tests/test_agent_prompting.py @@ -5,10 +5,12 @@ import unittest from pathlib import Path from src.agent_prompting import build_prompt_context, build_system_prompt_parts, render_system_prompt +from src.plan_runtime import PlanRuntime from src.agent_runtime import LocalCodingAgent from src.agent_session import AgentSessionState from src.agent_tools import default_tool_registry from src.agent_types import AgentPermissions, AgentRuntimeConfig, ModelConfig +from src.task_runtime import TaskRuntime class AgentPromptingTests(unittest.TestCase): @@ -79,3 +81,83 @@ class AgentPromptingTests(unittest.TestCase): prompt = render_system_prompt(parts) self.assertIn('# Plugins', prompt) + + def test_prompt_builder_mentions_hook_policy_when_manifest_is_loaded(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + (workspace / '.claw-policy.json').write_text( + '{"trusted": false, "hooks": {"beforePrompt": ["Follow workspace policy."]}}', + 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('# Hook Policy', prompt) + + def test_prompt_builder_mentions_mcp_when_manifest_is_loaded(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + (workspace / 'notes.txt').write_text('mcp notes\n', encoding='utf-8') + (workspace / '.claw-mcp.json').write_text( + ( + '{"servers":[{"name":"workspace","resources":[' + '{"uri":"mcp://workspace/notes","name":"Notes","path":"notes.txt"}' + ']}]}' + ), + 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('# MCP', prompt) + + def test_prompt_builder_mentions_tasks_when_runtime_is_loaded(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + runtime = TaskRuntime.from_workspace(workspace) + runtime.create_task(title='Inspect runtime tasks') + 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('# Tasks', prompt) + + def test_prompt_builder_mentions_planning_when_runtime_is_loaded(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + runtime = PlanRuntime.from_workspace(workspace) + runtime.update_plan( + [{'step': 'Inspect runtime planning', 'status': 'pending'}], + explanation='Track the current plan.', + ) + 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('# Planning', prompt) diff --git a/tests/test_agent_runtime.py b/tests/test_agent_runtime.py index 245ce98..eb9cd12 100644 --- a/tests/test_agent_runtime.py +++ b/tests/test_agent_runtime.py @@ -245,6 +245,26 @@ class AgentRuntimeTests(unittest.TestCase): ) self.assertFalse(result.ok) self.assertIn('--allow-write', result.content) + self.assertEqual(result.metadata.get('error_kind'), 'permission_denied') + + def test_build_tool_context_supports_safe_env_overlay_for_shell_tools(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + config = AgentRuntimeConfig( + cwd=Path(tmp_dir), + permissions=AgentPermissions(allow_shell_commands=True), + ) + context = build_tool_context( + config, + extra_env={'HOOK_SAFE_TOKEN': 'demo-secret'}, + ) + result = execute_tool( + default_tool_registry(), + 'bash', + {'command': 'printf %s "$HOOK_SAFE_TOKEN"'}, + context, + ) + self.assertTrue(result.ok) + self.assertIn('demo-secret', result.content) def test_local_slash_command_returns_without_model_call(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: @@ -2379,3 +2399,151 @@ class AgentRuntimeTests(unittest.TestCase): }, }, ) + + def test_hook_policy_before_prompt_injection_and_after_turn_event(self) -> None: + responses = [ + { + 'choices': [ + { + 'message': { + 'role': 'assistant', + 'content': 'Completed under policy guidance.', + }, + 'finish_reason': 'stop', + } + ], + 'usage': {'prompt_tokens': 6, 'completion_tokens': 3}, + } + ] + recorded_payloads: list[dict[str, object]] = [] + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + (workspace / '.claw-policy.json').write_text( + ( + '{"trusted": false, ' + '"managedSettings": {"reviewMode": "strict"}, ' + '"safeEnv": ["HOOK_SAFE_TOKEN"], ' + '"hooks": {"beforePrompt": ["Respect workspace policy."], ' + '"afterTurn": ["Persist the policy decision."]}}' + ), + encoding='utf-8', + ) + with patch.dict('os.environ', {'HOOK_SAFE_TOKEN': 'demo-secret'}, clear=False): + with patch( + 'src.openai_compat.request.urlopen', + side_effect=make_recording_urlopen_side_effect(responses, recorded_payloads), + ): + 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('Inspect the repository.') + payload_text = json.dumps(recorded_payloads[0]) + self.assertIn('Workspace hook/policy guidance', payload_text) + self.assertIn('Respect workspace policy.', payload_text) + self.assertIn('Trust mode: untrusted', payload_text) + self.assertEqual(agent.tool_context.extra_env.get('HOOK_SAFE_TOKEN'), 'demo-secret') + self.assertIn('reviewMode=strict', agent.render_trust_report()) + hook_events = [event for event in result.events if event.get('type') == 'hook_policy_after_turn'] + self.assertEqual(len(hook_events), 1) + self.assertEqual(hook_events[0].get('message'), 'Persist the policy decision.') + + def test_hook_policy_blocks_tool_and_tracks_permission_denial(self) -> None: + responses = [ + { + 'choices': [ + { + 'message': { + 'role': 'assistant', + 'content': 'Trying bash first.', + 'tool_calls': [ + { + 'id': 'call_1', + 'type': 'function', + 'function': { + 'name': 'bash', + 'arguments': '{"command": "pwd"}', + }, + } + ], + }, + 'finish_reason': 'tool_calls', + } + ], + 'usage': {'prompt_tokens': 8, 'completion_tokens': 3}, + }, + { + 'choices': [ + { + 'message': { + 'role': 'assistant', + 'content': 'Bash was blocked by workspace policy.', + }, + 'finish_reason': 'stop', + } + ], + 'usage': {'prompt_tokens': 7, 'completion_tokens': 3}, + }, + ] + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + (workspace / '.claw-policy.json').write_text( + '{"denyTools": ["bash"]}', + 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('Run pwd') + self.assertEqual(result.final_output, 'Bash was blocked by workspace policy.') + event_types = [event.get('type') for event in result.events] + self.assertIn('hook_policy_tool_block', event_types) + self.assertIn('tool_permission_denial', event_types) + tool_message = next( + message + for message in result.transcript + if message.get('role') == 'tool' + ) + self.assertTrue(tool_message['metadata'].get('hook_policy_blocked')) + self.assertEqual(tool_message['metadata'].get('error_kind'), 'permission_denied') + + def test_hook_policy_budget_override_applies_when_runtime_budget_is_unset(self) -> None: + responses = [ + { + 'choices': [ + { + 'message': { + 'role': 'assistant', + 'content': 'One model call happened.', + }, + 'finish_reason': 'stop', + } + ], + 'usage': {'prompt_tokens': 4, 'completion_tokens': 2}, + } + ] + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + (workspace / '.claw-policy.json').write_text( + '{"budget": {"max_model_calls": 0}}', + 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('Hello') + self.assertEqual(result.stop_reason, 'budget_exceeded') + self.assertIn('model-call budget was exceeded', result.final_output) diff --git a/tests/test_agent_slash_commands.py b/tests/test_agent_slash_commands.py index e8e0ab5..939a340 100644 --- a/tests/test_agent_slash_commands.py +++ b/tests/test_agent_slash_commands.py @@ -3,10 +3,13 @@ from __future__ import annotations import tempfile import unittest from pathlib import Path +from unittest.mock import patch from src.agent_runtime import LocalCodingAgent from src.agent_slash_commands import looks_like_command, parse_slash_command from src.agent_types import AgentRuntimeConfig, ModelConfig +from src.plan_runtime import PlanRuntime +from src.task_runtime import TaskRuntime class AgentSlashCommandTests(unittest.TestCase): @@ -53,6 +56,72 @@ class AgentSlashCommandTests(unittest.TestCase): self.assertIn('### Estimated usage by category', result.final_output) self.assertIn('### Memory Files', result.final_output) + def test_mcp_and_resource_commands_render_local_reports(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + (workspace / 'notes.txt').write_text('mcp notes\n', encoding='utf-8') + (workspace / '.claw-mcp.json').write_text( + ( + '{"servers":[{"name":"workspace","resources":[' + '{"uri":"mcp://workspace/notes","name":"Notes","path":"notes.txt"}' + ']}]}' + ), + encoding='utf-8', + ) + agent = LocalCodingAgent( + model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'), + runtime_config=AgentRuntimeConfig(cwd=workspace), + ) + mcp_result = agent.run('/mcp') + resources_result = agent.run('/resources') + resource_result = agent.run('/resource mcp://workspace/notes') + legacy_mcp_result = agent.run('/mcp (MCP)') + self.assertIn('# MCP', mcp_result.final_output) + self.assertIn('Local MCP resources: 1', mcp_result.final_output) + self.assertIn('# MCP Resources', resources_result.final_output) + self.assertIn('mcp://workspace/notes', resources_result.final_output) + self.assertIn('# MCP Resource', resource_result.final_output) + self.assertIn('mcp notes', resource_result.final_output) + self.assertIn('# MCP', legacy_mcp_result.final_output) + + def test_tasks_and_task_commands_render_local_reports(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + runtime = TaskRuntime.from_workspace(workspace) + mutation = runtime.create_task( + title='Review runtime tasks', + status='in_progress', + ) + task_id = mutation.task.task_id if mutation.task is not None else '' + agent = LocalCodingAgent( + model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'), + runtime_config=AgentRuntimeConfig(cwd=workspace), + ) + tasks_result = agent.run('/tasks') + task_result = agent.run(f'/task {task_id}') + todo_result = agent.run('/todo in_progress') + 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) + + def test_plan_command_renders_local_report(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + plan_runtime = PlanRuntime.from_workspace(workspace) + plan_runtime.update_plan( + [{'step': 'Inspect the plan command', 'status': 'in_progress'}], + explanation='Use the local plan runtime.', + ) + agent = LocalCodingAgent( + model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'), + runtime_config=AgentRuntimeConfig(cwd=workspace), + ) + plan_result = agent.run('/plan') + self.assertIn('# Plan', plan_result.final_output) + self.assertIn('Inspect the plan command', plan_result.final_output) + def test_tools_and_status_commands_render_local_reports(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: agent = LocalCodingAgent( @@ -66,6 +135,31 @@ class AgentSlashCommandTests(unittest.TestCase): self.assertIn('# Status', status_result.final_output) self.assertIn('Last run: none', status_result.final_output) + def test_hooks_and_trust_commands_render_local_reports(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + (workspace / '.claw-policy.json').write_text( + ( + '{"trusted": false, ' + '"managedSettings": {"reviewMode": "strict"}, ' + '"safeEnv": ["HOOK_SAFE_TOKEN"]}' + ), + encoding='utf-8', + ) + with patch.dict('os.environ', {'HOOK_SAFE_TOKEN': 'demo-secret'}, clear=False): + agent = LocalCodingAgent( + model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'), + runtime_config=AgentRuntimeConfig(cwd=workspace), + ) + hooks_result = agent.run('/hooks') + trust_result = agent.run('/trust') + self.assertIn('# Hook Policy', hooks_result.final_output) + self.assertIn('Local hook/policy manifests', hooks_result.final_output) + self.assertIn('# Trust', trust_result.final_output) + self.assertIn('untrusted', trust_result.final_output) + self.assertIn('reviewMode=strict', trust_result.final_output) + self.assertIn('HOOK_SAFE_TOKEN=demo-secret', trust_result.final_output) + def test_clear_command_clears_saved_runtime_state(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: agent = LocalCodingAgent( diff --git a/tests/test_background_runtime.py b/tests/test_background_runtime.py new file mode 100644 index 0000000..8590d36 --- /dev/null +++ b/tests/test_background_runtime.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path + +from src.background_runtime import BackgroundSessionRuntime + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +class BackgroundRuntimeTests(unittest.TestCase): + def test_runtime_can_launch_and_kill_generic_process(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + runtime = BackgroundSessionRuntime(workspace / '.port_sessions' / 'background') + record = runtime.launch( + [sys.executable, '-c', 'import time; time.sleep(10)'], + prompt='sleep', + workspace_cwd=workspace, + model='local/test-model', + process_cwd=workspace, + ) + running = runtime.load_record(record.background_id) + killed = runtime.kill(record.background_id) + for _ in range(30): + if runtime.load_record(record.background_id).status != 'running': + break + time.sleep(0.1) + + self.assertEqual(running.status, 'running') + self.assertEqual(killed.status, 'killed') + self.assertEqual(killed.stop_reason, 'killed') + + def test_agent_background_cli_exposes_ps_logs_and_attach(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + run_dir = Path(tmp_dir) + workspace = run_dir / 'workspace' + workspace.mkdir() + env = os.environ.copy() + existing_pythonpath = env.get('PYTHONPATH') + env['PYTHONPATH'] = ( + f'{PROJECT_ROOT}:{existing_pythonpath}' + if existing_pythonpath + else str(PROJECT_ROOT) + ) + launch = subprocess.run( + [ + sys.executable, + '-m', + 'src.main', + 'agent-bg', + '/help', + '--cwd', + str(workspace), + ], + cwd=run_dir, + env=env, + check=True, + capture_output=True, + text=True, + ) + background_id = next( + line.split('=', 1)[1] + for line in launch.stdout.splitlines() + if line.startswith('background_id=') + ) + runtime = BackgroundSessionRuntime(run_dir / '.port_sessions' / 'background') + record = runtime.load_record(background_id) + for _ in range(60): + if record.status in {'completed', 'failed', 'exited'}: + break + time.sleep(0.1) + record = runtime.load_record(background_id) + ps = subprocess.run( + [sys.executable, '-m', 'src.main', 'agent-ps'], + cwd=run_dir, + env=env, + check=True, + capture_output=True, + text=True, + ) + logs = subprocess.run( + [sys.executable, '-m', 'src.main', 'agent-logs', background_id], + cwd=run_dir, + env=env, + check=True, + capture_output=True, + text=True, + ) + attach = subprocess.run( + [sys.executable, '-m', 'src.main', 'agent-attach', background_id], + cwd=run_dir, + env=env, + check=True, + capture_output=True, + text=True, + ) + + self.assertIn(background_id, launch.stdout) + self.assertIn(background_id, ps.stdout) + self.assertIn('# Background Logs', logs.stdout) + self.assertIn('# Slash Commands', logs.stdout) + self.assertIn('# Background Attach', attach.stdout) diff --git a/tests/test_mcp_runtime.py b/tests/test_mcp_runtime.py new file mode 100644 index 0000000..ba006ab --- /dev/null +++ b/tests/test_mcp_runtime.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from src.agent_runtime import LocalCodingAgent +from src.agent_tools import build_tool_context, default_tool_registry, execute_tool +from src.agent_types import AgentRuntimeConfig, ModelConfig +from src.mcp_runtime import MCPRuntime + + +class FakeHTTPResponse: + def __init__(self, payload: dict[str, object]) -> None: + self.payload = payload + + def read(self) -> bytes: + return json.dumps(self.payload).encode('utf-8') + + def __enter__(self) -> 'FakeHTTPResponse': + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + +def make_urlopen_side_effect(responses: list[dict[str, object]]): + queued = [FakeHTTPResponse(payload) for payload in responses] + + def _fake_urlopen(request_obj, timeout=None): # noqa: ANN001 + return queued.pop(0) + + return _fake_urlopen + + +class MCPRuntimeTests(unittest.TestCase): + def test_runtime_discovers_and_reads_local_resources(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + (workspace / 'notes.txt').write_text('mcp notes\n', encoding='utf-8') + (workspace / '.claw-mcp.json').write_text( + ( + '{"servers":[{"name":"workspace","resources":[' + '{"uri":"mcp://workspace/notes","name":"Notes","path":"notes.txt"},' + '{"uri":"mcp://workspace/inline","name":"Inline","text":"inline body"}' + ']}]}' + ), + encoding='utf-8', + ) + runtime = MCPRuntime.from_workspace(workspace) + self.assertEqual(len(runtime.resources), 2) + self.assertIn('Local MCP resources: 2', runtime.render_summary()) + self.assertEqual(runtime.read_resource('mcp://workspace/inline'), 'inline body') + self.assertIn('mcp notes', runtime.read_resource('mcp://workspace/notes')) + + def test_mcp_tools_execute_against_runtime(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + (workspace / 'notes.txt').write_text('mcp notes\n', encoding='utf-8') + (workspace / '.claw-mcp.json').write_text( + ( + '{"servers":[{"name":"workspace","resources":[' + '{"uri":"mcp://workspace/notes","name":"Notes","path":"notes.txt"}' + ']}]}' + ), + 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_resources', + {}, + context, + ) + read_result = execute_tool( + default_tool_registry(), + 'mcp_read_resource', + {'uri': 'mcp://workspace/notes'}, + context, + ) + + self.assertTrue(list_result.ok) + self.assertIn('mcp://workspace/notes', list_result.content) + self.assertTrue(read_result.ok) + self.assertIn('mcp notes', read_result.content) + + def test_agent_can_use_mcp_tools_in_model_loop(self) -> None: + responses = [ + { + 'choices': [ + { + 'message': { + 'role': 'assistant', + 'content': 'I will inspect the MCP resource.', + 'tool_calls': [ + { + 'id': 'call_1', + 'type': 'function', + 'function': { + 'name': 'mcp_read_resource', + 'arguments': '{"uri": "mcp://workspace/notes"}', + }, + } + ], + }, + 'finish_reason': 'tool_calls', + } + ], + 'usage': {'prompt_tokens': 8, 'completion_tokens': 3}, + }, + { + 'choices': [ + { + 'message': { + 'role': 'assistant', + 'content': 'The MCP resource says mcp notes.', + }, + 'finish_reason': 'stop', + } + ], + 'usage': {'prompt_tokens': 6, 'completion_tokens': 3}, + }, + ] + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + (workspace / 'notes.txt').write_text('mcp notes\n', encoding='utf-8') + (workspace / '.claw-mcp.json').write_text( + ( + '{"servers":[{"name":"workspace","resources":[' + '{"uri":"mcp://workspace/notes","name":"Notes","path":"notes.txt"}' + ']}]}' + ), + 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('Read the MCP notes resource') + + self.assertEqual(result.final_output, 'The MCP resource says mcp notes.') + self.assertEqual(result.tool_calls, 1) + tool_message = next( + message + for message in result.transcript + if message.get('role') == 'tool' + ) + self.assertIn('mcp notes', tool_message.get('content', '')) diff --git a/tests/test_plan_runtime.py b/tests/test_plan_runtime.py new file mode 100644 index 0000000..d3eb176 --- /dev/null +++ b/tests/test_plan_runtime.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from src.agent_runtime import LocalCodingAgent +from src.agent_tools import build_tool_context, default_tool_registry, execute_tool +from src.agent_types import AgentPermissions, AgentRuntimeConfig, ModelConfig +from src.plan_runtime import PlanRuntime +from src.task_runtime import TaskRuntime + + +class FakeHTTPResponse: + def __init__(self, payload: dict[str, object]) -> None: + self.payload = payload + + def read(self) -> bytes: + return json.dumps(self.payload).encode('utf-8') + + def __enter__(self) -> 'FakeHTTPResponse': + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + +def make_urlopen_side_effect(responses: list[dict[str, object]]): + queued = [FakeHTTPResponse(payload) for payload in responses] + + def _fake_urlopen(request_obj, timeout=None): # noqa: ANN001 + return queued.pop(0) + + return _fake_urlopen + + +class PlanRuntimeTests(unittest.TestCase): + def test_runtime_persists_and_syncs_plan_to_tasks(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + task_runtime = TaskRuntime.from_workspace(workspace) + plan_runtime = PlanRuntime.from_workspace(workspace) + mutation = plan_runtime.update_plan( + [ + { + 'step': 'Inspect the runtime loop', + 'status': 'in_progress', + 'description': 'Read the core agent files first.', + }, + { + 'step': 'Patch the tool registry', + 'status': 'pending', + }, + ], + explanation='Work through the runtime in two phases.', + task_runtime=task_runtime, + ) + rendered_plan = plan_runtime.render_plan() + rendered_tasks = task_runtime.render_tasks() + + 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('Inspect the runtime loop', rendered_tasks) + + def test_plan_tools_execute_against_runtime(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + task_runtime = TaskRuntime.from_workspace(workspace) + plan_runtime = PlanRuntime.from_workspace(workspace) + context = build_tool_context( + AgentRuntimeConfig( + cwd=workspace, + permissions=AgentPermissions(allow_file_write=True), + ), + plan_runtime=plan_runtime, + task_runtime=task_runtime, + ) + update_result = execute_tool( + default_tool_registry(), + 'update_plan', + { + 'explanation': 'Follow the current plan.', + 'items': [ + {'step': 'Inspect the workspace', 'status': 'completed'}, + {'step': 'Implement the fix', 'status': 'in_progress'}, + ], + }, + context, + ) + get_result = execute_tool( + default_tool_registry(), + 'plan_get', + {}, + context, + ) + clear_result = execute_tool( + default_tool_registry(), + 'plan_clear', + {'sync_tasks': True}, + context, + ) + + self.assertTrue(update_result.ok) + self.assertEqual(update_result.metadata.get('total_steps'), 2) + self.assertEqual(update_result.metadata.get('synced_tasks'), 2) + self.assertIn('# Plan', get_result.content) + self.assertTrue(clear_result.ok) + self.assertEqual(clear_result.metadata.get('total_steps'), 0) + + def test_agent_can_use_update_plan_tool_in_model_loop(self) -> None: + responses = [ + { + 'choices': [ + { + 'message': { + 'role': 'assistant', + 'content': 'I will store the plan first.', + 'tool_calls': [ + { + 'id': 'call_1', + 'type': 'function', + 'function': { + 'name': 'update_plan', + 'arguments': json.dumps( + { + 'explanation': 'Start with a plan.', + 'items': [ + { + 'step': 'Inspect the current files', + 'status': 'in_progress', + }, + { + 'step': 'Apply the code changes', + 'status': 'pending', + }, + ], + } + ), + }, + } + ], + }, + 'finish_reason': 'tool_calls', + } + ], + 'usage': {'prompt_tokens': 8, 'completion_tokens': 3}, + }, + { + 'choices': [ + { + 'message': { + 'role': 'assistant', + 'content': 'The plan was stored successfully.', + }, + 'finish_reason': 'stop', + } + ], + 'usage': {'prompt_tokens': 6, 'completion_tokens': 3}, + }, + ] + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + 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, + permissions=AgentPermissions(allow_file_write=True), + ), + ) + result = agent.run('Store the current plan') + self.assertTrue((workspace / '.port_sessions' / 'plan_runtime.json').exists()) + + self.assertEqual(result.final_output, 'The plan was stored successfully.') + self.assertEqual(result.tool_calls, 1) + tool_message = next( + message for message in result.transcript if message.get('role') == 'tool' + ) + self.assertIn('update_plan', tool_message.get('content', '')) diff --git a/tests/test_task_runtime.py b/tests/test_task_runtime.py new file mode 100644 index 0000000..4cf5d56 --- /dev/null +++ b/tests/test_task_runtime.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from src.agent_runtime import LocalCodingAgent +from src.agent_tools import build_tool_context, default_tool_registry, execute_tool +from src.agent_types import AgentPermissions, AgentRuntimeConfig, ModelConfig +from src.task_runtime import TaskRuntime + + +class FakeHTTPResponse: + def __init__(self, payload: dict[str, object]) -> None: + self.payload = payload + + def read(self) -> bytes: + return json.dumps(self.payload).encode('utf-8') + + def __enter__(self) -> 'FakeHTTPResponse': + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + +def make_urlopen_side_effect(responses: list[dict[str, object]]): + queued = [FakeHTTPResponse(payload) for payload in responses] + + def _fake_urlopen(request_obj, timeout=None): # noqa: ANN001 + return queued.pop(0) + + return _fake_urlopen + + +class TaskRuntimeTests(unittest.TestCase): + def test_runtime_persists_and_renders_tasks(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + runtime = TaskRuntime.from_workspace(workspace) + created = runtime.create_task( + title='Implement task runtime', + description='Add persistent tasks.', + status='in_progress', + ) + assert created.task is not None + runtime.update_task(created.task.task_id, status='done') + 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) + + def test_task_tools_execute_against_runtime(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + runtime = TaskRuntime.from_workspace(workspace) + context = build_tool_context( + AgentRuntimeConfig( + cwd=workspace, + permissions=AgentPermissions(allow_file_write=True), + ), + task_runtime=runtime, + ) + create_result = execute_tool( + default_tool_registry(), + 'task_create', + {'title': 'Review task tools', 'status': 'todo'}, + context, + ) + self.assertTrue(create_result.ok) + task_id = str(create_result.metadata.get('task_id')) + list_result = execute_tool( + default_tool_registry(), + 'task_list', + {}, + context, + ) + get_result = execute_tool( + default_tool_registry(), + 'task_get', + {'task_id': task_id}, + context, + ) + update_result = execute_tool( + default_tool_registry(), + 'task_update', + {'task_id': task_id, 'status': 'done'}, + context, + ) + todo_result = execute_tool( + default_tool_registry(), + 'todo_write', + {'items': [{'title': 'Replace with todo snapshot', 'status': 'in_progress'}]}, + context, + ) + + 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.assertTrue(todo_result.ok) + self.assertEqual(todo_result.metadata.get('total_tasks'), 1) + + def test_agent_can_use_task_tools_in_model_loop(self) -> None: + responses = [ + { + 'choices': [ + { + 'message': { + 'role': 'assistant', + 'content': 'I will create a task first.', + 'tool_calls': [ + { + 'id': 'call_1', + 'type': 'function', + 'function': { + 'name': 'task_create', + 'arguments': '{"title": "Review runtime tasks", "status": "todo"}', + }, + } + ], + }, + 'finish_reason': 'tool_calls', + } + ], + 'usage': {'prompt_tokens': 8, 'completion_tokens': 3}, + }, + { + 'choices': [ + { + 'message': { + 'role': 'assistant', + 'content': 'The task was created successfully.', + }, + 'finish_reason': 'stop', + } + ], + 'usage': {'prompt_tokens': 6, 'completion_tokens': 3}, + }, + ] + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + 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, + permissions=AgentPermissions(allow_file_write=True), + ), + ) + result = agent.run('Create a task for the current work') + self.assertTrue((workspace / '.port_sessions' / 'task_runtime.json').exists()) + + self.assertEqual(result.final_output, 'The task was created successfully.') + self.assertEqual(result.tool_calls, 1) + tool_message = next( + message + for message in result.transcript + if message.get('role') == 'tool' + ) + self.assertIn('task_create', tool_message.get('content', ''))