add claw-code-agent/src/plan_runtime.py and claw-code-agent/src/background_runtime.py. The agent now has a real
persistent plan runtime with update_plan, plan_get, and plan_clear, plus plan-to-task sync wired through claw-code-agent/src/ agent_tools.py, claw-code-agent/src/agent_runtime.py, claw-code-agent/src/agent_context.py, claw-code-agent/src/agent_prompting.py, and claw-code-agent/src/ agent_slash_commands.py. New local plan slash commands are /plan and /planner.
This commit is contained in:
+86
-25
@@ -61,6 +61,23 @@ Done:
|
|||||||
- [x] Manifest-defined plugin `beforeTool` guidance
|
- [x] Manifest-defined plugin `beforeTool` guidance
|
||||||
- [x] Manifest-defined plugin tool-result guidance injected back into the transcript
|
- [x] Manifest-defined plugin tool-result guidance injected back into the transcript
|
||||||
- [x] Plugin runtime session-state persistence and resume restoration
|
- [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 compacted message ids
|
||||||
- [x] Compaction metadata with preserved-tail ids and compaction depth
|
- [x] Compaction metadata with preserved-tail ids and compaction depth
|
||||||
- [x] Compaction metadata with compacted/preserved lineage ids and revision summaries
|
- [x] Compaction metadata with compacted/preserved lineage ids and revision summaries
|
||||||
@@ -103,16 +120,16 @@ Done:
|
|||||||
- [x] `agent-prompt` command
|
- [x] `agent-prompt` command
|
||||||
- [x] `agent-context` command
|
- [x] `agent-context` command
|
||||||
- [x] `agent-context-raw` 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`
|
- [x] Inventory/helper commands such as `summary`, `manifest`, `commands`, and `tools`
|
||||||
|
|
||||||
Missing:
|
Missing:
|
||||||
|
|
||||||
- [ ] Daemon worker mode
|
- [ ] Daemon worker mode
|
||||||
- [ ] Background session mode
|
|
||||||
- [ ] Session process listing (`ps`)
|
|
||||||
- [ ] Background session logs
|
|
||||||
- [ ] Background attach flow
|
|
||||||
- [ ] Background kill flow
|
|
||||||
- [ ] Remote-control / bridge runtime mode
|
- [ ] Remote-control / bridge runtime mode
|
||||||
- [ ] Browser/native-host runtime mode
|
- [ ] Browser/native-host runtime mode
|
||||||
- [ ] Computer-use MCP mode
|
- [ ] Computer-use MCP mode
|
||||||
@@ -133,6 +150,10 @@ Done:
|
|||||||
- [x] Environment-aware prompt sections
|
- [x] Environment-aware prompt sections
|
||||||
- [x] User context reminder injection
|
- [x] User context reminder injection
|
||||||
- [x] Custom system prompt override and append support
|
- [x] Custom system prompt override and append support
|
||||||
|
- [x] Local hook/policy guidance section in the Python system prompt
|
||||||
|
- [x] Local 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:
|
Missing:
|
||||||
|
|
||||||
@@ -160,6 +181,11 @@ Done:
|
|||||||
- [x] Raw context inspection command
|
- [x] Raw context inspection command
|
||||||
- [x] Plugin cache snapshot injection
|
- [x] Plugin cache snapshot injection
|
||||||
- [x] Manifest-based plugin runtime summary injection
|
- [x] Manifest-based plugin runtime summary injection
|
||||||
|
- [x] Manifest-based hook/policy summary injection
|
||||||
|
- [x] 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:
|
Missing:
|
||||||
|
|
||||||
@@ -182,9 +208,20 @@ Done:
|
|||||||
- [x] `/usage`
|
- [x] `/usage`
|
||||||
- [x] `/context-raw`
|
- [x] `/context-raw`
|
||||||
- [x] `/env`
|
- [x] `/env`
|
||||||
|
- [x] `/mcp`
|
||||||
|
- [x] `/resources`
|
||||||
|
- [x] `/resource`
|
||||||
|
- [x] `/plan`
|
||||||
|
- [x] `/planner`
|
||||||
|
- [x] `/tasks`
|
||||||
|
- [x] `/todo`
|
||||||
|
- [x] `/task`
|
||||||
- [x] `/prompt`
|
- [x] `/prompt`
|
||||||
- [x] `/system-prompt`
|
- [x] `/system-prompt`
|
||||||
- [x] `/permissions`
|
- [x] `/permissions`
|
||||||
|
- [x] `/hooks`
|
||||||
|
- [x] `/policy`
|
||||||
|
- [x] `/trust`
|
||||||
- [x] `/model`
|
- [x] `/model`
|
||||||
- [x] `/tools`
|
- [x] `/tools`
|
||||||
- [x] `/memory`
|
- [x] `/memory`
|
||||||
@@ -196,7 +233,7 @@ Missing:
|
|||||||
|
|
||||||
- [ ] Full npm slash-command surface
|
- [ ] Full npm slash-command surface
|
||||||
- [ ] Slash commands backed by MCP integration
|
- [ ] 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 tied to remote/background sessions
|
||||||
- [ ] Slash commands with richer interactive behavior
|
- [ ] Slash commands with richer interactive behavior
|
||||||
- [ ] Slash commands tied to plugins and bundled skills
|
- [ ] Slash commands tied to plugins and bundled skills
|
||||||
@@ -213,22 +250,29 @@ Done:
|
|||||||
- [x] `glob_search`
|
- [x] `glob_search`
|
||||||
- [x] `grep_search`
|
- [x] `grep_search`
|
||||||
- [x] `bash`
|
- [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:
|
Missing:
|
||||||
|
|
||||||
- [ ] Agent spawning tool
|
- [ ] Agent spawning tool parity beyond the current `delegate_agent` runtime tool
|
||||||
- [ ] Skill tool
|
- [ ] Skill tool
|
||||||
- [ ] Notebook edit tool
|
- [ ] Notebook edit tool
|
||||||
- [ ] Web fetch tool
|
- [ ] Web fetch tool
|
||||||
- [ ] Web search tool
|
- [ ] Web search tool
|
||||||
- [ ] Todo write tool
|
|
||||||
- [ ] Ask-user-question tool
|
- [ ] Ask-user-question tool
|
||||||
- [ ] LSP tool
|
- [ ] LSP tool
|
||||||
- [ ] MCP resource listing tool
|
|
||||||
- [ ] MCP resource read tool
|
|
||||||
- [ ] Tool search tool
|
- [ ] Tool search tool
|
||||||
- [ ] Config tool
|
- [ ] Config tool
|
||||||
- [ ] Task create/get/update/list tools
|
|
||||||
- [ ] Team create/delete tools
|
- [ ] Team create/delete tools
|
||||||
- [ ] Send-message tool
|
- [ ] Send-message tool
|
||||||
- [ ] Terminal capture tool
|
- [ ] Terminal capture tool
|
||||||
@@ -246,14 +290,18 @@ Done:
|
|||||||
|
|
||||||
- [x] Basic local command dispatch for the Python runtime
|
- [x] Basic local command dispatch for the Python runtime
|
||||||
- [x] Inventory view of mirrored command names
|
- [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:
|
Missing:
|
||||||
|
|
||||||
- [ ] Real implementation of the larger upstream command tree
|
- [ ] Real implementation of the larger upstream command tree
|
||||||
- [ ] Task orchestration system
|
- [ ] Task orchestration system beyond the current local plan/task sync runtime
|
||||||
- [ ] Planner / task execution parity
|
- [ ] Planner / task execution parity beyond the current local plan persistence and sync flow
|
||||||
- [ ] Team / collaboration command flows
|
- [ ] Team / collaboration command flows
|
||||||
- [ ] Background task management
|
|
||||||
- [ ] Command-specific session behaviors
|
- [ ] Command-specific session behaviors
|
||||||
- [ ] Full `src/commands/*` parity
|
- [ ] Full `src/commands/*` parity
|
||||||
- [ ] Full `src/tasks/*` parity
|
- [ ] Full `src/tasks/*` parity
|
||||||
@@ -266,16 +314,19 @@ Done:
|
|||||||
- [x] Write-gated mode
|
- [x] Write-gated mode
|
||||||
- [x] Shell-gated mode
|
- [x] Shell-gated mode
|
||||||
- [x] Unsafe mode for destructive shell actions
|
- [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:
|
Missing:
|
||||||
|
|
||||||
- [ ] Hooks runtime
|
|
||||||
- [ ] Tool-permission workflow parity
|
- [ ] Tool-permission workflow parity
|
||||||
- [ ] Policy limit loading
|
|
||||||
- [ ] Managed settings loading
|
|
||||||
- [ ] Trust-gated initialization
|
- [ ] Trust-gated initialization
|
||||||
- [ ] Safe environment loading parity
|
|
||||||
- [ ] More exact denial tracking
|
|
||||||
- [ ] Hook-config management
|
- [ ] Hook-config management
|
||||||
- [ ] Full hooks and policy parity
|
- [ ] Full hooks and policy parity
|
||||||
|
|
||||||
@@ -284,13 +335,15 @@ Missing:
|
|||||||
Done:
|
Done:
|
||||||
|
|
||||||
- [x] Placeholder mirrored package layout for plugins, skills, services, and remote subsystems
|
- [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:
|
Missing:
|
||||||
|
|
||||||
- [ ] Real MCP client support
|
- [ ] Real MCP client support
|
||||||
- [ ] MCP server integration
|
- [ ] MCP server integration
|
||||||
- [ ] MCP resource listing and reading
|
- [ ] Full MCP-backed tool parity beyond the current local resource list/read tools
|
||||||
- [ ] MCP-backed tools
|
|
||||||
- [ ] Plugin discovery and loading
|
- [ ] Plugin discovery and loading
|
||||||
- [ ] Bundled plugin support
|
- [ ] Bundled plugin support
|
||||||
- [ ] Plugin lifecycle management
|
- [ ] Plugin lifecycle management
|
||||||
@@ -323,12 +376,12 @@ Missing:
|
|||||||
Done:
|
Done:
|
||||||
|
|
||||||
- [x] Session save/resume on local disk
|
- [x] Session save/resume on local disk
|
||||||
|
- [x] Local background agent processes
|
||||||
|
- [x] Local background attach/log/kill workflows
|
||||||
|
|
||||||
Missing:
|
Missing:
|
||||||
|
|
||||||
- [ ] Remote execution modes
|
- [ ] Remote execution modes
|
||||||
- [ ] Background agent processes
|
|
||||||
- [ ] Background attach/log/kill workflows
|
|
||||||
- [ ] Team runtime features
|
- [ ] Team runtime features
|
||||||
- [ ] Team messaging features
|
- [ ] Team messaging features
|
||||||
- [ ] Shared remote state
|
- [ ] Shared remote state
|
||||||
@@ -383,6 +436,13 @@ Working Python runtime today:
|
|||||||
- [x] `src/agent_session.py`
|
- [x] `src/agent_session.py`
|
||||||
- [x] `src/agent_slash_commands.py`
|
- [x] `src/agent_slash_commands.py`
|
||||||
- [x] `src/agent_types.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/openai_compat.py`
|
||||||
- [x] `src/session_store.py`
|
- [x] `src/session_store.py`
|
||||||
- [x] `src/permissions.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`
|
- [ ] Expand the real Python tool registry toward upstream `tools.ts`
|
||||||
- [ ] Replace more snapshot-backed mirrored modules with working runtime code
|
- [ ] Replace more snapshot-backed mirrored modules with working runtime code
|
||||||
- [ ] Implement real MCP support
|
- [ ] 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
|
- [ ] Build a real interactive REPL / TUI
|
||||||
- [ ] Add tokenizer-accurate context accounting
|
- [ ] 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
|
- [ ] Port more of the command/task system
|
||||||
- [ ] Close the gap between the mirrored workspace and the working runtime
|
- [ ] Close the gap between the mirrored workspace and the working runtime
|
||||||
|
|||||||
+338
-35
@@ -54,6 +54,8 @@ pip install -e .
|
|||||||
```bash
|
```bash
|
||||||
python3 -m src.main --help
|
python3 -m src.main --help
|
||||||
python3 -m src.main agent --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-chat --help
|
||||||
python3 -m src.main agent-resume --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 "/commands"
|
||||||
python3 -m src.main agent "/context" --cwd ..
|
python3 -m src.main agent "/context" --cwd ..
|
||||||
python3 -m src.main agent "/context-raw" --cwd ..
|
python3 -m src.main agent "/context-raw" --cwd ..
|
||||||
|
python3 -m src.main agent "/plan" --cwd ..
|
||||||
python3 -m src.main agent "/prompt" --cwd ..
|
python3 -m src.main agent "/prompt" --cwd ..
|
||||||
python3 -m src.main agent "/permissions" --cwd ..
|
python3 -m src.main agent "/permissions" --cwd ..
|
||||||
|
python3 -m src.main agent "/hooks" --cwd ..
|
||||||
|
python3 -m src.main agent "/policy" --cwd ..
|
||||||
|
python3 -m src.main agent "/trust" --cwd ..
|
||||||
python3 -m src.main agent "/tools" --cwd ..
|
python3 -m src.main agent "/tools" --cwd ..
|
||||||
python3 -m src.main agent "/memory" --cwd ..
|
python3 -m src.main agent "/memory" --cwd ..
|
||||||
python3 -m src.main agent "/status" --cwd ..
|
python3 -m src.main agent "/status" --cwd ..
|
||||||
@@ -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
|
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. Core Agent Loop
|
||||||
|
|
||||||
### 5.1 Read-only run
|
### 5.1 Read-only run
|
||||||
@@ -227,6 +294,52 @@ python3 -m src.main agent \
|
|||||||
--unsafe
|
--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. Session Persistence And Resume
|
||||||
|
|
||||||
### 7.1 Create a saved session
|
### 7.1 Create a saved session
|
||||||
@@ -261,7 +374,50 @@ python3 -m src.main agent-resume \
|
|||||||
ls -lt .port_sessions/agent
|
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:
|
Create a schema file:
|
||||||
|
|
||||||
@@ -290,9 +446,9 @@ python3 -m src.main agent \
|
|||||||
--response-schema-strict
|
--response-schema-strict
|
||||||
```
|
```
|
||||||
|
|
||||||
## 9. Budgets And Limits
|
## 10. Budgets And Limits
|
||||||
|
|
||||||
### 9.1 Total token budget
|
### 10.1 Total token budget
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -301,7 +457,7 @@ python3 -m src.main agent \
|
|||||||
--max-total-tokens 50
|
--max-total-tokens 50
|
||||||
```
|
```
|
||||||
|
|
||||||
### 9.2 Input / output token budgets
|
### 10.2 Input / output token budgets
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -311,7 +467,7 @@ python3 -m src.main agent \
|
|||||||
--max-output-tokens 80
|
--max-output-tokens 80
|
||||||
```
|
```
|
||||||
|
|
||||||
### 9.3 Reasoning-token budget
|
### 10.3 Reasoning-token budget
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -320,7 +476,7 @@ python3 -m src.main agent \
|
|||||||
--max-reasoning-tokens 10
|
--max-reasoning-tokens 10
|
||||||
```
|
```
|
||||||
|
|
||||||
### 9.4 Tool-call budget
|
### 10.4 Tool-call budget
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -329,7 +485,7 @@ python3 -m src.main agent \
|
|||||||
--max-tool-calls 1
|
--max-tool-calls 1
|
||||||
```
|
```
|
||||||
|
|
||||||
### 9.5 Delegated-task budget
|
### 10.5 Delegated-task budget
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -338,7 +494,7 @@ python3 -m src.main agent \
|
|||||||
--max-delegated-tasks 1
|
--max-delegated-tasks 1
|
||||||
```
|
```
|
||||||
|
|
||||||
### 9.6 Cost budget
|
### 10.6 Cost budget
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -349,7 +505,7 @@ python3 -m src.main agent \
|
|||||||
--max-budget-usd 0.000001
|
--max-budget-usd 0.000001
|
||||||
```
|
```
|
||||||
|
|
||||||
### 9.7 Model-call budget
|
### 10.7 Model-call budget
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -358,7 +514,7 @@ python3 -m src.main agent \
|
|||||||
--max-model-calls 1
|
--max-model-calls 1
|
||||||
```
|
```
|
||||||
|
|
||||||
### 9.8 Session-turn budget
|
### 10.8 Session-turn budget
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -367,9 +523,27 @@ python3 -m src.main agent \
|
|||||||
--max-session-turns 1
|
--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
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -379,7 +553,7 @@ python3 -m src.main agent \
|
|||||||
--show-transcript
|
--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:
|
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
|
--show-transcript
|
||||||
```
|
```
|
||||||
|
|
||||||
### 10.3 Snipping older context
|
### 11.3 Snipping older context
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -402,7 +576,7 @@ python3 -m src.main agent \
|
|||||||
--show-transcript
|
--show-transcript
|
||||||
```
|
```
|
||||||
|
|
||||||
### 10.4 Compaction boundaries
|
### 11.4 Compaction boundaries
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -413,9 +587,9 @@ python3 -m src.main agent \
|
|||||||
--show-transcript
|
--show-transcript
|
||||||
```
|
```
|
||||||
|
|
||||||
## 11. File History Replay
|
## 12. File History Replay
|
||||||
|
|
||||||
### 11.1 Create file history
|
### 12.1 Create file history
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -424,7 +598,7 @@ python3 -m src.main agent \
|
|||||||
--allow-write
|
--allow-write
|
||||||
```
|
```
|
||||||
|
|
||||||
### 11.2 Resume and inspect replay
|
### 12.2 Resume and inspect replay
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent-resume \
|
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.
|
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
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -447,7 +621,7 @@ python3 -m src.main agent \
|
|||||||
--show-transcript
|
--show-transcript
|
||||||
```
|
```
|
||||||
|
|
||||||
### 12.2 Multiple delegated subtasks
|
### 13.2 Multiple delegated subtasks
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -456,7 +630,7 @@ python3 -m src.main agent \
|
|||||||
--show-transcript
|
--show-transcript
|
||||||
```
|
```
|
||||||
|
|
||||||
### 12.3 Resume a delegated child session
|
### 13.3 Resume a delegated child session
|
||||||
|
|
||||||
1. Seed a normal saved session:
|
1. Seed a normal saved session:
|
||||||
|
|
||||||
@@ -475,7 +649,7 @@ python3 -m src.main agent \
|
|||||||
--show-transcript
|
--show-transcript
|
||||||
```
|
```
|
||||||
|
|
||||||
### 12.4 Topological dependency batches
|
### 13.4 Topological dependency batches
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -490,7 +664,7 @@ Look for:
|
|||||||
- `delegate_group_result`
|
- `delegate_group_result`
|
||||||
- `batch_index=...`
|
- `batch_index=...`
|
||||||
|
|
||||||
## 13. Plugin Runtime
|
## 14. Plugin Runtime
|
||||||
|
|
||||||
Create a local plugin manifest:
|
Create a local plugin manifest:
|
||||||
|
|
||||||
@@ -531,14 +705,14 @@ cat > ./test_cases/plugins/demo/plugin.json <<'EOF'
|
|||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
### 13.1 Plugin prompt/context discovery
|
### 14.1 Plugin prompt/context discovery
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent-prompt --cwd ./test_cases
|
python3 -m src.main agent-prompt --cwd ./test_cases
|
||||||
python3 -m src.main agent-context-raw --cwd ./test_cases
|
python3 -m src.main agent-context-raw --cwd ./test_cases
|
||||||
```
|
```
|
||||||
|
|
||||||
### 13.2 Plugin alias tool
|
### 14.2 Plugin alias tool
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
echo "hello plugin" > ./test_cases/hello.txt
|
echo "hello plugin" > ./test_cases/hello.txt
|
||||||
@@ -548,7 +722,7 @@ python3 -m src.main agent \
|
|||||||
--show-transcript
|
--show-transcript
|
||||||
```
|
```
|
||||||
|
|
||||||
### 13.3 Plugin virtual tool
|
### 14.3 Plugin virtual tool
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -557,7 +731,7 @@ python3 -m src.main agent \
|
|||||||
--show-transcript
|
--show-transcript
|
||||||
```
|
```
|
||||||
|
|
||||||
### 13.4 Plugin before/after tool guidance
|
### 14.4 Plugin before/after tool guidance
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent \
|
python3 -m src.main agent \
|
||||||
@@ -566,7 +740,7 @@ python3 -m src.main agent \
|
|||||||
--show-transcript
|
--show-transcript
|
||||||
```
|
```
|
||||||
|
|
||||||
### 13.5 Plugin lifecycle with resume/persist
|
### 14.5 Plugin lifecycle with resume/persist
|
||||||
|
|
||||||
1. Start a session:
|
1. Start a session:
|
||||||
|
|
||||||
@@ -591,9 +765,138 @@ Look for:
|
|||||||
- `Plugin resume hooks:`
|
- `Plugin resume hooks:`
|
||||||
- `Plugin runtime state:`
|
- `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
|
```bash
|
||||||
python3 -m src.main summary
|
python3 -m src.main summary
|
||||||
@@ -603,7 +906,7 @@ python3 -m src.main commands --limit 20
|
|||||||
python3 -m src.main tools --limit 20
|
python3 -m src.main tools --limit 20
|
||||||
```
|
```
|
||||||
|
|
||||||
### 14.2 Query routing and bootstrap reports
|
### 17.2 Query routing and bootstrap reports
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main route "inspect the runtime and tools" --limit 10
|
python3 -m src.main route "inspect the runtime and tools" --limit 10
|
||||||
@@ -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
|
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
|
```bash
|
||||||
python3 -m src.main flush-transcript "store a temporary transcript"
|
python3 -m src.main flush-transcript "store a temporary transcript"
|
||||||
python3 -m src.main load-session <session-id>
|
python3 -m src.main load-session <session-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
## 15. Remote/Direct Mode Simulations
|
## 18. Remote/Direct Mode Simulations
|
||||||
|
|
||||||
These are mirrored workspace simulation commands, not the real agent runtime:
|
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
|
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:
|
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_session import AgentMessage, AgentSessionState
|
||||||
from .agent_tools import build_tool_context, default_tool_registry, execute_tool
|
from .agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||||
from .agent_types import AgentPermissions, AgentRunResult, AgentRuntimeConfig, ModelConfig
|
from .agent_types import AgentPermissions, AgentRunResult, AgentRuntimeConfig, ModelConfig
|
||||||
|
from .background_runtime import BackgroundSessionRuntime
|
||||||
from .commands import PORTED_COMMANDS, build_command_backlog
|
from .commands import PORTED_COMMANDS, build_command_backlog
|
||||||
|
from .mcp_runtime import MCPRuntime
|
||||||
from .parity_audit import ParityAuditResult, run_parity_audit
|
from .parity_audit import ParityAuditResult, run_parity_audit
|
||||||
|
from .plan_runtime import PlanRuntime, PlanStep
|
||||||
from .plugin_runtime import PluginRuntime
|
from .plugin_runtime import PluginRuntime
|
||||||
from .port_manifest import PortManifest, build_port_manifest
|
from .port_manifest import PortManifest, build_port_manifest
|
||||||
from .query_engine import QueryEnginePort, TurnResult
|
from .query_engine import QueryEnginePort, TurnResult
|
||||||
from .runtime import PortRuntime, RuntimeSession
|
from .runtime import PortRuntime, RuntimeSession
|
||||||
from .session_store import StoredSession, load_session, save_session
|
from .session_store import StoredSession, load_session, save_session
|
||||||
from .system_init import build_system_init_message
|
from .system_init import build_system_init_message
|
||||||
|
from .task import PortingTask
|
||||||
|
from .task_runtime import TaskRuntime
|
||||||
from .tools import PORTED_TOOLS, build_tool_backlog
|
from .tools import PORTED_TOOLS, build_tool_backlog
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -31,15 +36,21 @@ __all__ = [
|
|||||||
'AgentRuntimeConfig',
|
'AgentRuntimeConfig',
|
||||||
'AgentMessage',
|
'AgentMessage',
|
||||||
'AgentSessionState',
|
'AgentSessionState',
|
||||||
|
'BackgroundSessionRuntime',
|
||||||
'LocalCodingAgent',
|
'LocalCodingAgent',
|
||||||
|
'MCPRuntime',
|
||||||
'ModelConfig',
|
'ModelConfig',
|
||||||
'ParityAuditResult',
|
'ParityAuditResult',
|
||||||
|
'PlanRuntime',
|
||||||
|
'PlanStep',
|
||||||
'PortManifest',
|
'PortManifest',
|
||||||
'PortRuntime',
|
'PortRuntime',
|
||||||
'PluginRuntime',
|
'PluginRuntime',
|
||||||
|
'PortingTask',
|
||||||
'QueryEnginePort',
|
'QueryEnginePort',
|
||||||
'RuntimeSession',
|
'RuntimeSession',
|
||||||
'StoredSession',
|
'StoredSession',
|
||||||
|
'TaskRuntime',
|
||||||
'TurnResult',
|
'TurnResult',
|
||||||
'PORTED_COMMANDS',
|
'PORTED_COMMANDS',
|
||||||
'PORTED_TOOLS',
|
'PORTED_TOOLS',
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ from functools import lru_cache
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .agent_plugin_cache import load_plugin_cache_summary
|
from .agent_plugin_cache import load_plugin_cache_summary
|
||||||
|
from .hook_policy import HookPolicyRuntime
|
||||||
|
from .mcp_runtime import MCPRuntime
|
||||||
|
from .plan_runtime import PlanRuntime
|
||||||
from .plugin_runtime import PluginRuntime
|
from .plugin_runtime import PluginRuntime
|
||||||
|
from .task_runtime import TaskRuntime
|
||||||
from .agent_types import AgentRuntimeConfig
|
from .agent_types import AgentRuntimeConfig
|
||||||
|
|
||||||
MAX_STATUS_CHARS = 2000
|
MAX_STATUS_CHARS = 2000
|
||||||
@@ -197,6 +201,35 @@ def _get_user_context_cached(
|
|||||||
plugin_runtime = PluginRuntime.from_workspace(Path(cwd), additional_working_directories)
|
plugin_runtime = PluginRuntime.from_workspace(Path(cwd), additional_working_directories)
|
||||||
if plugin_runtime.manifests:
|
if plugin_runtime.manifests:
|
||||||
context['pluginRuntime'] = plugin_runtime.render_summary()
|
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
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ def build_system_prompt_parts(
|
|||||||
get_actions_section(),
|
get_actions_section(),
|
||||||
get_using_your_tools_section(enabled_tool_names),
|
get_using_your_tools_section(enabled_tool_names),
|
||||||
get_plugin_guidance_section(prompt_context),
|
get_plugin_guidance_section(prompt_context),
|
||||||
|
get_mcp_guidance_section(prompt_context),
|
||||||
|
get_plan_guidance_section(prompt_context),
|
||||||
|
get_task_guidance_section(prompt_context),
|
||||||
|
get_hook_policy_guidance_section(prompt_context),
|
||||||
get_tone_and_style_section(),
|
get_tone_and_style_section(),
|
||||||
get_output_efficiency_section(),
|
get_output_efficiency_section(),
|
||||||
SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||||
@@ -207,6 +211,56 @@ def get_plugin_guidance_section(prompt_context: PromptContext) -> str:
|
|||||||
return '\n'.join(['# Plugins', *prepend_bullets(items)])
|
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:
|
def get_output_efficiency_section() -> str:
|
||||||
return """# Communicating with the user
|
return """# Communicating with the user
|
||||||
|
|
||||||
|
|||||||
+386
-16
@@ -10,6 +10,8 @@ from uuid import uuid4
|
|||||||
from .agent_manager import AgentManager
|
from .agent_manager import AgentManager
|
||||||
from .agent_context import render_context_report as render_agent_context_report
|
from .agent_context import render_context_report as render_agent_context_report
|
||||||
from .agent_context_usage import collect_context_usage, estimate_tokens, format_context_usage
|
from .agent_context_usage import collect_context_usage, estimate_tokens, format_context_usage
|
||||||
|
from .hook_policy import HookPolicyRuntime
|
||||||
|
from .mcp_runtime import MCPRuntime
|
||||||
from .agent_prompting import (
|
from .agent_prompting import (
|
||||||
build_prompt_context,
|
build_prompt_context,
|
||||||
build_system_prompt_parts,
|
build_system_prompt_parts,
|
||||||
@@ -38,7 +40,9 @@ from .agent_types import (
|
|||||||
UsageStats,
|
UsageStats,
|
||||||
)
|
)
|
||||||
from .openai_compat import OpenAICompatClient, OpenAICompatError
|
from .openai_compat import OpenAICompatClient, OpenAICompatError
|
||||||
|
from .plan_runtime import PlanRuntime
|
||||||
from .plugin_runtime import PluginRuntime
|
from .plugin_runtime import PluginRuntime
|
||||||
|
from .task_runtime import TaskRuntime
|
||||||
from .session_store import (
|
from .session_store import (
|
||||||
StoredAgentSession,
|
StoredAgentSession,
|
||||||
load_agent_session,
|
load_agent_session,
|
||||||
@@ -69,6 +73,10 @@ class LocalCodingAgent:
|
|||||||
managed_child_index: int | None = None
|
managed_child_index: int | None = None
|
||||||
managed_label: str | None = None
|
managed_label: str | None = None
|
||||||
plugin_runtime: PluginRuntime | 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_session: AgentSessionState | None = field(default=None, init=False, repr=False)
|
||||||
last_run_result: AgentRunResult | 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)
|
active_session_id: str | None = field(default=None, init=False, repr=False)
|
||||||
@@ -86,6 +94,21 @@ class LocalCodingAgent:
|
|||||||
self.runtime_config.cwd,
|
self.runtime_config.cwd,
|
||||||
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
||||||
)
|
)
|
||||||
|
if self.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)
|
registry = dict(self.tool_registry)
|
||||||
plugin_tools = self.plugin_runtime.register_tool_aliases(registry)
|
plugin_tools = self.plugin_runtime.register_tool_aliases(registry)
|
||||||
if plugin_tools:
|
if plugin_tools:
|
||||||
@@ -95,7 +118,17 @@ class LocalCodingAgent:
|
|||||||
registry = {**registry, **virtual_tools}
|
registry = {**registry, **virtual_tools}
|
||||||
self.tool_registry = registry
|
self.tool_registry = registry
|
||||||
self.client = OpenAICompatClient(self.model_config)
|
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:
|
def set_model(self, model: str) -> None:
|
||||||
self.model_config = replace(self.model_config, model=model)
|
self.model_config = replace(self.model_config, model=model)
|
||||||
@@ -144,6 +177,67 @@ class LocalCodingAgent:
|
|||||||
system_context=prompt_context.system_context,
|
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:
|
def run(self, prompt: str) -> AgentRunResult:
|
||||||
self.managed_agent_id = None
|
self.managed_agent_id = None
|
||||||
self.resume_source_session_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 = self._apply_plugin_resume_hooks(
|
||||||
effective_prompt,
|
effective_prompt,
|
||||||
resumed=base_session is not None,
|
resumed=base_session is not None,
|
||||||
@@ -431,7 +528,7 @@ class LocalCodingAgent:
|
|||||||
str(scratchpad_directory) if scratchpad_directory is not None else None
|
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,
|
result,
|
||||||
prompt=effective_prompt,
|
prompt=effective_prompt,
|
||||||
turn_index=turn_index,
|
turn_index=turn_index,
|
||||||
@@ -512,7 +609,7 @@ class LocalCodingAgent:
|
|||||||
str(scratchpad_directory) if scratchpad_directory is not None else None
|
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,
|
result,
|
||||||
prompt=effective_prompt,
|
prompt=effective_prompt,
|
||||||
turn_index=turn_index,
|
turn_index=turn_index,
|
||||||
@@ -583,6 +680,9 @@ class LocalCodingAgent:
|
|||||||
if self.plugin_runtime is not None:
|
if self.plugin_runtime is not None:
|
||||||
self.plugin_runtime.record_tool_attempt(tool_call.name, blocked=False)
|
self.plugin_runtime.record_tool_attempt(tool_call.name, blocked=False)
|
||||||
plugin_preflight_messages = self._plugin_tool_preflight_messages(tool_call.name)
|
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:
|
if plugin_preflight_messages:
|
||||||
stream_events.append(
|
stream_events.append(
|
||||||
{
|
{
|
||||||
@@ -593,7 +693,18 @@ class LocalCodingAgent:
|
|||||||
'message_count': len(plugin_preflight_messages),
|
'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)
|
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 plugin_block_message is not None:
|
||||||
if self.plugin_runtime is not None:
|
if self.plugin_runtime is not None:
|
||||||
blocked_attempts = int(
|
blocked_attempts = int(
|
||||||
@@ -621,6 +732,27 @@ class LocalCodingAgent:
|
|||||||
'message': plugin_block_message,
|
'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_call.name == 'delegate_agent':
|
||||||
if tool_result is None:
|
if tool_result is None:
|
||||||
tool_result = self._execute_delegate_agent(tool_call.arguments)
|
tool_result = self._execute_delegate_agent(tool_call.arguments)
|
||||||
@@ -658,6 +790,7 @@ class LocalCodingAgent:
|
|||||||
metadata=tool_result.metadata,
|
metadata=tool_result.metadata,
|
||||||
)
|
)
|
||||||
plugin_messages = self._plugin_tool_result_messages(tool_call.name)
|
plugin_messages = self._plugin_tool_result_messages(tool_call.name)
|
||||||
|
policy_messages = self._hook_policy_tool_result_messages(tool_call.name)
|
||||||
if plugin_messages:
|
if plugin_messages:
|
||||||
merged_metadata = dict(tool_result.metadata)
|
merged_metadata = dict(tool_result.metadata)
|
||||||
merged_metadata['plugin_messages'] = list(plugin_messages)
|
merged_metadata['plugin_messages'] = list(plugin_messages)
|
||||||
@@ -677,12 +810,47 @@ class LocalCodingAgent:
|
|||||||
'message': message,
|
'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(
|
session.finalize_tool(
|
||||||
tool_message_index,
|
tool_message_index,
|
||||||
content=serialize_tool_result(tool_result),
|
content=serialize_tool_result(tool_result),
|
||||||
metadata={
|
metadata={
|
||||||
'phase': 'completed',
|
'phase': 'completed',
|
||||||
'plugin_preflight_messages': list(plugin_preflight_messages),
|
'plugin_preflight_messages': list(plugin_preflight_messages),
|
||||||
|
'hook_policy_preflight_messages': list(policy_preflight_messages),
|
||||||
**dict(tool_result.metadata),
|
**dict(tool_result.metadata),
|
||||||
},
|
},
|
||||||
stop_reason='tool_completed',
|
stop_reason='tool_completed',
|
||||||
@@ -707,6 +875,9 @@ class LocalCodingAgent:
|
|||||||
preflight_messages=plugin_preflight_messages,
|
preflight_messages=plugin_preflight_messages,
|
||||||
block_message=plugin_block_message,
|
block_message=plugin_block_message,
|
||||||
plugin_messages=plugin_messages,
|
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(
|
delegate_preflight_messages=tuple(
|
||||||
message
|
message
|
||||||
for message in tool_result.metadata.get(
|
for message in tool_result.metadata.get(
|
||||||
@@ -774,7 +945,7 @@ class LocalCodingAgent:
|
|||||||
str(scratchpad_directory) if scratchpad_directory is not None else None
|
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,
|
result,
|
||||||
prompt=effective_prompt,
|
prompt=effective_prompt,
|
||||||
turn_index=self.runtime_config.max_turns,
|
turn_index=self.runtime_config.max_turns,
|
||||||
@@ -2381,6 +2552,39 @@ class LocalCodingAgent:
|
|||||||
)
|
)
|
||||||
return '\n'.join(lines)
|
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(
|
def _build_plugin_tool_runtime_message(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -2388,6 +2592,9 @@ class LocalCodingAgent:
|
|||||||
preflight_messages: tuple[str, ...],
|
preflight_messages: tuple[str, ...],
|
||||||
block_message: str | None,
|
block_message: str | None,
|
||||||
plugin_messages: tuple[str, ...],
|
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_preflight_messages: tuple[str, ...] = (),
|
||||||
delegate_after_messages: tuple[str, ...] = (),
|
delegate_after_messages: tuple[str, ...] = (),
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
@@ -2395,28 +2602,46 @@ class LocalCodingAgent:
|
|||||||
block_message is None
|
block_message is None
|
||||||
and not plugin_messages
|
and not plugin_messages
|
||||||
and not preflight_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_preflight_messages
|
||||||
and not delegate_after_messages
|
and not delegate_after_messages
|
||||||
):
|
):
|
||||||
return None
|
return None
|
||||||
|
plugin_only = (
|
||||||
|
hook_policy_block_message is None
|
||||||
|
and not hook_policy_preflight_messages
|
||||||
|
and not hook_policy_messages
|
||||||
|
)
|
||||||
lines = [
|
lines = [
|
||||||
'<system-reminder>',
|
'<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:
|
for message in preflight_messages:
|
||||||
lines.append(f'- Before tool: {message}')
|
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:
|
for message in delegate_preflight_messages:
|
||||||
lines.append(f'- Before delegate: {message}')
|
lines.append(f'- Before delegate: {message}')
|
||||||
if block_message is not None:
|
if block_message is not None:
|
||||||
lines.append(f'- Blocked: {block_message}')
|
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:
|
for message in plugin_messages:
|
||||||
lines.append(f'- After result: {message}')
|
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:
|
for message in delegate_after_messages:
|
||||||
lines.append(f'- After delegate: {message}')
|
lines.append(f'- After delegate: {message}')
|
||||||
lines.extend(
|
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>',
|
'</system-reminder>',
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -2437,6 +2662,21 @@ class LocalCodingAgent:
|
|||||||
return ()
|
return ()
|
||||||
return self.plugin_runtime.tool_result_injections(tool_name)
|
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(
|
def _persist_session(
|
||||||
self,
|
self,
|
||||||
session: AgentSessionState,
|
session: AgentSessionState,
|
||||||
@@ -2546,15 +2786,27 @@ class LocalCodingAgent:
|
|||||||
|
|
||||||
def render_permissions_report(self) -> str:
|
def render_permissions_report(self) -> str:
|
||||||
permissions = self.runtime_config.permissions
|
permissions = self.runtime_config.permissions
|
||||||
return '\n'.join(
|
lines = [
|
||||||
[
|
'# Permissions',
|
||||||
'# Permissions',
|
'',
|
||||||
'',
|
f'- File write tools: {"enabled" if permissions.allow_file_write else "disabled"}',
|
||||||
f'- File write tools: {"enabled" if permissions.allow_file_write else "disabled"}',
|
f'- Shell commands: {"enabled" if permissions.allow_shell_commands 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"}',
|
||||||
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:
|
def render_tools_report(self) -> str:
|
||||||
permissions = self.runtime_config.permissions
|
permissions = self.runtime_config.permissions
|
||||||
@@ -2565,6 +2817,11 @@ class LocalCodingAgent:
|
|||||||
state = 'blocked by permissions'
|
state = 'blocked by permissions'
|
||||||
if tool.name in {'write_file', 'edit_file'} and not permissions.allow_file_write:
|
if tool.name in {'write_file', 'edit_file'} and not permissions.allow_file_write:
|
||||||
state = 'blocked by permissions'
|
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}]')
|
lines.append(f'- `{tool.name}`: {tool.description} [{state}]')
|
||||||
return '\n'.join(lines)
|
return '\n'.join(lines)
|
||||||
|
|
||||||
@@ -2575,6 +2832,64 @@ class LocalCodingAgent:
|
|||||||
return '# Memory\n\nNo CLAUDE.md memory files are currently loaded.'
|
return '# Memory\n\nNo CLAUDE.md memory files are currently loaded.'
|
||||||
return '\n'.join(['# Memory', '', claude_md])
|
return '\n'.join(['# Memory', '', claude_md])
|
||||||
|
|
||||||
|
def render_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:
|
def render_status_report(self) -> str:
|
||||||
lines = [
|
lines = [
|
||||||
'# Status',
|
'# Status',
|
||||||
@@ -2585,6 +2900,16 @@ class LocalCodingAgent:
|
|||||||
f'- Session ID: {self.active_session_id or "none"}',
|
f'- Session ID: {self.active_session_id or "none"}',
|
||||||
f'- Last session loaded: {"yes" if self.last_session is not None else "no"}',
|
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:
|
if self.last_session_path is not None:
|
||||||
lines.append(f'- Session path: {self.last_session_path}')
|
lines.append(f'- Session path: {self.last_session_path}')
|
||||||
if self.last_run_result is not None:
|
if self.last_run_result is not None:
|
||||||
@@ -2689,3 +3014,48 @@ class LocalCodingAgent:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
return replace(result, events=tuple(appended))
|
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]`.',
|
'Commands are in the form `/command [args]`.',
|
||||||
)
|
)
|
||||||
|
|
||||||
if parsed.is_mcp:
|
normalized_name = (
|
||||||
return _local_result(
|
parsed.command_name[:-6]
|
||||||
input_text,
|
if parsed.is_mcp and parsed.command_name.endswith(' (MCP)')
|
||||||
'MCP slash commands are not implemented in the Python runtime yet.',
|
else parsed.command_name
|
||||||
)
|
)
|
||||||
|
spec = find_slash_command(normalized_name)
|
||||||
spec = find_slash_command(parsed.command_name)
|
|
||||||
if spec is None:
|
if spec is None:
|
||||||
if looks_like_command(parsed.command_name):
|
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 SlashCommandResult(handled=False, should_query=True, prompt=input_text)
|
||||||
|
|
||||||
return spec.handler(agent, parsed.args.strip(), 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.',
|
description='Show the raw environment, user context, and system context snapshot.',
|
||||||
handler=_handle_context_raw,
|
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(
|
SlashCommandSpec(
|
||||||
names=('prompt', 'system-prompt'),
|
names=('prompt', 'system-prompt'),
|
||||||
description='Render the effective Python 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.',
|
description='Show the active tool permission mode.',
|
||||||
handler=_handle_permissions,
|
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(
|
SlashCommandSpec(
|
||||||
names=('model',),
|
names=('model',),
|
||||||
description='Show or update the active model for the current agent instance.',
|
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())
|
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:
|
def _handle_prompt(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
|
||||||
return _local_result(input_text, agent.render_system_prompt())
|
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())
|
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:
|
def _handle_model(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||||
if not args:
|
if not args:
|
||||||
return _local_result(input_text, f'Current model: {agent.model_config.model}')
|
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 hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import selectors
|
import selectors
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
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
|
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):
|
class ToolPermissionError(RuntimeError):
|
||||||
"""Raised when the runtime configuration does not allow a tool action."""
|
"""Raised when the runtime configuration does not allow a tool action."""
|
||||||
@@ -27,6 +33,10 @@ class ToolExecutionContext:
|
|||||||
command_timeout_seconds: float
|
command_timeout_seconds: float
|
||||||
max_output_chars: int
|
max_output_chars: int
|
||||||
permissions: AgentPermissions
|
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[
|
ToolHandler = Callable[
|
||||||
@@ -60,8 +70,20 @@ class AgentTool:
|
|||||||
else:
|
else:
|
||||||
content, metadata = result, {}
|
content, metadata = result, {}
|
||||||
return ToolExecutionResult(name=self.name, ok=True, content=content, metadata=metadata)
|
return ToolExecutionResult(name=self.name, ok=True, content=content, metadata=metadata)
|
||||||
except (ToolPermissionError, ToolExecutionError, OSError, subprocess.SubprocessError) as exc:
|
except ToolPermissionError as exc:
|
||||||
return ToolExecutionResult(name=self.name, ok=False, content=str(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)
|
@dataclass(frozen=True)
|
||||||
@@ -73,12 +95,23 @@ class ToolStreamUpdate:
|
|||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
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(
|
return ToolExecutionContext(
|
||||||
root=config.cwd.resolve(),
|
root=config.cwd.resolve(),
|
||||||
command_timeout_seconds=config.command_timeout_seconds,
|
command_timeout_seconds=config.command_timeout_seconds,
|
||||||
max_output_chars=config.max_output_chars,
|
max_output_chars=config.max_output_chars,
|
||||||
permissions=config.permissions,
|
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,
|
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(
|
AgentTool(
|
||||||
name='delegate_agent',
|
name='delegate_agent',
|
||||||
description='Delegate a subtask to a nested Python coding agent and return its summary.',
|
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,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=context.command_timeout_seconds,
|
timeout=context.command_timeout_seconds,
|
||||||
|
env=_build_subprocess_env(context),
|
||||||
)
|
)
|
||||||
stdout = completed.stdout or ''
|
stdout = completed.stdout or ''
|
||||||
stderr = completed.stderr 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(
|
def _stream_bash(
|
||||||
arguments: dict[str, Any],
|
arguments: dict[str, Any],
|
||||||
context: ToolExecutionContext,
|
context: ToolExecutionContext,
|
||||||
@@ -570,6 +955,7 @@ def _stream_bash(
|
|||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
bufsize=1,
|
bufsize=1,
|
||||||
|
env=_build_subprocess_env(context),
|
||||||
)
|
)
|
||||||
except (ToolPermissionError, ToolExecutionError, OSError, subprocess.SubprocessError) as exc:
|
except (ToolPermissionError, ToolExecutionError, OSError, subprocess.SubprocessError) as exc:
|
||||||
yield ToolStreamUpdate(
|
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(
|
def _drain_registered_streams(
|
||||||
selector: selectors.BaseSelector,
|
selector: selectors.BaseSelector,
|
||||||
stdout_chunks: list[str],
|
stdout_chunks: list[str],
|
||||||
@@ -721,6 +1196,12 @@ def _drain_registered_streams(
|
|||||||
pass
|
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(
|
def _stream_static_text_result(
|
||||||
result: ToolExecutionResult,
|
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 argparse
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
import json
|
import json
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
from .background_runtime import BackgroundSessionRuntime, build_background_worker_command
|
||||||
from .agent_runtime import LocalCodingAgent
|
from .agent_runtime import LocalCodingAgent
|
||||||
from .agent_types import (
|
from .agent_types import (
|
||||||
AgentPermissions,
|
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:
|
def _add_agent_resume_args(parser: argparse.ArgumentParser) -> None:
|
||||||
parser.add_argument('session_id')
|
parser.add_argument('session_id')
|
||||||
parser.add_argument('prompt')
|
parser.add_argument('prompt')
|
||||||
@@ -487,6 +547,34 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
agent_parser.add_argument('--show-transcript', action='store_true')
|
agent_parser.add_argument('--show-transcript', action='store_true')
|
||||||
_add_agent_common_args(agent_parser, include_backend=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 = subparsers.add_parser('agent-chat', help='run an interactive Python local-model chat loop')
|
||||||
chat_parser.add_argument('prompt', nargs='?')
|
chat_parser.add_argument('prompt', nargs='?')
|
||||||
chat_parser.add_argument('--resume-session-id')
|
chat_parser.add_argument('--resume-session-id')
|
||||||
@@ -640,6 +728,82 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
result = agent.run(args.prompt)
|
result = agent.run(args.prompt)
|
||||||
_print_agent_result(result, show_transcript=args.show_transcript)
|
_print_agent_result(result, show_transcript=args.show_transcript)
|
||||||
return 0
|
return 0
|
||||||
|
if args.command == 'agent-bg':
|
||||||
|
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':
|
if args.command == 'agent-chat':
|
||||||
agent = _build_agent(args)
|
agent = _build_agent(args)
|
||||||
return _run_agent_chat_loop(
|
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] + '...'
|
||||||
+75
-2
@@ -1,5 +1,78 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .task import PortingTask
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
__all__ = ['PortingTask']
|
|
||||||
|
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 tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from src.agent_context import (
|
from src.agent_context import (
|
||||||
build_context_snapshot,
|
build_context_snapshot,
|
||||||
clear_context_caches,
|
clear_context_caches,
|
||||||
set_system_prompt_injection,
|
set_system_prompt_injection,
|
||||||
)
|
)
|
||||||
|
from src.plan_runtime import PlanRuntime
|
||||||
from src.agent_types import AgentRuntimeConfig
|
from src.agent_types import AgentRuntimeConfig
|
||||||
|
from src.task_runtime import TaskRuntime
|
||||||
|
|
||||||
|
|
||||||
class AgentContextTests(unittest.TestCase):
|
class AgentContextTests(unittest.TestCase):
|
||||||
@@ -57,6 +60,76 @@ class AgentContextTests(unittest.TestCase):
|
|||||||
self.assertIn('demo-plugin', snapshot.user_context['pluginCache'])
|
self.assertIn('demo-plugin', snapshot.user_context['pluginCache'])
|
||||||
self.assertIn('1.2.3', 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')
|
@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:
|
def test_git_status_snapshot_contains_branch_and_status(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import unittest
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from src.agent_prompting import build_prompt_context, build_system_prompt_parts, render_system_prompt
|
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_runtime import LocalCodingAgent
|
||||||
from src.agent_session import AgentSessionState
|
from src.agent_session import AgentSessionState
|
||||||
from src.agent_tools import default_tool_registry
|
from src.agent_tools import default_tool_registry
|
||||||
from src.agent_types import AgentPermissions, AgentRuntimeConfig, ModelConfig
|
from src.agent_types import AgentPermissions, AgentRuntimeConfig, ModelConfig
|
||||||
|
from src.task_runtime import TaskRuntime
|
||||||
|
|
||||||
|
|
||||||
class AgentPromptingTests(unittest.TestCase):
|
class AgentPromptingTests(unittest.TestCase):
|
||||||
@@ -79,3 +81,83 @@ class AgentPromptingTests(unittest.TestCase):
|
|||||||
|
|
||||||
prompt = render_system_prompt(parts)
|
prompt = render_system_prompt(parts)
|
||||||
self.assertIn('# Plugins', prompt)
|
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.assertFalse(result.ok)
|
||||||
self.assertIn('--allow-write', result.content)
|
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:
|
def test_local_slash_command_returns_without_model_call(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
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 tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from src.agent_runtime import LocalCodingAgent
|
from src.agent_runtime import LocalCodingAgent
|
||||||
from src.agent_slash_commands import looks_like_command, parse_slash_command
|
from src.agent_slash_commands import looks_like_command, parse_slash_command
|
||||||
from src.agent_types import AgentRuntimeConfig, ModelConfig
|
from src.agent_types import AgentRuntimeConfig, ModelConfig
|
||||||
|
from src.plan_runtime import PlanRuntime
|
||||||
|
from src.task_runtime import TaskRuntime
|
||||||
|
|
||||||
|
|
||||||
class AgentSlashCommandTests(unittest.TestCase):
|
class AgentSlashCommandTests(unittest.TestCase):
|
||||||
@@ -53,6 +56,72 @@ class AgentSlashCommandTests(unittest.TestCase):
|
|||||||
self.assertIn('### Estimated usage by category', result.final_output)
|
self.assertIn('### Estimated usage by category', result.final_output)
|
||||||
self.assertIn('### Memory Files', 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:
|
def test_tools_and_status_commands_render_local_reports(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
agent = LocalCodingAgent(
|
agent = LocalCodingAgent(
|
||||||
@@ -66,6 +135,31 @@ class AgentSlashCommandTests(unittest.TestCase):
|
|||||||
self.assertIn('# Status', status_result.final_output)
|
self.assertIn('# Status', status_result.final_output)
|
||||||
self.assertIn('Last run: none', status_result.final_output)
|
self.assertIn('Last run: none', status_result.final_output)
|
||||||
|
|
||||||
|
def test_hooks_and_trust_commands_render_local_reports(self) -> None:
|
||||||
|
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:
|
def test_clear_command_clears_saved_runtime_state(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
agent = LocalCodingAgent(
|
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