Merge pull request #9 from HarnessLab/feature/readme-update-april-2026
add claw-code-agent/src/plan_runtime.py and claw-code-agent/src/backg…
This commit is contained in:
+86
-25
@@ -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
|
||||
|
||||
+338
-35
@@ -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 <background-id>
|
||||
python3 -m src.main agent-logs <background-id> --tail 40
|
||||
```
|
||||
|
||||
### 8.4 Attach to the current output snapshot
|
||||
|
||||
```bash
|
||||
python3 -m src.main agent-attach <background-id>
|
||||
python3 -m src.main agent-attach <background-id> --tail 40
|
||||
```
|
||||
|
||||
### 8.5 Kill a running background session
|
||||
|
||||
```bash
|
||||
python3 -m src.main agent-kill <background-id>
|
||||
```
|
||||
|
||||
## 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 <session-id>
|
||||
```
|
||||
|
||||
## 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:
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+386
-16
@@ -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 = ['<system-reminder>', '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(['</system-reminder>', '', 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 = [
|
||||
'<system-reminder>',
|
||||
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.',
|
||||
'</system-reminder>',
|
||||
]
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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 <mcp-resource-uri>')
|
||||
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 <task-id>')
|
||||
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}')
|
||||
|
||||
+485
-4
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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] + '...'
|
||||
@@ -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
|
||||
+164
@@ -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(
|
||||
|
||||
@@ -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}'
|
||||
@@ -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] + '...'
|
||||
+78
-5
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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', ''))
|
||||
@@ -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', ''))
|
||||
@@ -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', ''))
|
||||
Reference in New Issue
Block a user