Merge pull request #9 from HarnessLab/feature/readme-update-april-2026

add claw-code-agent/src/plan_runtime.py and claw-code-agent/src/backg…
This commit is contained in:
Abdelrahman Abdallah
2026-04-03 18:32:02 +02:00
committed by GitHub
23 changed files with 4344 additions and 93 deletions
+86 -25
View File
@@ -61,6 +61,23 @@ Done:
- [x] Manifest-defined plugin `beforeTool` guidance
- [x] Manifest-defined plugin tool-result guidance injected back into the transcript
- [x] Plugin runtime session-state persistence and resume restoration
- [x] Manifest-based hook/policy runtime discovery
- [x] Hook/policy before-prompt runtime injection
- [x] Hook/policy after-turn runtime events
- [x] Hook/policy tool preflight guidance
- [x] Hook/policy tool blocking
- [x] Hook/policy after-tool guidance
- [x] Hook/policy budget override loading
- [x] Hook/policy safe-environment overlay for shell tools
- [x] Local manifest-backed MCP resource discovery
- [x] Local MCP resource listing and reading
- [x] MCP-backed runtime tools for local resource access
- [x] Local persistent task runtime discovery
- [x] Local task create/get/list/update runtime flows
- [x] Local todo-list replacement runtime flow
- [x] Local persistent plan runtime discovery
- [x] Local plan get/update/clear runtime flows
- [x] Local plan-to-task sync flow
- [x] Compaction metadata with compacted message ids
- [x] Compaction metadata with preserved-tail ids and compaction depth
- [x] Compaction metadata with compacted/preserved lineage ids and revision summaries
@@ -103,16 +120,16 @@ Done:
- [x] `agent-prompt` command
- [x] `agent-context` command
- [x] `agent-context-raw` command
- [x] Local background session mode
- [x] Local background session listing (`agent-ps`)
- [x] Local background session logs (`agent-logs`)
- [x] Local background attach snapshot (`agent-attach`)
- [x] Local background kill flow (`agent-kill`)
- [x] Inventory/helper commands such as `summary`, `manifest`, `commands`, and `tools`
Missing:
- [ ] Daemon worker mode
- [ ] Background session mode
- [ ] Session process listing (`ps`)
- [ ] Background session logs
- [ ] Background attach flow
- [ ] Background kill flow
- [ ] Remote-control / bridge runtime mode
- [ ] Browser/native-host runtime mode
- [ ] Computer-use MCP mode
@@ -133,6 +150,10 @@ Done:
- [x] Environment-aware prompt sections
- [x] User context reminder injection
- [x] Custom system prompt override and append support
- [x] Local hook/policy guidance section in the Python system prompt
- [x] Local MCP guidance section in the Python system prompt
- [x] Local planning guidance section in the Python system prompt
- [x] Local task guidance section in the Python system prompt
Missing:
@@ -160,6 +181,11 @@ Done:
- [x] Raw context inspection command
- [x] Plugin cache snapshot injection
- [x] Manifest-based plugin runtime summary injection
- [x] Manifest-based hook/policy summary injection
- [x] Trust-mode, managed-settings, and safe-env context injection
- [x] Manifest-based MCP runtime summary injection
- [x] Manifest-based plan runtime summary injection
- [x] Manifest-based task runtime summary injection
Missing:
@@ -182,9 +208,20 @@ Done:
- [x] `/usage`
- [x] `/context-raw`
- [x] `/env`
- [x] `/mcp`
- [x] `/resources`
- [x] `/resource`
- [x] `/plan`
- [x] `/planner`
- [x] `/tasks`
- [x] `/todo`
- [x] `/task`
- [x] `/prompt`
- [x] `/system-prompt`
- [x] `/permissions`
- [x] `/hooks`
- [x] `/policy`
- [x] `/trust`
- [x] `/model`
- [x] `/tools`
- [x] `/memory`
@@ -196,7 +233,7 @@ Missing:
- [ ] Full npm slash-command surface
- [ ] Slash commands backed by MCP integration
- [ ] Slash commands tied to task/plan systems
- [ ] Slash commands tied to task/plan systems beyond the current local `/plan`, `/tasks`, and `/task` flows
- [ ] Slash commands tied to remote/background sessions
- [ ] Slash commands with richer interactive behavior
- [ ] Slash commands tied to plugins and bundled skills
@@ -213,22 +250,29 @@ Done:
- [x] `glob_search`
- [x] `grep_search`
- [x] `bash`
- [x] `mcp_list_resources`
- [x] `mcp_read_resource`
- [x] `plan_get`
- [x] `update_plan`
- [x] `plan_clear`
- [x] `delegate_agent`
- [x] `task_list`
- [x] `task_get`
- [x] `task_create`
- [x] `task_update`
- [x] `todo_write`
Missing:
- [ ] Agent spawning tool
- [ ] Agent spawning tool parity beyond the current `delegate_agent` runtime tool
- [ ] Skill tool
- [ ] Notebook edit tool
- [ ] Web fetch tool
- [ ] Web search tool
- [ ] Todo write tool
- [ ] Ask-user-question tool
- [ ] LSP tool
- [ ] MCP resource listing tool
- [ ] MCP resource read tool
- [ ] Tool search tool
- [ ] Config tool
- [ ] Task create/get/update/list tools
- [ ] Team create/delete tools
- [ ] Send-message tool
- [ ] Terminal capture tool
@@ -246,14 +290,18 @@ Done:
- [x] Basic local command dispatch for the Python runtime
- [x] Inventory view of mirrored command names
- [x] Local persistent task runtime with create/get/list/update flows
- [x] Local todo-list replacement flow
- [x] Local persistent plan runtime with get/update/clear flows
- [x] Local plan-to-task sync flow
- [x] Local background task management for agent worker sessions
Missing:
- [ ] Real implementation of the larger upstream command tree
- [ ] Task orchestration system
- [ ] Planner / task execution parity
- [ ] Task orchestration system beyond the current local plan/task sync runtime
- [ ] Planner / task execution parity beyond the current local plan persistence and sync flow
- [ ] Team / collaboration command flows
- [ ] Background task management
- [ ] Command-specific session behaviors
- [ ] Full `src/commands/*` parity
- [ ] Full `src/tasks/*` parity
@@ -266,16 +314,19 @@ Done:
- [x] Write-gated mode
- [x] Shell-gated mode
- [x] Unsafe mode for destructive shell actions
- [x] Local hook/policy manifest discovery
- [x] Hook before-prompt and after-turn runtime handling
- [x] Hook/policy tool preflight, deny, and after-tool handling
- [x] Policy budget override loading
- [x] Managed settings loading and reporting
- [x] Safe environment loading for shell tool context
- [x] Trust reporting and hook/policy slash commands
- [x] Permission-denial runtime events for policy/tool blocks
Missing:
- [ ] Hooks runtime
- [ ] Tool-permission workflow parity
- [ ] Policy limit loading
- [ ] Managed settings loading
- [ ] Trust-gated initialization
- [ ] Safe environment loading parity
- [ ] More exact denial tracking
- [ ] Hook-config management
- [ ] Full hooks and policy parity
@@ -284,13 +335,15 @@ Missing:
Done:
- [x] Placeholder mirrored package layout for plugins, skills, services, and remote subsystems
- [x] Local manifest-backed MCP discovery
- [x] Local MCP resource listing and reading
- [x] MCP-backed runtime tools for local resource access
Missing:
- [ ] Real MCP client support
- [ ] MCP server integration
- [ ] MCP resource listing and reading
- [ ] MCP-backed tools
- [ ] Full MCP-backed tool parity beyond the current local resource list/read tools
- [ ] Plugin discovery and loading
- [ ] Bundled plugin support
- [ ] Plugin lifecycle management
@@ -323,12 +376,12 @@ Missing:
Done:
- [x] Session save/resume on local disk
- [x] Local background agent processes
- [x] Local background attach/log/kill workflows
Missing:
- [ ] Remote execution modes
- [ ] Background agent processes
- [ ] Background attach/log/kill workflows
- [ ] Team runtime features
- [ ] Team messaging features
- [ ] Shared remote state
@@ -383,6 +436,13 @@ Working Python runtime today:
- [x] `src/agent_session.py`
- [x] `src/agent_slash_commands.py`
- [x] `src/agent_types.py`
- [x] `src/mcp_runtime.py`
- [x] `src/plan_runtime.py`
- [x] `src/plugin_runtime.py`
- [x] `src/hook_policy.py`
- [x] `src/background_runtime.py`
- [x] `src/task.py`
- [x] `src/task_runtime.py`
- [x] `src/openai_compat.py`
- [x] `src/session_store.py`
- [x] `src/permissions.py`
@@ -405,9 +465,10 @@ Mirrored inventory / scaffold areas that still need real implementation work:
- [ ] Expand the real Python tool registry toward upstream `tools.ts`
- [ ] Replace more snapshot-backed mirrored modules with working runtime code
- [ ] Implement real MCP support
- [ ] Implement hooks and policy flows
- [ ] Expand hooks and policy parity beyond the current manifest/runtime implementation
- [ ] Build a real interactive REPL / TUI
- [ ] Add tokenizer-accurate context accounting
- [ ] Add background and remote session modes
- [ ] Expand background session parity beyond the current local worker/log/attach model
- [ ] Add real remote session modes
- [ ] Port more of the command/task system
- [ ] Close the gap between the mirrored workspace and the working runtime
+338 -35
View File
@@ -54,6 +54,8 @@ pip install -e .
```bash
python3 -m src.main --help
python3 -m src.main agent --help
python3 -m src.main agent-bg --help
python3 -m src.main agent-ps --help
python3 -m src.main agent-chat --help
python3 -m src.main agent-resume --help
```
@@ -73,8 +75,12 @@ python3 -m src.main agent "/help"
python3 -m src.main agent "/commands"
python3 -m src.main agent "/context" --cwd ..
python3 -m src.main agent "/context-raw" --cwd ..
python3 -m src.main agent "/plan" --cwd ..
python3 -m src.main agent "/prompt" --cwd ..
python3 -m src.main agent "/permissions" --cwd ..
python3 -m src.main agent "/hooks" --cwd ..
python3 -m src.main agent "/policy" --cwd ..
python3 -m src.main agent "/trust" --cwd ..
python3 -m src.main agent "/tools" --cwd ..
python3 -m src.main agent "/memory" --cwd ..
python3 -m src.main agent "/status" --cwd ..
@@ -113,6 +119,67 @@ python3 -m src.main agent-context --cwd .. --add-dir /path/to/directory
python3 -m src.main agent-context --cwd .. --disable-claude-md
```
### 4.6 Hook/policy context and trust inspection
Create a local policy file:
```bash
mkdir -p ./test_cases
cat > ./test_cases/.claw-policy.json <<'EOF'
{
"trusted": false,
"managedSettings": {
"reviewMode": "strict"
},
"safeEnv": ["HOOK_SAFE_TOKEN"],
"hooks": {
"beforePrompt": ["Respect workspace policy before acting."],
"afterTurn": ["Persist the policy decision after each turn."],
"beforeTool": {
"read_file": ["Validate the path before reading."]
}
}
}
EOF
export HOOK_SAFE_TOKEN=demo-secret
```
Inspect the runtime view:
```bash
python3 -m src.main agent "/hooks" --cwd ./test_cases
python3 -m src.main agent "/trust" --cwd ./test_cases
python3 -m src.main agent "/permissions" --cwd ./test_cases
python3 -m src.main agent "/tools" --cwd ./test_cases
python3 -m src.main agent-context-raw --cwd ./test_cases
python3 -m src.main agent-prompt --cwd ./test_cases
```
### 4.7 Safe environment values in shell tools
```bash
python3 -m src.main agent \
"Run bash and print HOOK_SAFE_TOKEN, then explain where it came from." \
--cwd ./test_cases \
--allow-shell \
--show-transcript
```
### 4.8 Plan runtime context and prompt inspection
```bash
python3 -m src.main agent \
"Use update_plan to store a two-step plan for inspecting and editing the workspace." \
--cwd ./test_cases \
--allow-write \
--show-transcript
python3 -m src.main agent "/plan" --cwd ./test_cases
python3 -m src.main agent "/tasks" --cwd ./test_cases
python3 -m src.main agent-context-raw --cwd ./test_cases
python3 -m src.main agent-prompt --cwd ./test_cases
```
## 5. Core Agent Loop
### 5.1 Read-only run
@@ -227,6 +294,52 @@ python3 -m src.main agent \
--unsafe
```
### 6.7 Hook/policy tool blocking
Update the policy to block `bash`:
```bash
cat > ./test_cases/.claw-policy.json <<'EOF'
{
"trusted": false,
"denyTools": ["bash"],
"hooks": {
"beforePrompt": ["Respect workspace policy before acting."],
"afterTurn": ["Persist the policy decision after each turn."]
}
}
EOF
```
Then test the block:
```bash
python3 -m src.main agent \
"Try to run bash and then explain what was blocked." \
--cwd ./test_cases \
--allow-shell \
--show-transcript
```
Look for:
- `hook_policy_tool_block`
- `tool_permission_denial`
- `plugin_tool_runtime` messages that now also include hook/policy guidance when present
### 6.8 Plan tools
```bash
python3 -m src.main agent \
"Use update_plan to store these steps: inspect the repository, implement the change, run verification. Mark the first step in_progress and sync the plan to tasks." \
--cwd ./test_cases \
--allow-write \
--show-transcript
python3 -m src.main agent "/plan" --cwd ./test_cases
python3 -m src.main agent "/tasks" --cwd ./test_cases
```
## 7. Session Persistence And Resume
### 7.1 Create a saved session
@@ -261,7 +374,50 @@ python3 -m src.main agent-resume \
ls -lt .port_sessions/agent
```
## 8. Structured Output / JSON Schema
## 8. Background Sessions
### 8.1 Launch a background session
Use a local slash-command prompt first so you can verify the background workflow without depending on the model backend:
```bash
python3 -m src.main agent-bg "/help" --cwd ./test_cases
```
This prints:
- `background_id=...`
- `pid=...`
- `log_path=...`
- `record_path=...`
### 8.2 List background sessions
```bash
python3 -m src.main agent-ps
```
### 8.3 Read background logs
```bash
python3 -m src.main agent-logs <background-id>
python3 -m src.main agent-logs <background-id> --tail 40
```
### 8.4 Attach to the current output snapshot
```bash
python3 -m src.main agent-attach <background-id>
python3 -m src.main agent-attach <background-id> --tail 40
```
### 8.5 Kill a running background session
```bash
python3 -m src.main agent-kill <background-id>
```
## 9. Structured Output / JSON Schema
Create a schema file:
@@ -290,9 +446,9 @@ python3 -m src.main agent \
--response-schema-strict
```
## 9. Budgets And Limits
## 10. Budgets And Limits
### 9.1 Total token budget
### 10.1 Total token budget
```bash
python3 -m src.main agent \
@@ -301,7 +457,7 @@ python3 -m src.main agent \
--max-total-tokens 50
```
### 9.2 Input / output token budgets
### 10.2 Input / output token budgets
```bash
python3 -m src.main agent \
@@ -311,7 +467,7 @@ python3 -m src.main agent \
--max-output-tokens 80
```
### 9.3 Reasoning-token budget
### 10.3 Reasoning-token budget
```bash
python3 -m src.main agent \
@@ -320,7 +476,7 @@ python3 -m src.main agent \
--max-reasoning-tokens 10
```
### 9.4 Tool-call budget
### 10.4 Tool-call budget
```bash
python3 -m src.main agent \
@@ -329,7 +485,7 @@ python3 -m src.main agent \
--max-tool-calls 1
```
### 9.5 Delegated-task budget
### 10.5 Delegated-task budget
```bash
python3 -m src.main agent \
@@ -338,7 +494,7 @@ python3 -m src.main agent \
--max-delegated-tasks 1
```
### 9.6 Cost budget
### 10.6 Cost budget
```bash
python3 -m src.main agent \
@@ -349,7 +505,7 @@ python3 -m src.main agent \
--max-budget-usd 0.000001
```
### 9.7 Model-call budget
### 10.7 Model-call budget
```bash
python3 -m src.main agent \
@@ -358,7 +514,7 @@ python3 -m src.main agent \
--max-model-calls 1
```
### 9.8 Session-turn budget
### 10.8 Session-turn budget
```bash
python3 -m src.main agent \
@@ -367,9 +523,27 @@ python3 -m src.main agent \
--max-session-turns 1
```
## 10. Streaming, Continuation, And Context Reduction
### 10.9 Budget overrides from local policy
### 10.1 Streaming assistant output
```bash
cat > ./test_cases/.claw-policy.json <<'EOF'
{
"budget": {
"max_model_calls": 0
}
}
EOF
python3 -m src.main agent \
"Say hello once." \
--cwd ./test_cases
```
Expected result: the run stops with a model-call budget exceeded message even though you did not pass `--max-model-calls` on the CLI.
## 11. Streaming, Continuation, And Context Reduction
### 11.1 Streaming assistant output
```bash
python3 -m src.main agent \
@@ -379,7 +553,7 @@ python3 -m src.main agent \
--show-transcript
```
### 10.2 Automatic continuation after truncation
### 11.2 Automatic continuation after truncation
Use a small output budget so the backend is more likely to stop early:
@@ -391,7 +565,7 @@ python3 -m src.main agent \
--show-transcript
```
### 10.3 Snipping older context
### 11.3 Snipping older context
```bash
python3 -m src.main agent \
@@ -402,7 +576,7 @@ python3 -m src.main agent \
--show-transcript
```
### 10.4 Compaction boundaries
### 11.4 Compaction boundaries
```bash
python3 -m src.main agent \
@@ -413,9 +587,9 @@ python3 -m src.main agent \
--show-transcript
```
## 11. File History Replay
## 12. File History Replay
### 11.1 Create file history
### 12.1 Create file history
```bash
python3 -m src.main agent \
@@ -424,7 +598,7 @@ python3 -m src.main agent \
--allow-write
```
### 11.2 Resume and inspect replay
### 12.2 Resume and inspect replay
```bash
python3 -m src.main agent-resume \
@@ -436,9 +610,9 @@ python3 -m src.main agent-resume \
Look for `file_history_replay` messages in the transcript.
## 12. Nested Delegation
## 13. Nested Delegation
### 12.1 Basic delegated subtask
### 13.1 Basic delegated subtask
```bash
python3 -m src.main agent \
@@ -447,7 +621,7 @@ python3 -m src.main agent \
--show-transcript
```
### 12.2 Multiple delegated subtasks
### 13.2 Multiple delegated subtasks
```bash
python3 -m src.main agent \
@@ -456,7 +630,7 @@ python3 -m src.main agent \
--show-transcript
```
### 12.3 Resume a delegated child session
### 13.3 Resume a delegated child session
1. Seed a normal saved session:
@@ -475,7 +649,7 @@ python3 -m src.main agent \
--show-transcript
```
### 12.4 Topological dependency batches
### 13.4 Topological dependency batches
```bash
python3 -m src.main agent \
@@ -490,7 +664,7 @@ Look for:
- `delegate_group_result`
- `batch_index=...`
## 13. Plugin Runtime
## 14. Plugin Runtime
Create a local plugin manifest:
@@ -531,14 +705,14 @@ cat > ./test_cases/plugins/demo/plugin.json <<'EOF'
EOF
```
### 13.1 Plugin prompt/context discovery
### 14.1 Plugin prompt/context discovery
```bash
python3 -m src.main agent-prompt --cwd ./test_cases
python3 -m src.main agent-context-raw --cwd ./test_cases
```
### 13.2 Plugin alias tool
### 14.2 Plugin alias tool
```bash
echo "hello plugin" > ./test_cases/hello.txt
@@ -548,7 +722,7 @@ python3 -m src.main agent \
--show-transcript
```
### 13.3 Plugin virtual tool
### 14.3 Plugin virtual tool
```bash
python3 -m src.main agent \
@@ -557,7 +731,7 @@ python3 -m src.main agent \
--show-transcript
```
### 13.4 Plugin before/after tool guidance
### 14.4 Plugin before/after tool guidance
```bash
python3 -m src.main agent \
@@ -566,7 +740,7 @@ python3 -m src.main agent \
--show-transcript
```
### 13.5 Plugin lifecycle with resume/persist
### 14.5 Plugin lifecycle with resume/persist
1. Start a session:
@@ -591,9 +765,138 @@ Look for:
- `Plugin resume hooks:`
- `Plugin runtime state:`
## 14. Query Engine And Workspace Commands
## 15. MCP Runtime
### 14.1 Workspace inventory
Create a local MCP manifest:
```bash
mkdir -p ./test_cases_mcp
printf 'mcp notes\n' > ./test_cases_mcp/notes.txt
cat > ./test_cases_mcp/.claw-mcp.json <<'EOF'
{
"servers": [
{
"name": "workspace",
"resources": [
{
"uri": "mcp://workspace/notes",
"name": "Notes",
"path": "notes.txt",
"mimeType": "text/plain"
},
{
"uri": "mcp://workspace/inline",
"name": "Inline",
"text": "inline body"
}
]
}
]
}
EOF
```
### 15.1 MCP context and slash commands
```bash
python3 -m src.main agent "/mcp" --cwd ./test_cases_mcp
python3 -m src.main agent "/resources" --cwd ./test_cases_mcp
python3 -m src.main agent "/resource mcp://workspace/notes" --cwd ./test_cases_mcp
python3 -m src.main agent "/mcp (MCP)" --cwd ./test_cases_mcp
python3 -m src.main agent-context-raw --cwd ./test_cases_mcp
python3 -m src.main agent-prompt --cwd ./test_cases_mcp
```
### 15.2 MCP tools through the model loop
```bash
python3 -m src.main agent \
"List the available MCP resources, then read mcp://workspace/notes and summarize it." \
--cwd ./test_cases_mcp \
--show-transcript
```
### 15.3 Read inline MCP resources
```bash
python3 -m src.main agent \
"Read the MCP resource mcp://workspace/inline and repeat its content." \
--cwd ./test_cases_mcp \
--show-transcript
```
## 16. Task Runtime
Create a clean task workspace:
```bash
mkdir -p ./test_cases_tasks
rm -rf ./test_cases_tasks/.port_sessions
```
### 16.1 Task slash commands
```bash
python3 -m src.main agent "/tasks" --cwd ./test_cases_tasks
python3 -m src.main agent "/todo" --cwd ./test_cases_tasks
python3 -m src.main agent "/task missing-task-id" --cwd ./test_cases_tasks
python3 -m src.main agent-context-raw --cwd ./test_cases_tasks
python3 -m src.main agent-prompt --cwd ./test_cases_tasks
```
### 16.2 Create and update tasks through the model loop
```bash
python3 -m src.main agent \
"Create a task called Review runtime tasks, then list the current tasks." \
--cwd ./test_cases_tasks \
--allow-write \
--show-transcript
```
Then inspect the stored task file:
```bash
cat ./test_cases_tasks/.port_sessions/task_runtime.json
```
### 16.3 Replace the todo list
```bash
python3 -m src.main agent \
"Replace the current todo list with three tasks: inspect runtime, verify tests, and update docs. Mark inspect runtime as done and the others as todo." \
--cwd ./test_cases_tasks \
--allow-write \
--show-transcript
```
### 16.4 Read back task state
```bash
python3 -m src.main agent "/tasks" --cwd ./test_cases_tasks
python3 -m src.main agent \
"List the current tasks and show me the id of each one." \
--cwd ./test_cases_tasks \
--show-transcript
```
### 16.5 Plan runtime and task sync
```bash
python3 -m src.main agent \
"Use update_plan to create three steps: inspect runtime, verify tests, update docs. Mark inspect runtime completed and sync to tasks." \
--cwd ./test_cases_tasks \
--allow-write \
--show-transcript
python3 -m src.main agent "/plan" --cwd ./test_cases_tasks
python3 -m src.main agent "/tasks" --cwd ./test_cases_tasks
cat ./test_cases_tasks/.port_sessions/plan_runtime.json
```
## 17. Query Engine And Workspace Commands
### 17.1 Workspace inventory
```bash
python3 -m src.main summary
@@ -603,7 +906,7 @@ python3 -m src.main commands --limit 20
python3 -m src.main tools --limit 20
```
### 14.2 Query routing and bootstrap reports
### 17.2 Query routing and bootstrap reports
```bash
python3 -m src.main route "inspect the runtime and tools" --limit 10
@@ -611,14 +914,14 @@ python3 -m src.main bootstrap "inspect the runtime and tools" --limit 10
python3 -m src.main turn-loop "inspect the runtime and tools" --limit 5 --max-turns 3
```
### 14.3 Session flushing for the mirrored workspace
### 17.3 Session flushing for the mirrored workspace
```bash
python3 -m src.main flush-transcript "store a temporary transcript"
python3 -m src.main load-session <session-id>
```
## 15. Remote/Direct Mode Simulations
## 18. Remote/Direct Mode Simulations
These are mirrored workspace simulation commands, not the real agent runtime:
@@ -630,7 +933,7 @@ python3 -m src.main direct-connect-mode demo-target
python3 -m src.main deep-link-mode demo-target
```
## 16. Parity Tracking Workflow
## 19. Parity Tracking Workflow
Use this every time a new feature lands:
+11
View File
@@ -13,14 +13,19 @@ from .agent_runtime import LocalCodingAgent
from .agent_session import AgentMessage, AgentSessionState
from .agent_tools import build_tool_context, default_tool_registry, execute_tool
from .agent_types import AgentPermissions, AgentRunResult, AgentRuntimeConfig, ModelConfig
from .background_runtime import BackgroundSessionRuntime
from .commands import PORTED_COMMANDS, build_command_backlog
from .mcp_runtime import MCPRuntime
from .parity_audit import ParityAuditResult, run_parity_audit
from .plan_runtime import PlanRuntime, PlanStep
from .plugin_runtime import PluginRuntime
from .port_manifest import PortManifest, build_port_manifest
from .query_engine import QueryEnginePort, TurnResult
from .runtime import PortRuntime, RuntimeSession
from .session_store import StoredSession, load_session, save_session
from .system_init import build_system_init_message
from .task import PortingTask
from .task_runtime import TaskRuntime
from .tools import PORTED_TOOLS, build_tool_backlog
__all__ = [
@@ -31,15 +36,21 @@ __all__ = [
'AgentRuntimeConfig',
'AgentMessage',
'AgentSessionState',
'BackgroundSessionRuntime',
'LocalCodingAgent',
'MCPRuntime',
'ModelConfig',
'ParityAuditResult',
'PlanRuntime',
'PlanStep',
'PortManifest',
'PortRuntime',
'PluginRuntime',
'PortingTask',
'QueryEnginePort',
'RuntimeSession',
'StoredSession',
'TaskRuntime',
'TurnResult',
'PORTED_COMMANDS',
'PORTED_TOOLS',
+33
View File
@@ -9,7 +9,11 @@ from functools import lru_cache
from pathlib import Path
from .agent_plugin_cache import load_plugin_cache_summary
from .hook_policy import HookPolicyRuntime
from .mcp_runtime import MCPRuntime
from .plan_runtime import PlanRuntime
from .plugin_runtime import PluginRuntime
from .task_runtime import TaskRuntime
from .agent_types import AgentRuntimeConfig
MAX_STATUS_CHARS = 2000
@@ -197,6 +201,35 @@ def _get_user_context_cached(
plugin_runtime = PluginRuntime.from_workspace(Path(cwd), additional_working_directories)
if plugin_runtime.manifests:
context['pluginRuntime'] = plugin_runtime.render_summary()
hook_policy_runtime = HookPolicyRuntime.from_workspace(Path(cwd), additional_working_directories)
if hook_policy_runtime.manifests:
context['hookPolicy'] = hook_policy_runtime.render_summary()
managed_settings = hook_policy_runtime.managed_settings()
if managed_settings:
context['managedSettings'] = '\n'.join(
f'{key}={value}'
for key, value in sorted(managed_settings.items())
)
safe_env = hook_policy_runtime.safe_env()
if safe_env:
context['safeEnv'] = '\n'.join(
f'{key}={value}'
for key, value in sorted(safe_env.items())
)
context['trustMode'] = (
'Workspace trust mode: trusted'
if hook_policy_runtime.is_trusted()
else 'Workspace trust mode: untrusted'
)
mcp_runtime = MCPRuntime.from_workspace(Path(cwd), additional_working_directories)
if mcp_runtime.resources:
context['mcpRuntime'] = mcp_runtime.render_summary()
plan_runtime = PlanRuntime.from_workspace(Path(cwd))
if plan_runtime.steps:
context['planRuntime'] = plan_runtime.render_summary()
task_runtime = TaskRuntime.from_workspace(Path(cwd))
if task_runtime.tasks:
context['taskRuntime'] = task_runtime.render_summary()
return context
+54
View File
@@ -91,6 +91,10 @@ def build_system_prompt_parts(
get_actions_section(),
get_using_your_tools_section(enabled_tool_names),
get_plugin_guidance_section(prompt_context),
get_mcp_guidance_section(prompt_context),
get_plan_guidance_section(prompt_context),
get_task_guidance_section(prompt_context),
get_hook_policy_guidance_section(prompt_context),
get_tone_and_style_section(),
get_output_efficiency_section(),
SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
@@ -207,6 +211,56 @@ def get_plugin_guidance_section(prompt_context: PromptContext) -> str:
return '\n'.join(['# Plugins', *prepend_bullets(items)])
def get_hook_policy_guidance_section(prompt_context: PromptContext) -> str:
hook_policy = prompt_context.user_context.get('hookPolicy')
trust_mode = prompt_context.user_context.get('trustMode')
if not hook_policy and not trust_mode:
return ''
items = [
'Workspace hook and policy manifests may inject trust mode, safe environment values, tool deny rules, and managed settings.',
'Treat workspace trust mode as high-priority local runtime guidance when deciding whether to edit files or run shell commands.',
'If a workspace policy blocks a tool, do not retry it unchanged. Change approach or explain the limitation.',
]
return '\n'.join(['# Hook Policy', *prepend_bullets(items)])
def get_mcp_guidance_section(prompt_context: PromptContext) -> str:
mcp_runtime = prompt_context.user_context.get('mcpRuntime')
if not mcp_runtime:
return ''
items = [
'Local MCP manifests may expose additional resources through the runtime.',
'Use MCP resource tools when the task depends on manifest-backed external context or curated workspace resources.',
'Treat MCP resource summaries as discoverability hints and prefer reading the specific resource URI before relying on its contents.',
]
return '\n'.join(['# MCP', *prepend_bullets(items)])
def get_task_guidance_section(prompt_context: PromptContext) -> str:
task_runtime = prompt_context.user_context.get('taskRuntime')
if not task_runtime:
return ''
items = [
'A local runtime task list may be available to track ongoing work.',
'Use task and todo tools to keep the plan state current when the task spans multiple steps or files.',
'Prefer updating the stored task list instead of repeating the same progress summary in free-form text.',
]
return '\n'.join(['# Tasks', *prepend_bullets(items)])
def get_plan_guidance_section(prompt_context: PromptContext) -> str:
plan_runtime = prompt_context.user_context.get('planRuntime')
if not plan_runtime:
return ''
items = [
'A local runtime plan may be available to track the active multi-step workflow.',
'Use the update_plan tool to keep the stored plan current when the task spans multiple milestones.',
'When the plan changes materially, update the stored plan rather than relying only on free-form progress text.',
'Plan updates can sync into the local task runtime, so keep step statuses accurate.',
]
return '\n'.join(['# Planning', *prepend_bullets(items)])
def get_output_efficiency_section() -> str:
return """# Communicating with the user
+386 -16
View File
@@ -10,6 +10,8 @@ from uuid import uuid4
from .agent_manager import AgentManager
from .agent_context import render_context_report as render_agent_context_report
from .agent_context_usage import collect_context_usage, estimate_tokens, format_context_usage
from .hook_policy import HookPolicyRuntime
from .mcp_runtime import MCPRuntime
from .agent_prompting import (
build_prompt_context,
build_system_prompt_parts,
@@ -38,7 +40,9 @@ from .agent_types import (
UsageStats,
)
from .openai_compat import OpenAICompatClient, OpenAICompatError
from .plan_runtime import PlanRuntime
from .plugin_runtime import PluginRuntime
from .task_runtime import TaskRuntime
from .session_store import (
StoredAgentSession,
load_agent_session,
@@ -69,6 +73,10 @@ class LocalCodingAgent:
managed_child_index: int | None = None
managed_label: str | None = None
plugin_runtime: PluginRuntime | None = None
hook_policy_runtime: HookPolicyRuntime | None = None
mcp_runtime: MCPRuntime | None = None
plan_runtime: PlanRuntime | None = None
task_runtime: TaskRuntime | None = None
last_session: AgentSessionState | None = field(default=None, init=False, repr=False)
last_run_result: AgentRunResult | None = field(default=None, init=False, repr=False)
active_session_id: str | None = field(default=None, init=False, repr=False)
@@ -86,6 +94,21 @@ class LocalCodingAgent:
self.runtime_config.cwd,
tuple(str(path) for path in self.runtime_config.additional_working_directories),
)
if self.hook_policy_runtime is None:
self.hook_policy_runtime = HookPolicyRuntime.from_workspace(
self.runtime_config.cwd,
tuple(str(path) for path in self.runtime_config.additional_working_directories),
)
if self.mcp_runtime is None:
self.mcp_runtime = MCPRuntime.from_workspace(
self.runtime_config.cwd,
tuple(str(path) for path in self.runtime_config.additional_working_directories),
)
if self.plan_runtime is None:
self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd)
if self.task_runtime is None:
self.task_runtime = TaskRuntime.from_workspace(self.runtime_config.cwd)
self.runtime_config = self._apply_hook_policy_budget_overrides(self.runtime_config)
registry = dict(self.tool_registry)
plugin_tools = self.plugin_runtime.register_tool_aliases(registry)
if plugin_tools:
@@ -95,7 +118,17 @@ class LocalCodingAgent:
registry = {**registry, **virtual_tools}
self.tool_registry = registry
self.client = OpenAICompatClient(self.model_config)
self.tool_context = build_tool_context(self.runtime_config)
self.tool_context = build_tool_context(
self.runtime_config,
extra_env=(
self.hook_policy_runtime.safe_env()
if self.hook_policy_runtime is not None
else None
),
mcp_runtime=self.mcp_runtime,
plan_runtime=self.plan_runtime,
task_runtime=self.task_runtime,
)
def set_model(self, model: str) -> None:
self.model_config = replace(self.model_config, model=model)
@@ -144,6 +177,67 @@ class LocalCodingAgent:
system_context=prompt_context.system_context,
)
def _apply_hook_policy_budget_overrides(
self,
runtime_config: AgentRuntimeConfig,
) -> AgentRuntimeConfig:
if self.hook_policy_runtime is None or not self.hook_policy_runtime.manifests:
return runtime_config
overrides = self.hook_policy_runtime.budget_overrides()
if not overrides:
return runtime_config
budget = runtime_config.budget_config
return replace(
runtime_config,
budget_config=BudgetConfig(
max_total_tokens=(
budget.max_total_tokens
if budget.max_total_tokens is not None
else _optional_policy_int(overrides.get('max_total_tokens'))
),
max_input_tokens=(
budget.max_input_tokens
if budget.max_input_tokens is not None
else _optional_policy_int(overrides.get('max_input_tokens'))
),
max_output_tokens=(
budget.max_output_tokens
if budget.max_output_tokens is not None
else _optional_policy_int(overrides.get('max_output_tokens'))
),
max_reasoning_tokens=(
budget.max_reasoning_tokens
if budget.max_reasoning_tokens is not None
else _optional_policy_int(overrides.get('max_reasoning_tokens'))
),
max_total_cost_usd=(
budget.max_total_cost_usd
if budget.max_total_cost_usd is not None
else _optional_policy_float(overrides.get('max_total_cost_usd'))
),
max_tool_calls=(
budget.max_tool_calls
if budget.max_tool_calls is not None
else _optional_policy_int(overrides.get('max_tool_calls'))
),
max_delegated_tasks=(
budget.max_delegated_tasks
if budget.max_delegated_tasks is not None
else _optional_policy_int(overrides.get('max_delegated_tasks'))
),
max_model_calls=(
budget.max_model_calls
if budget.max_model_calls is not None
else _optional_policy_int(overrides.get('max_model_calls'))
),
max_session_turns=(
budget.max_session_turns
if budget.max_session_turns is not None
else _optional_policy_int(overrides.get('max_session_turns'))
),
),
)
def run(self, prompt: str) -> AgentRunResult:
self.managed_agent_id = None
self.resume_source_session_id = None
@@ -220,7 +314,10 @@ class LocalCodingAgent:
),
)
effective_prompt = self._apply_plugin_before_prompt_hooks(slash_result.prompt or prompt)
effective_prompt = self._apply_hook_policy_before_prompt_hooks(
slash_result.prompt or prompt
)
effective_prompt = self._apply_plugin_before_prompt_hooks(effective_prompt)
effective_prompt = self._apply_plugin_resume_hooks(
effective_prompt,
resumed=base_session is not None,
@@ -431,7 +528,7 @@ class LocalCodingAgent:
str(scratchpad_directory) if scratchpad_directory is not None else None
),
)
result = self._append_plugin_after_turn_events(
result = self._append_runtime_after_turn_events(
result,
prompt=effective_prompt,
turn_index=turn_index,
@@ -512,7 +609,7 @@ class LocalCodingAgent:
str(scratchpad_directory) if scratchpad_directory is not None else None
),
)
result = self._append_plugin_after_turn_events(
result = self._append_runtime_after_turn_events(
result,
prompt=effective_prompt,
turn_index=turn_index,
@@ -583,6 +680,9 @@ class LocalCodingAgent:
if self.plugin_runtime is not None:
self.plugin_runtime.record_tool_attempt(tool_call.name, blocked=False)
plugin_preflight_messages = self._plugin_tool_preflight_messages(tool_call.name)
policy_preflight_messages = self._hook_policy_tool_preflight_messages(
tool_call.name
)
if plugin_preflight_messages:
stream_events.append(
{
@@ -593,7 +693,18 @@ class LocalCodingAgent:
'message_count': len(plugin_preflight_messages),
}
)
if policy_preflight_messages:
stream_events.append(
{
'type': 'hook_policy_tool_preflight',
'tool_name': tool_call.name,
'tool_call_id': tool_call.id,
'message_id': session.messages[tool_message_index].message_id,
'message_count': len(policy_preflight_messages),
}
)
plugin_block_message = self._plugin_block_message(tool_call.name)
policy_block_message = self._hook_policy_block_message(tool_call.name)
if plugin_block_message is not None:
if self.plugin_runtime is not None:
blocked_attempts = int(
@@ -621,6 +732,27 @@ class LocalCodingAgent:
'message': plugin_block_message,
}
)
if policy_block_message is not None:
tool_result = ToolExecutionResult(
name=tool_call.name,
ok=False,
content=policy_block_message,
metadata={
'action': 'hook_policy_block',
'hook_policy_blocked': True,
'hook_policy_block_message': policy_block_message,
'error_kind': 'permission_denied',
},
)
stream_events.append(
{
'type': 'hook_policy_tool_block',
'tool_name': tool_call.name,
'tool_call_id': tool_call.id,
'message_id': session.messages[tool_message_index].message_id,
'message': policy_block_message,
}
)
if tool_call.name == 'delegate_agent':
if tool_result is None:
tool_result = self._execute_delegate_agent(tool_call.arguments)
@@ -658,6 +790,7 @@ class LocalCodingAgent:
metadata=tool_result.metadata,
)
plugin_messages = self._plugin_tool_result_messages(tool_call.name)
policy_messages = self._hook_policy_tool_result_messages(tool_call.name)
if plugin_messages:
merged_metadata = dict(tool_result.metadata)
merged_metadata['plugin_messages'] = list(plugin_messages)
@@ -677,12 +810,47 @@ class LocalCodingAgent:
'message': message,
}
)
if policy_messages:
merged_metadata = dict(tool_result.metadata)
merged_metadata['hook_policy_messages'] = list(policy_messages)
tool_result = ToolExecutionResult(
name=tool_result.name,
ok=tool_result.ok,
content=tool_result.content,
metadata=merged_metadata,
)
for message in policy_messages:
stream_events.append(
{
'type': 'hook_policy_tool_hook',
'tool_name': tool_call.name,
'tool_call_id': tool_call.id,
'message_id': session.messages[tool_message_index].message_id,
'message': message,
}
)
if tool_result.metadata.get('error_kind') == 'permission_denied':
stream_events.append(
{
'type': 'tool_permission_denial',
'tool_name': tool_call.name,
'tool_call_id': tool_call.id,
'message_id': session.messages[tool_message_index].message_id,
'reason': tool_result.content,
'source': (
'hook_policy'
if tool_result.metadata.get('action') == 'hook_policy_block'
else 'tool_runtime'
),
}
)
session.finalize_tool(
tool_message_index,
content=serialize_tool_result(tool_result),
metadata={
'phase': 'completed',
'plugin_preflight_messages': list(plugin_preflight_messages),
'hook_policy_preflight_messages': list(policy_preflight_messages),
**dict(tool_result.metadata),
},
stop_reason='tool_completed',
@@ -707,6 +875,9 @@ class LocalCodingAgent:
preflight_messages=plugin_preflight_messages,
block_message=plugin_block_message,
plugin_messages=plugin_messages,
hook_policy_preflight_messages=policy_preflight_messages,
hook_policy_block_message=policy_block_message,
hook_policy_messages=policy_messages,
delegate_preflight_messages=tuple(
message
for message in tool_result.metadata.get(
@@ -774,7 +945,7 @@ class LocalCodingAgent:
str(scratchpad_directory) if scratchpad_directory is not None else None
),
)
result = self._append_plugin_after_turn_events(
result = self._append_runtime_after_turn_events(
result,
prompt=effective_prompt,
turn_index=self.runtime_config.max_turns,
@@ -2381,6 +2552,39 @@ class LocalCodingAgent:
)
return '\n'.join(lines)
def _apply_hook_policy_before_prompt_hooks(self, prompt: str) -> str:
if self.hook_policy_runtime is None or not self.hook_policy_runtime.manifests:
return prompt
injections = self.hook_policy_runtime.before_prompt_messages()
managed_settings = self.hook_policy_runtime.managed_settings()
safe_env = self.hook_policy_runtime.safe_env()
trusted = self.hook_policy_runtime.is_trusted()
if not injections and not managed_settings and not safe_env and trusted:
return prompt
lines = ['<system-reminder>', 'Workspace hook/policy guidance:']
lines.append(
f'- Trust mode: {"trusted" if trusted else "untrusted"}'
)
if not trusted:
lines.append(
'- Untrusted workspaces should favor inspection-first behavior. '
'Avoid unnecessary writes or shell actions unless the task clearly requires them.'
)
for entry in injections:
lines.append(f'- Before prompt: {entry}')
if managed_settings:
lines.append(
'- Managed settings: '
+ ', '.join(f'{key}={value}' for key, value in sorted(managed_settings.items()))
)
if safe_env:
lines.append(
'- Safe environment values loaded for tools: '
+ ', '.join(sorted(safe_env))
)
lines.extend(['</system-reminder>', '', prompt])
return '\n'.join(lines)
def _build_plugin_tool_runtime_message(
self,
*,
@@ -2388,6 +2592,9 @@ class LocalCodingAgent:
preflight_messages: tuple[str, ...],
block_message: str | None,
plugin_messages: tuple[str, ...],
hook_policy_preflight_messages: tuple[str, ...] = (),
hook_policy_block_message: str | None = None,
hook_policy_messages: tuple[str, ...] = (),
delegate_preflight_messages: tuple[str, ...] = (),
delegate_after_messages: tuple[str, ...] = (),
) -> str | None:
@@ -2395,28 +2602,46 @@ class LocalCodingAgent:
block_message is None
and not plugin_messages
and not preflight_messages
and hook_policy_block_message is None
and not hook_policy_preflight_messages
and not hook_policy_messages
and not delegate_preflight_messages
and not delegate_after_messages
):
return None
plugin_only = (
hook_policy_block_message is None
and not hook_policy_preflight_messages
and not hook_policy_messages
)
lines = [
'<system-reminder>',
f'Plugin tool runtime guidance for `{tool_name}`:',
(
f'Plugin tool runtime guidance for `{tool_name}`:'
if plugin_only
else f'Runtime tool guidance for `{tool_name}`:'
),
]
for message in preflight_messages:
lines.append(f'- Before tool: {message}')
for message in hook_policy_preflight_messages:
lines.append(f'- Hook/policy before tool: {message}')
for message in delegate_preflight_messages:
lines.append(f'- Before delegate: {message}')
if block_message is not None:
lines.append(f'- Blocked: {block_message}')
if hook_policy_block_message is not None:
lines.append(f'- Hook/policy blocked: {hook_policy_block_message}')
for message in plugin_messages:
lines.append(f'- After result: {message}')
for message in hook_policy_messages:
lines.append(f'- Hook/policy after result: {message}')
for message in delegate_after_messages:
lines.append(f'- After delegate: {message}')
lines.extend(
[
'',
'Use this plugin guidance when deciding the next tool call or assistant response.',
'Use this runtime guidance when deciding the next tool call or assistant response.',
'</system-reminder>',
]
)
@@ -2437,6 +2662,21 @@ class LocalCodingAgent:
return ()
return self.plugin_runtime.tool_result_injections(tool_name)
def _hook_policy_tool_preflight_messages(self, tool_name: str) -> tuple[str, ...]:
if self.hook_policy_runtime is None:
return ()
return self.hook_policy_runtime.before_tool_messages(tool_name)
def _hook_policy_block_message(self, tool_name: str) -> str | None:
if self.hook_policy_runtime is None:
return None
return self.hook_policy_runtime.denied_tool_message(tool_name)
def _hook_policy_tool_result_messages(self, tool_name: str) -> tuple[str, ...]:
if self.hook_policy_runtime is None:
return ()
return self.hook_policy_runtime.after_tool_messages(tool_name)
def _persist_session(
self,
session: AgentSessionState,
@@ -2546,15 +2786,27 @@ class LocalCodingAgent:
def render_permissions_report(self) -> str:
permissions = self.runtime_config.permissions
return '\n'.join(
[
'# Permissions',
'',
f'- File write tools: {"enabled" if permissions.allow_file_write else "disabled"}',
f'- Shell commands: {"enabled" if permissions.allow_shell_commands else "disabled"}',
f'- Destructive shell commands: {"enabled" if permissions.allow_destructive_shell_commands else "disabled"}',
]
)
lines = [
'# Permissions',
'',
f'- File write tools: {"enabled" if permissions.allow_file_write else "disabled"}',
f'- Shell commands: {"enabled" if permissions.allow_shell_commands else "disabled"}',
f'- Destructive shell commands: {"enabled" if permissions.allow_destructive_shell_commands else "disabled"}',
]
if self.hook_policy_runtime is not None and self.hook_policy_runtime.manifests:
lines.append(
f'- Workspace trust mode: {"trusted" if self.hook_policy_runtime.is_trusted() else "untrusted"}'
)
denied_tools = sorted(
{
name
for manifest in self.hook_policy_runtime.manifests
for name in manifest.deny_tools
}
)
if denied_tools:
lines.append('- Policy-denied tools: ' + ', '.join(denied_tools))
return '\n'.join(lines)
def render_tools_report(self) -> str:
permissions = self.runtime_config.permissions
@@ -2565,6 +2817,11 @@ class LocalCodingAgent:
state = 'blocked by permissions'
if tool.name in {'write_file', 'edit_file'} and not permissions.allow_file_write:
state = 'blocked by permissions'
if (
self.hook_policy_runtime is not None
and self.hook_policy_runtime.denied_tool_message(tool.name) is not None
):
state = 'blocked by hook policy'
lines.append(f'- `{tool.name}`: {tool.description} [{state}]')
return '\n'.join(lines)
@@ -2575,6 +2832,64 @@ class LocalCodingAgent:
return '# Memory\n\nNo CLAUDE.md memory files are currently loaded.'
return '\n'.join(['# Memory', '', claude_md])
def render_mcp_report(self, query: str | None = None) -> str:
if self.mcp_runtime is None:
return '# MCP\n\nNo local MCP manifests or resources discovered.'
if query:
return self.mcp_runtime.render_resource_index(query=query)
return '\n'.join(['# MCP', '', self.mcp_runtime.render_summary()])
def render_mcp_resources_report(self, query: str | None = None) -> str:
if self.mcp_runtime is None:
return '# MCP Resources\n\nNo local MCP manifests or resources discovered.'
return self.mcp_runtime.render_resource_index(query=query)
def render_mcp_resource_report(self, uri: str) -> str:
if self.mcp_runtime is None:
return '# MCP Resource\n\nNo local MCP manifests or resources discovered.'
return self.mcp_runtime.render_resource(uri)
def render_tasks_report(self, status: str | None = None) -> str:
if self.task_runtime is None:
return '# Tasks\n\nNo local task runtime is available.'
return self.task_runtime.render_tasks(status=status)
def render_plan_report(self) -> str:
if self.plan_runtime is None:
return '# Plan\n\nNo local plan runtime is available.'
return self.plan_runtime.render_plan()
def render_task_report(self, task_id: str) -> str:
if self.task_runtime is None:
return '# Task\n\nNo local task runtime is available.'
return self.task_runtime.render_task(task_id)
def render_hook_policy_report(self) -> str:
if self.hook_policy_runtime is None:
return '# Hook Policy\n\nNo local hook or policy manifests discovered.'
return '\n'.join(['# Hook Policy', '', self.hook_policy_runtime.render_summary()])
def render_trust_report(self) -> str:
trusted = True
settings: dict[str, Any] = {}
env_values: dict[str, str] = {}
if self.hook_policy_runtime is not None:
trusted = self.hook_policy_runtime.is_trusted()
settings = self.hook_policy_runtime.managed_settings()
env_values = self.hook_policy_runtime.safe_env()
lines = [
'# Trust',
'',
f'- Workspace trust mode: {"trusted" if trusted else "untrusted"}',
]
if settings:
lines.append('- Managed settings:')
lines.extend(f' - {key}={value}' for key, value in sorted(settings.items()))
if env_values:
lines.append('- Safe environment values:')
lines.extend(f' - {key}={value}' for key, value in sorted(env_values.items()))
return '\n'.join(lines)
def render_status_report(self) -> str:
lines = [
'# Status',
@@ -2585,6 +2900,16 @@ class LocalCodingAgent:
f'- Session ID: {self.active_session_id or "none"}',
f'- Last session loaded: {"yes" if self.last_session is not None else "no"}',
]
if self.hook_policy_runtime is not None and self.hook_policy_runtime.manifests:
lines.append(
f'- Workspace trust mode: {"trusted" if self.hook_policy_runtime.is_trusted() else "untrusted"}'
)
if self.mcp_runtime is not None and self.mcp_runtime.resources:
lines.append(f'- MCP resources: {len(self.mcp_runtime.resources)}')
if self.plan_runtime is not None and self.plan_runtime.steps:
lines.append(f'- Local plan steps: {len(self.plan_runtime.steps)}')
if self.task_runtime is not None and self.task_runtime.tasks:
lines.append(f'- Local tasks: {len(self.task_runtime.tasks)}')
if self.last_session_path is not None:
lines.append(f'- Session path: {self.last_session_path}')
if self.last_run_result is not None:
@@ -2689,3 +3014,48 @@ class LocalCodingAgent:
}
)
return replace(result, events=tuple(appended))
def _append_runtime_after_turn_events(
self,
result: AgentRunResult,
*,
prompt: str,
turn_index: int,
) -> AgentRunResult:
updated = self._append_plugin_after_turn_events(
result,
prompt=prompt,
turn_index=turn_index,
)
if self.hook_policy_runtime is None:
return updated
injections = self.hook_policy_runtime.after_turn_messages()
if not injections:
return updated
appended = list(updated.events)
for entry in injections:
appended.append(
{
'type': 'hook_policy_after_turn',
'turn_index': turn_index,
'message': entry,
'prompt_preview': self._preview_text(prompt, 120),
'stop_reason': updated.stop_reason,
'trusted': self.hook_policy_runtime.is_trusted(),
}
)
return replace(updated, events=tuple(appended))
def _optional_policy_int(value: object) -> int | None:
if isinstance(value, bool) or not isinstance(value, int):
return None
return value
def _optional_policy_float(value: object) -> float | None:
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
return None
+87 -8
View File
@@ -77,16 +77,16 @@ def preprocess_slash_command(
'Commands are in the form `/command [args]`.',
)
if parsed.is_mcp:
return _local_result(
input_text,
'MCP slash commands are not implemented in the Python runtime yet.',
)
spec = find_slash_command(parsed.command_name)
normalized_name = (
parsed.command_name[:-6]
if parsed.is_mcp and parsed.command_name.endswith(' (MCP)')
else parsed.command_name
)
spec = find_slash_command(normalized_name)
if spec is None:
if looks_like_command(parsed.command_name):
return _local_result(input_text, f'Unknown skill: {parsed.command_name}')
label = normalized_name if parsed.is_mcp else parsed.command_name
return _local_result(input_text, f'Unknown skill: {label}')
return SlashCommandResult(handled=False, should_query=True, prompt=input_text)
return spec.handler(agent, parsed.args.strip(), input_text)
@@ -109,6 +109,36 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]:
description='Show the raw environment, user context, and system context snapshot.',
handler=_handle_context_raw,
),
SlashCommandSpec(
names=('mcp',),
description='Show discovered local MCP manifests and resource counts.',
handler=_handle_mcp,
),
SlashCommandSpec(
names=('resources',),
description='List local MCP resources, optionally filtered by a query string.',
handler=_handle_resources,
),
SlashCommandSpec(
names=('resource',),
description='Render a local MCP resource by URI.',
handler=_handle_resource,
),
SlashCommandSpec(
names=('tasks', 'todo'),
description='Show the local runtime task list, optionally filtered by status.',
handler=_handle_tasks,
),
SlashCommandSpec(
names=('plan', 'planner'),
description='Show the current local runtime plan.',
handler=_handle_plan,
),
SlashCommandSpec(
names=('task',),
description='Show a local runtime task by id.',
handler=_handle_task,
),
SlashCommandSpec(
names=('prompt', 'system-prompt'),
description='Render the effective Python system prompt.',
@@ -119,6 +149,16 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]:
description='Show the active tool permission mode.',
handler=_handle_permissions,
),
SlashCommandSpec(
names=('hooks', 'policy'),
description='Show discovered local hook and policy manifests.',
handler=_handle_hooks,
),
SlashCommandSpec(
names=('trust',),
description='Show workspace trust mode, managed settings, and safe environment values.',
handler=_handle_trust,
),
SlashCommandSpec(
names=('model',),
description='Show or update the active model for the current agent instance.',
@@ -180,6 +220,37 @@ def _handle_context_raw(agent: 'LocalCodingAgent', _args: str, input_text: str)
return _local_result(input_text, agent.render_context_snapshot_report())
def _handle_mcp(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
query = args or None
return _local_result(input_text, agent.render_mcp_report(query))
def _handle_resources(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
query = args or None
return _local_result(input_text, agent.render_mcp_resources_report(query))
def _handle_resource(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
if not args:
return _local_result(input_text, 'Usage: /resource <mcp-resource-uri>')
return _local_result(input_text, agent.render_mcp_resource_report(args))
def _handle_tasks(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
status = args or None
return _local_result(input_text, agent.render_tasks_report(status))
def _handle_plan(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
return _local_result(input_text, agent.render_plan_report())
def _handle_task(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
if not args:
return _local_result(input_text, 'Usage: /task <task-id>')
return _local_result(input_text, agent.render_task_report(args))
def _handle_prompt(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
return _local_result(input_text, agent.render_system_prompt())
@@ -188,6 +259,14 @@ def _handle_permissions(agent: 'LocalCodingAgent', _args: str, input_text: str)
return _local_result(input_text, agent.render_permissions_report())
def _handle_hooks(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
return _local_result(input_text, agent.render_hook_policy_report())
def _handle_trust(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
return _local_result(input_text, agent.render_trust_report())
def _handle_model(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
if not args:
return _local_result(input_text, f'Current model: {agent.model_config.model}')
+485 -4
View File
@@ -2,16 +2,22 @@ from __future__ import annotations
import hashlib
import json
import os
import re
import selectors
import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Iterator, Union
from typing import TYPE_CHECKING, Any, Callable, Iterator, Union
from .agent_types import AgentPermissions, AgentRuntimeConfig, ToolExecutionResult
if TYPE_CHECKING:
from .mcp_runtime import MCPRuntime
from .plan_runtime import PlanRuntime
from .task_runtime import TaskRuntime
class ToolPermissionError(RuntimeError):
"""Raised when the runtime configuration does not allow a tool action."""
@@ -27,6 +33,10 @@ class ToolExecutionContext:
command_timeout_seconds: float
max_output_chars: int
permissions: AgentPermissions
extra_env: dict[str, str] = field(default_factory=dict)
mcp_runtime: 'MCPRuntime | None' = None
plan_runtime: 'PlanRuntime | None' = None
task_runtime: 'TaskRuntime | None' = None
ToolHandler = Callable[
@@ -60,8 +70,20 @@ class AgentTool:
else:
content, metadata = result, {}
return ToolExecutionResult(name=self.name, ok=True, content=content, metadata=metadata)
except (ToolPermissionError, ToolExecutionError, OSError, subprocess.SubprocessError) as exc:
return ToolExecutionResult(name=self.name, ok=False, content=str(exc))
except ToolPermissionError as exc:
return ToolExecutionResult(
name=self.name,
ok=False,
content=str(exc),
metadata={'error_kind': 'permission_denied'},
)
except (ToolExecutionError, OSError, subprocess.SubprocessError) as exc:
return ToolExecutionResult(
name=self.name,
ok=False,
content=str(exc),
metadata={'error_kind': 'tool_execution_error'},
)
@dataclass(frozen=True)
@@ -73,12 +95,23 @@ class ToolStreamUpdate:
metadata: dict[str, Any] = field(default_factory=dict)
def build_tool_context(config: AgentRuntimeConfig) -> ToolExecutionContext:
def build_tool_context(
config: AgentRuntimeConfig,
*,
extra_env: dict[str, str] | None = None,
mcp_runtime: 'MCPRuntime | None' = None,
plan_runtime: 'PlanRuntime | None' = None,
task_runtime: 'TaskRuntime | None' = None,
) -> ToolExecutionContext:
return ToolExecutionContext(
root=config.cwd.resolve(),
command_timeout_seconds=config.command_timeout_seconds,
max_output_chars=config.max_output_chars,
permissions=config.permissions,
extra_env=dict(extra_env or {}),
mcp_runtime=mcp_runtime,
plan_runtime=plan_runtime,
task_runtime=task_runtime,
)
@@ -222,6 +255,159 @@ def default_tool_registry() -> dict[str, AgentTool]:
},
handler=_run_bash,
),
AgentTool(
name='mcp_list_resources',
description='List local MCP resources discovered from workspace MCP manifests.',
parameters={
'type': 'object',
'properties': {
'query': {'type': 'string'},
'max_resources': {'type': 'integer', 'minimum': 1, 'maximum': 200},
},
},
handler=_mcp_list_resources,
),
AgentTool(
name='mcp_read_resource',
description='Read a local MCP resource by URI from workspace MCP manifests.',
parameters={
'type': 'object',
'properties': {
'uri': {'type': 'string'},
'max_chars': {'type': 'integer', 'minimum': 1, 'maximum': 50000},
},
'required': ['uri'],
},
handler=_mcp_read_resource,
),
AgentTool(
name='plan_get',
description='Show the current local runtime plan.',
parameters={
'type': 'object',
'properties': {},
},
handler=_plan_get,
),
AgentTool(
name='update_plan',
description='Replace the current local runtime plan with a structured multi-step plan and optionally sync it to tasks.',
parameters={
'type': 'object',
'properties': {
'explanation': {'type': 'string'},
'sync_tasks': {'type': 'boolean'},
'items': {
'type': 'array',
'items': {
'type': 'object',
'properties': {
'step': {'type': 'string'},
'status': {'type': 'string'},
'task_id': {'type': 'string'},
'description': {'type': 'string'},
'priority': {'type': 'string'},
},
'required': ['step'],
},
},
},
'required': ['items'],
},
handler=_update_plan,
),
AgentTool(
name='plan_clear',
description='Clear the current local runtime plan and optionally sync the task runtime.',
parameters={
'type': 'object',
'properties': {
'sync_tasks': {'type': 'boolean'},
},
},
handler=_plan_clear,
),
AgentTool(
name='task_list',
description='List locally stored runtime tasks.',
parameters={
'type': 'object',
'properties': {
'status': {'type': 'string'},
'max_tasks': {'type': 'integer', 'minimum': 1, 'maximum': 200},
},
},
handler=_task_list,
),
AgentTool(
name='task_get',
description='Show a locally stored runtime task by id.',
parameters={
'type': 'object',
'properties': {
'task_id': {'type': 'string'},
},
'required': ['task_id'],
},
handler=_task_get,
),
AgentTool(
name='task_create',
description='Create a locally stored runtime task.',
parameters={
'type': 'object',
'properties': {
'title': {'type': 'string'},
'description': {'type': 'string'},
'status': {'type': 'string'},
'priority': {'type': 'string'},
'task_id': {'type': 'string'},
},
'required': ['title'],
},
handler=_task_create,
),
AgentTool(
name='task_update',
description='Update a locally stored runtime task by id.',
parameters={
'type': 'object',
'properties': {
'task_id': {'type': 'string'},
'title': {'type': 'string'},
'description': {'type': 'string'},
'status': {'type': 'string'},
'priority': {'type': 'string'},
},
'required': ['task_id'],
},
handler=_task_update,
),
AgentTool(
name='todo_write',
description='Replace the current local runtime task list with a structured todo list.',
parameters={
'type': 'object',
'properties': {
'items': {
'type': 'array',
'items': {
'type': 'object',
'properties': {
'task_id': {'type': 'string'},
'title': {'type': 'string'},
'description': {'type': 'string'},
'status': {'type': 'string'},
'priority': {'type': 'string'},
},
'required': ['title'],
},
},
},
'required': ['items'],
},
handler=_todo_write,
),
AgentTool(
name='delegate_agent',
description='Delegate a subtask to a nested Python coding agent and return its summary.',
@@ -531,6 +717,7 @@ def _run_bash(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
capture_output=True,
text=True,
timeout=context.command_timeout_seconds,
env=_build_subprocess_env(context),
)
stdout = completed.stdout or ''
stderr = completed.stderr or ''
@@ -554,6 +741,204 @@ def _run_bash(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
)
def _mcp_list_resources(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
runtime = _require_mcp_runtime(context)
query = arguments.get('query')
if query is not None and not isinstance(query, str):
raise ToolExecutionError('query must be a string')
max_resources = _coerce_int(arguments, 'max_resources', 50)
resources = runtime.list_resources(query=query, limit=max_resources)
if not resources:
return '(no MCP resources)'
lines: list[str] = []
for resource in resources:
details = [resource.uri, f'server={resource.server_name}']
if resource.name:
details.append(f'name={resource.name}')
if resource.mime_type:
details.append(f'mime={resource.mime_type}')
if resource.resolved_path:
details.append(f'path={resource.resolved_path}')
lines.append(' ; '.join(details))
return '\n'.join(lines)
def _mcp_read_resource(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
runtime = _require_mcp_runtime(context)
uri = _require_string(arguments, 'uri')
max_chars = _coerce_int(arguments, 'max_chars', context.max_output_chars)
try:
content = runtime.read_resource(uri, max_chars=max_chars)
except FileNotFoundError as exc:
raise ToolExecutionError(str(exc)) from exc
return content
def _task_list(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
runtime = _require_task_runtime(context)
status = arguments.get('status')
if status is not None and not isinstance(status, str):
raise ToolExecutionError('status must be a string')
max_tasks = _coerce_int(arguments, 'max_tasks', 50)
return runtime.render_tasks(status=status, limit=max_tasks)
def _task_get(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
runtime = _require_task_runtime(context)
return runtime.render_task(_require_string(arguments, 'task_id'))
def _plan_get(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
del arguments
runtime = _require_plan_runtime(context)
return runtime.render_plan()
def _update_plan(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
_ensure_write_allowed(context)
runtime = _require_plan_runtime(context)
items = arguments.get('items')
if not isinstance(items, list):
raise ToolExecutionError('items must be an array of plan step objects')
explanation = arguments.get('explanation')
if explanation is not None and not isinstance(explanation, str):
raise ToolExecutionError('explanation must be a string')
sync_tasks = arguments.get('sync_tasks', True)
if not isinstance(sync_tasks, bool):
raise ToolExecutionError('sync_tasks must be a boolean')
mutation = runtime.update_plan(
[item for item in items if isinstance(item, dict)],
explanation=explanation,
task_runtime=context.task_runtime,
sync_tasks=sync_tasks,
)
return (
f'updated plan with {mutation.after_count} step(s)',
_plan_mutation_metadata(
action='update_plan',
mutation=mutation,
total_steps=mutation.after_count,
sync_tasks=sync_tasks,
),
)
def _plan_clear(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
_ensure_write_allowed(context)
runtime = _require_plan_runtime(context)
sync_tasks = arguments.get('sync_tasks', True)
if not isinstance(sync_tasks, bool):
raise ToolExecutionError('sync_tasks must be a boolean')
mutation = runtime.clear_plan(
task_runtime=context.task_runtime if sync_tasks else None,
)
return (
'cleared local plan',
_plan_mutation_metadata(
action='plan_clear',
mutation=mutation,
total_steps=0,
sync_tasks=sync_tasks,
),
)
def _task_create(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
_ensure_write_allowed(context)
runtime = _require_task_runtime(context)
title = _require_string(arguments, 'title')
description = arguments.get('description')
status = arguments.get('status', 'todo')
priority = arguments.get('priority')
task_id = arguments.get('task_id')
if description is not None and not isinstance(description, str):
raise ToolExecutionError('description must be a string')
if status is not None and not isinstance(status, str):
raise ToolExecutionError('status must be a string')
if priority is not None and not isinstance(priority, str):
raise ToolExecutionError('priority must be a string')
if task_id is not None and not isinstance(task_id, str):
raise ToolExecutionError('task_id must be a string')
mutation = runtime.create_task(
title=title,
description=description,
status=status or 'todo',
priority=priority,
task_id=task_id,
)
task = mutation.task
assert task is not None
return (
f'created task {task.task_id}: {task.title} [{task.status}]',
_task_mutation_metadata(
action='task_create',
mutation=mutation,
task_id=task.task_id,
task_status=task.status,
total_tasks=mutation.after_count,
),
)
def _task_update(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
_ensure_write_allowed(context)
runtime = _require_task_runtime(context)
task_id = _require_string(arguments, 'task_id')
title = arguments.get('title')
description = arguments.get('description')
status = arguments.get('status')
priority = arguments.get('priority')
for key, value in (
('title', title),
('description', description),
('status', status),
('priority', priority),
):
if value is not None and not isinstance(value, str):
raise ToolExecutionError(f'{key} must be a string')
try:
mutation = runtime.update_task(
task_id,
title=title,
description=description,
status=status,
priority=priority,
)
except KeyError as exc:
raise ToolExecutionError(f'Unknown task id: {task_id}') from exc
task = mutation.task
assert task is not None
return (
f'updated task {task.task_id}: {task.title} [{task.status}]',
_task_mutation_metadata(
action='task_update',
mutation=mutation,
task_id=task.task_id,
task_status=task.status,
total_tasks=mutation.after_count,
),
)
def _todo_write(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
_ensure_write_allowed(context)
runtime = _require_task_runtime(context)
items = arguments.get('items')
if not isinstance(items, list):
raise ToolExecutionError('items must be an array of task objects')
mutation = runtime.replace_tasks(
[item for item in items if isinstance(item, dict)]
)
return (
f'replaced todo list with {mutation.after_count} task(s)',
_task_mutation_metadata(
action='todo_write',
mutation=mutation,
total_tasks=mutation.after_count,
),
)
def _stream_bash(
arguments: dict[str, Any],
context: ToolExecutionContext,
@@ -570,6 +955,7 @@ def _stream_bash(
stderr=subprocess.PIPE,
text=True,
bufsize=1,
env=_build_subprocess_env(context),
)
except (ToolPermissionError, ToolExecutionError, OSError, subprocess.SubprocessError) as exc:
yield ToolStreamUpdate(
@@ -687,6 +1073,95 @@ def _delegate_agent_placeholder(
)
def _require_mcp_runtime(context: ToolExecutionContext):
if context.mcp_runtime is None or not context.mcp_runtime.resources:
raise ToolExecutionError(
'No local MCP resources are available. Add a .claw-mcp.json or .mcp.json manifest first.'
)
return context.mcp_runtime
def _require_plan_runtime(context: ToolExecutionContext):
if context.plan_runtime is None:
raise ToolExecutionError('Local plan runtime is not available.')
return context.plan_runtime
def _require_task_runtime(context: ToolExecutionContext):
if context.task_runtime is None:
raise ToolExecutionError('Local task runtime is not available.')
return context.task_runtime
def _task_mutation_metadata(
*,
action: str,
mutation,
task_id: str | None = None,
task_status: str | None = None,
total_tasks: int,
) -> dict[str, Any]:
try:
relative_path = str(Path(mutation.store_path).relative_to(Path.cwd()))
except ValueError:
relative_path = str(mutation.store_path)
payload: dict[str, Any] = {
'action': action,
'path': relative_path,
'before_sha256': mutation.before_sha256,
'after_sha256': mutation.after_sha256,
'before_preview': mutation.before_preview,
'after_preview': mutation.after_preview,
'before_task_count': mutation.before_count,
'after_task_count': mutation.after_count,
'total_tasks': total_tasks,
}
if task_id is not None:
payload['task_id'] = task_id
if task_status is not None:
payload['task_status'] = task_status
return payload
def _plan_mutation_metadata(
*,
action: str,
mutation,
total_steps: int,
sync_tasks: bool,
) -> dict[str, Any]:
try:
relative_path = str(Path(mutation.store_path).relative_to(Path.cwd()))
except ValueError:
relative_path = str(mutation.store_path)
payload: dict[str, Any] = {
'action': action,
'path': relative_path,
'before_sha256': mutation.before_sha256,
'after_sha256': mutation.after_sha256,
'before_preview': mutation.before_preview,
'after_preview': mutation.after_preview,
'before_plan_count': mutation.before_count,
'after_plan_count': mutation.after_count,
'total_steps': total_steps,
'sync_tasks': sync_tasks,
}
if mutation.explanation is not None:
payload['explanation'] = mutation.explanation
if mutation.synced_task_store_path is not None:
try:
payload['synced_task_store_path'] = str(
Path(mutation.synced_task_store_path).relative_to(Path.cwd())
)
except ValueError:
payload['synced_task_store_path'] = mutation.synced_task_store_path
if mutation.synced_task_sha256 is not None:
payload['synced_task_sha256'] = mutation.synced_task_sha256
if mutation.synced_tasks:
payload['synced_tasks'] = mutation.synced_tasks
return payload
def _drain_registered_streams(
selector: selectors.BaseSelector,
stdout_chunks: list[str],
@@ -721,6 +1196,12 @@ def _drain_registered_streams(
pass
def _build_subprocess_env(context: ToolExecutionContext) -> dict[str, str]:
env = os.environ.copy()
env.update(context.extra_env)
return env
def _stream_static_text_result(
result: ToolExecutionResult,
*,
+371
View File
@@ -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] + '...'
+339
View File
@@ -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
View File
@@ -2,11 +2,13 @@ from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
from dataclasses import replace
import json
from typing import Callable
from .background_runtime import BackgroundSessionRuntime, build_background_worker_command
from .agent_runtime import LocalCodingAgent
from .agent_types import (
AgentPermissions,
@@ -154,6 +156,64 @@ def _build_agent(args: argparse.Namespace) -> LocalCodingAgent:
)
def _append_agent_forwarded_args(
command: list[str],
args: argparse.Namespace,
*,
include_backend: bool,
) -> None:
command.extend(['--cwd', str(args.cwd)])
command.extend(['--max-turns', str(getattr(args, 'max_turns', 12))])
if include_backend:
command.extend(['--model', str(args.model)])
command.extend(['--base-url', str(args.base_url)])
command.extend(['--api-key', str(args.api_key)])
command.extend(['--temperature', str(args.temperature)])
command.extend(['--timeout-seconds', str(args.timeout_seconds)])
command.extend(['--input-cost-per-million', str(args.input_cost_per_million)])
command.extend(['--output-cost-per-million', str(args.output_cost_per_million)])
else:
command.extend(['--model', str(args.model)])
for path in getattr(args, 'add_dir', []):
command.extend(['--add-dir', str(path)])
for flag in (
('--disable-claude-md', getattr(args, 'disable_claude_md', False)),
('--allow-write', getattr(args, 'allow_write', False)),
('--allow-shell', getattr(args, 'allow_shell', False)),
('--unsafe', getattr(args, 'unsafe', False)),
('--stream', getattr(args, 'stream', False)),
('--show-transcript', getattr(args, 'show_transcript', False)),
(
'--response-schema-strict',
getattr(args, 'response_schema_strict', False),
),
):
if flag[1]:
command.append(flag[0])
for name, value in (
('--auto-snip-threshold', getattr(args, 'auto_snip_threshold', None)),
('--auto-compact-threshold', getattr(args, 'auto_compact_threshold', None)),
('--compact-preserve-messages', getattr(args, 'compact_preserve_messages', None)),
('--max-total-tokens', getattr(args, 'max_total_tokens', None)),
('--max-input-tokens', getattr(args, 'max_input_tokens', None)),
('--max-output-tokens', getattr(args, 'max_output_tokens', None)),
('--max-reasoning-tokens', getattr(args, 'max_reasoning_tokens', None)),
('--max-budget-usd', getattr(args, 'max_budget_usd', None)),
('--max-tool-calls', getattr(args, 'max_tool_calls', None)),
('--max-delegated-tasks', getattr(args, 'max_delegated_tasks', None)),
('--max-model-calls', getattr(args, 'max_model_calls', None)),
('--max-session-turns', getattr(args, 'max_session_turns', None)),
('--response-schema-file', getattr(args, 'response_schema_file', None)),
('--response-schema-name', getattr(args, 'response_schema_name', None)),
('--scratchpad-root', getattr(args, 'scratchpad_root', None)),
('--system-prompt', getattr(args, 'system_prompt', None)),
('--append-system-prompt', getattr(args, 'append_system_prompt', None)),
('--override-system-prompt', getattr(args, 'override_system_prompt', None)),
):
if value is not None:
command.extend([name, str(value)])
def _add_agent_resume_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument('session_id')
parser.add_argument('prompt')
@@ -487,6 +547,34 @@ def build_parser() -> argparse.ArgumentParser:
agent_parser.add_argument('--show-transcript', action='store_true')
_add_agent_common_args(agent_parser, include_backend=True)
background_parser = subparsers.add_parser('agent-bg', help='run the Python local-model agent as a local background session')
background_parser.add_argument('prompt')
background_parser.add_argument('--max-turns', type=int, default=12)
background_parser.add_argument('--show-transcript', action='store_true')
_add_agent_common_args(background_parser, include_backend=True)
background_worker_parser = subparsers.add_parser('agent-bg-worker', help=argparse.SUPPRESS)
background_worker_parser.add_argument('background_id')
background_worker_parser.add_argument('prompt')
background_worker_parser.add_argument('--background-root', required=True)
background_worker_parser.add_argument('--max-turns', type=int, default=12)
background_worker_parser.add_argument('--show-transcript', action='store_true')
_add_agent_common_args(background_worker_parser, include_backend=True)
ps_parser = subparsers.add_parser('agent-ps', help='list local background agent sessions')
ps_parser.add_argument('--tail', type=int, default=None)
logs_parser = subparsers.add_parser('agent-logs', help='show logs for a local background agent session')
logs_parser.add_argument('background_id')
logs_parser.add_argument('--tail', type=int, default=None)
attach_parser = subparsers.add_parser('agent-attach', help='show the current output snapshot for a local background agent session')
attach_parser.add_argument('background_id')
attach_parser.add_argument('--tail', type=int, default=None)
kill_parser = subparsers.add_parser('agent-kill', help='stop a local background agent session')
kill_parser.add_argument('background_id')
chat_parser = subparsers.add_parser('agent-chat', help='run an interactive Python local-model chat loop')
chat_parser.add_argument('prompt', nargs='?')
chat_parser.add_argument('--resume-session-id')
@@ -640,6 +728,82 @@ def main(argv: list[str] | None = None) -> int:
result = agent.run(args.prompt)
_print_agent_result(result, show_transcript=args.show_transcript)
return 0
if args.command == 'agent-bg':
background_runtime = BackgroundSessionRuntime()
background_id = background_runtime.create_id()
forwarded_args: list[str] = []
_append_agent_forwarded_args(forwarded_args, args, include_backend=True)
forwarded_args.extend(['--background-root', str(background_runtime.root)])
command = build_background_worker_command(
background_id=background_id,
prompt=args.prompt,
forwarded_args=forwarded_args,
)
record = background_runtime.launch(
command,
prompt=args.prompt,
workspace_cwd=Path(args.cwd).resolve(),
model=args.model,
background_id=background_id,
process_cwd=Path(__file__).resolve().parent.parent,
)
print('# Background Session')
print(f'background_id={record.background_id}')
print(f'pid={record.pid}')
print(f'log_path={record.log_path}')
print(f'record_path={record.record_path}')
return 0
if args.command == 'agent-bg-worker':
background_runtime = BackgroundSessionRuntime(Path(args.background_root))
exit_code = 1
stop_reason = 'worker_failed'
session_id = None
session_path = None
try:
agent = _build_agent(args)
result = agent.run(args.prompt)
_print_agent_result(result, show_transcript=args.show_transcript)
exit_code = 0
stop_reason = result.stop_reason or 'completed'
session_id = result.session_id
session_path = result.session_path
return 0
finally:
background_runtime.mark_finished(
args.background_id,
exit_code=exit_code,
stop_reason=stop_reason,
session_id=session_id,
session_path=session_path,
)
if args.command == 'agent-ps':
print(BackgroundSessionRuntime().render_ps())
return 0
if args.command == 'agent-logs':
print(
BackgroundSessionRuntime().render_logs(
args.background_id,
tail=args.tail,
)
)
return 0
if args.command == 'agent-attach':
print(
BackgroundSessionRuntime().render_attach(
args.background_id,
tail=args.tail,
)
)
return 0
if args.command == 'agent-kill':
record = BackgroundSessionRuntime().kill(args.background_id)
print('# Background Session')
print(f'background_id={record.background_id}')
print(f'status={record.status}')
print(f'pid={record.pid}')
if record.exit_code is not None:
print(f'exit_code={record.exit_code}')
return 0
if args.command == 'agent-chat':
agent = _build_agent(args)
return _run_agent_chat_loop(
+275
View File
@@ -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}'
+319
View File
@@ -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
View File
@@ -1,5 +1,78 @@
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'
+278
View File
@@ -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'
+73
View File
@@ -5,13 +5,16 @@ import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from src.agent_context import (
build_context_snapshot,
clear_context_caches,
set_system_prompt_injection,
)
from src.plan_runtime import PlanRuntime
from src.agent_types import AgentRuntimeConfig
from src.task_runtime import TaskRuntime
class AgentContextTests(unittest.TestCase):
@@ -57,6 +60,76 @@ class AgentContextTests(unittest.TestCase):
self.assertIn('demo-plugin', snapshot.user_context['pluginCache'])
self.assertIn('1.2.3', snapshot.user_context['pluginCache'])
def test_user_context_loads_hook_policy_manifest(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir) / 'repo'
workspace.mkdir(parents=True)
(workspace / '.claw-policy.json').write_text(
(
'{"trusted": false, '
'"managedSettings": {"reviewMode": "strict"}, '
'"safeEnv": ["HOOK_SAFE_TOKEN"], '
'"hooks": {"beforePrompt": ["Respect workspace policy."]}}'
),
encoding='utf-8',
)
with patch.dict('os.environ', {'HOOK_SAFE_TOKEN': 'demo-secret'}, clear=False):
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace))
self.assertIn('hookPolicy', snapshot.user_context)
self.assertIn('managedSettings', snapshot.user_context)
self.assertIn('safeEnv', snapshot.user_context)
self.assertIn('trustMode', snapshot.user_context)
self.assertIn('reviewMode=strict', snapshot.user_context['managedSettings'])
self.assertIn('HOOK_SAFE_TOKEN=demo-secret', snapshot.user_context['safeEnv'])
self.assertIn('untrusted', snapshot.user_context['trustMode'])
def test_user_context_loads_mcp_runtime_summary(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir) / 'repo'
workspace.mkdir(parents=True)
(workspace / 'notes.txt').write_text('mcp notes\n', encoding='utf-8')
(workspace / '.claw-mcp.json').write_text(
(
'{"servers":[{"name":"workspace","resources":['
'{"uri":"mcp://workspace/notes","name":"Notes","path":"notes.txt"}'
']}]}'
),
encoding='utf-8',
)
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace))
self.assertIn('mcpRuntime', snapshot.user_context)
self.assertIn('Local MCP resources: 1', snapshot.user_context['mcpRuntime'])
def test_user_context_loads_task_runtime_summary(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir) / 'repo'
workspace.mkdir(parents=True)
runtime = TaskRuntime.from_workspace(workspace)
runtime.create_task(title='Review task runtime')
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace))
self.assertIn('taskRuntime', snapshot.user_context)
self.assertIn('Total tasks: 1', snapshot.user_context['taskRuntime'])
def test_user_context_loads_plan_runtime_summary(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir) / 'repo'
workspace.mkdir(parents=True)
plan_runtime = PlanRuntime.from_workspace(workspace)
plan_runtime.update_plan(
[{'step': 'Inspect the runtime', 'status': 'in_progress'}],
explanation='Use a stored plan.',
)
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace))
self.assertIn('planRuntime', snapshot.user_context)
self.assertIn('Total plan steps: 1', snapshot.user_context['planRuntime'])
@unittest.skipIf(shutil.which('git') is None, 'git is required for git context tests')
def test_git_status_snapshot_contains_branch_and_status(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
+82
View File
@@ -5,10 +5,12 @@ import unittest
from pathlib import Path
from src.agent_prompting import build_prompt_context, build_system_prompt_parts, render_system_prompt
from src.plan_runtime import PlanRuntime
from src.agent_runtime import LocalCodingAgent
from src.agent_session import AgentSessionState
from src.agent_tools import default_tool_registry
from src.agent_types import AgentPermissions, AgentRuntimeConfig, ModelConfig
from src.task_runtime import TaskRuntime
class AgentPromptingTests(unittest.TestCase):
@@ -79,3 +81,83 @@ class AgentPromptingTests(unittest.TestCase):
prompt = render_system_prompt(parts)
self.assertIn('# Plugins', prompt)
def test_prompt_builder_mentions_hook_policy_when_manifest_is_loaded(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / '.claw-policy.json').write_text(
'{"trusted": false, "hooks": {"beforePrompt": ["Follow workspace policy."]}}',
encoding='utf-8',
)
runtime_config = AgentRuntimeConfig(cwd=workspace)
model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct')
prompt_context = build_prompt_context(runtime_config, model_config)
parts = build_system_prompt_parts(
prompt_context=prompt_context,
runtime_config=runtime_config,
tools=default_tool_registry(),
)
prompt = render_system_prompt(parts)
self.assertIn('# Hook Policy', prompt)
def test_prompt_builder_mentions_mcp_when_manifest_is_loaded(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / 'notes.txt').write_text('mcp notes\n', encoding='utf-8')
(workspace / '.claw-mcp.json').write_text(
(
'{"servers":[{"name":"workspace","resources":['
'{"uri":"mcp://workspace/notes","name":"Notes","path":"notes.txt"}'
']}]}'
),
encoding='utf-8',
)
runtime_config = AgentRuntimeConfig(cwd=workspace)
model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct')
prompt_context = build_prompt_context(runtime_config, model_config)
parts = build_system_prompt_parts(
prompt_context=prompt_context,
runtime_config=runtime_config,
tools=default_tool_registry(),
)
prompt = render_system_prompt(parts)
self.assertIn('# MCP', prompt)
def test_prompt_builder_mentions_tasks_when_runtime_is_loaded(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
runtime = TaskRuntime.from_workspace(workspace)
runtime.create_task(title='Inspect runtime tasks')
runtime_config = AgentRuntimeConfig(cwd=workspace)
model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct')
prompt_context = build_prompt_context(runtime_config, model_config)
parts = build_system_prompt_parts(
prompt_context=prompt_context,
runtime_config=runtime_config,
tools=default_tool_registry(),
)
prompt = render_system_prompt(parts)
self.assertIn('# Tasks', prompt)
def test_prompt_builder_mentions_planning_when_runtime_is_loaded(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
runtime = PlanRuntime.from_workspace(workspace)
runtime.update_plan(
[{'step': 'Inspect runtime planning', 'status': 'pending'}],
explanation='Track the current plan.',
)
runtime_config = AgentRuntimeConfig(cwd=workspace)
model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct')
prompt_context = build_prompt_context(runtime_config, model_config)
parts = build_system_prompt_parts(
prompt_context=prompt_context,
runtime_config=runtime_config,
tools=default_tool_registry(),
)
prompt = render_system_prompt(parts)
self.assertIn('# Planning', prompt)
+168
View File
@@ -245,6 +245,26 @@ class AgentRuntimeTests(unittest.TestCase):
)
self.assertFalse(result.ok)
self.assertIn('--allow-write', result.content)
self.assertEqual(result.metadata.get('error_kind'), 'permission_denied')
def test_build_tool_context_supports_safe_env_overlay_for_shell_tools(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
config = AgentRuntimeConfig(
cwd=Path(tmp_dir),
permissions=AgentPermissions(allow_shell_commands=True),
)
context = build_tool_context(
config,
extra_env={'HOOK_SAFE_TOKEN': 'demo-secret'},
)
result = execute_tool(
default_tool_registry(),
'bash',
{'command': 'printf %s "$HOOK_SAFE_TOKEN"'},
context,
)
self.assertTrue(result.ok)
self.assertIn('demo-secret', result.content)
def test_local_slash_command_returns_without_model_call(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
@@ -2379,3 +2399,151 @@ class AgentRuntimeTests(unittest.TestCase):
},
},
)
def test_hook_policy_before_prompt_injection_and_after_turn_event(self) -> None:
responses = [
{
'choices': [
{
'message': {
'role': 'assistant',
'content': 'Completed under policy guidance.',
},
'finish_reason': 'stop',
}
],
'usage': {'prompt_tokens': 6, 'completion_tokens': 3},
}
]
recorded_payloads: list[dict[str, object]] = []
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / '.claw-policy.json').write_text(
(
'{"trusted": false, '
'"managedSettings": {"reviewMode": "strict"}, '
'"safeEnv": ["HOOK_SAFE_TOKEN"], '
'"hooks": {"beforePrompt": ["Respect workspace policy."], '
'"afterTurn": ["Persist the policy decision."]}}'
),
encoding='utf-8',
)
with patch.dict('os.environ', {'HOOK_SAFE_TOKEN': 'demo-secret'}, clear=False):
with patch(
'src.openai_compat.request.urlopen',
side_effect=make_recording_urlopen_side_effect(responses, recorded_payloads),
):
agent = LocalCodingAgent(
model_config=ModelConfig(
model='Qwen/Qwen3-Coder-30B-A3B-Instruct',
base_url='http://127.0.0.1:8000/v1',
),
runtime_config=AgentRuntimeConfig(cwd=workspace),
)
result = agent.run('Inspect the repository.')
payload_text = json.dumps(recorded_payloads[0])
self.assertIn('Workspace hook/policy guidance', payload_text)
self.assertIn('Respect workspace policy.', payload_text)
self.assertIn('Trust mode: untrusted', payload_text)
self.assertEqual(agent.tool_context.extra_env.get('HOOK_SAFE_TOKEN'), 'demo-secret')
self.assertIn('reviewMode=strict', agent.render_trust_report())
hook_events = [event for event in result.events if event.get('type') == 'hook_policy_after_turn']
self.assertEqual(len(hook_events), 1)
self.assertEqual(hook_events[0].get('message'), 'Persist the policy decision.')
def test_hook_policy_blocks_tool_and_tracks_permission_denial(self) -> None:
responses = [
{
'choices': [
{
'message': {
'role': 'assistant',
'content': 'Trying bash first.',
'tool_calls': [
{
'id': 'call_1',
'type': 'function',
'function': {
'name': 'bash',
'arguments': '{"command": "pwd"}',
},
}
],
},
'finish_reason': 'tool_calls',
}
],
'usage': {'prompt_tokens': 8, 'completion_tokens': 3},
},
{
'choices': [
{
'message': {
'role': 'assistant',
'content': 'Bash was blocked by workspace policy.',
},
'finish_reason': 'stop',
}
],
'usage': {'prompt_tokens': 7, 'completion_tokens': 3},
},
]
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / '.claw-policy.json').write_text(
'{"denyTools": ["bash"]}',
encoding='utf-8',
)
with patch('src.openai_compat.request.urlopen', side_effect=make_urlopen_side_effect(responses)):
agent = LocalCodingAgent(
model_config=ModelConfig(
model='Qwen/Qwen3-Coder-30B-A3B-Instruct',
base_url='http://127.0.0.1:8000/v1',
),
runtime_config=AgentRuntimeConfig(cwd=workspace),
)
result = agent.run('Run pwd')
self.assertEqual(result.final_output, 'Bash was blocked by workspace policy.')
event_types = [event.get('type') for event in result.events]
self.assertIn('hook_policy_tool_block', event_types)
self.assertIn('tool_permission_denial', event_types)
tool_message = next(
message
for message in result.transcript
if message.get('role') == 'tool'
)
self.assertTrue(tool_message['metadata'].get('hook_policy_blocked'))
self.assertEqual(tool_message['metadata'].get('error_kind'), 'permission_denied')
def test_hook_policy_budget_override_applies_when_runtime_budget_is_unset(self) -> None:
responses = [
{
'choices': [
{
'message': {
'role': 'assistant',
'content': 'One model call happened.',
},
'finish_reason': 'stop',
}
],
'usage': {'prompt_tokens': 4, 'completion_tokens': 2},
}
]
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / '.claw-policy.json').write_text(
'{"budget": {"max_model_calls": 0}}',
encoding='utf-8',
)
with patch('src.openai_compat.request.urlopen', side_effect=make_urlopen_side_effect(responses)):
agent = LocalCodingAgent(
model_config=ModelConfig(
model='Qwen/Qwen3-Coder-30B-A3B-Instruct',
base_url='http://127.0.0.1:8000/v1',
),
runtime_config=AgentRuntimeConfig(cwd=workspace),
)
result = agent.run('Hello')
self.assertEqual(result.stop_reason, 'budget_exceeded')
self.assertIn('model-call budget was exceeded', result.final_output)
+94
View File
@@ -3,10 +3,13 @@ from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from src.agent_runtime import LocalCodingAgent
from src.agent_slash_commands import looks_like_command, parse_slash_command
from src.agent_types import AgentRuntimeConfig, ModelConfig
from src.plan_runtime import PlanRuntime
from src.task_runtime import TaskRuntime
class AgentSlashCommandTests(unittest.TestCase):
@@ -53,6 +56,72 @@ class AgentSlashCommandTests(unittest.TestCase):
self.assertIn('### Estimated usage by category', result.final_output)
self.assertIn('### Memory Files', result.final_output)
def test_mcp_and_resource_commands_render_local_reports(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / 'notes.txt').write_text('mcp notes\n', encoding='utf-8')
(workspace / '.claw-mcp.json').write_text(
(
'{"servers":[{"name":"workspace","resources":['
'{"uri":"mcp://workspace/notes","name":"Notes","path":"notes.txt"}'
']}]}'
),
encoding='utf-8',
)
agent = LocalCodingAgent(
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
runtime_config=AgentRuntimeConfig(cwd=workspace),
)
mcp_result = agent.run('/mcp')
resources_result = agent.run('/resources')
resource_result = agent.run('/resource mcp://workspace/notes')
legacy_mcp_result = agent.run('/mcp (MCP)')
self.assertIn('# MCP', mcp_result.final_output)
self.assertIn('Local MCP resources: 1', mcp_result.final_output)
self.assertIn('# MCP Resources', resources_result.final_output)
self.assertIn('mcp://workspace/notes', resources_result.final_output)
self.assertIn('# MCP Resource', resource_result.final_output)
self.assertIn('mcp notes', resource_result.final_output)
self.assertIn('# MCP', legacy_mcp_result.final_output)
def test_tasks_and_task_commands_render_local_reports(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
runtime = TaskRuntime.from_workspace(workspace)
mutation = runtime.create_task(
title='Review runtime tasks',
status='in_progress',
)
task_id = mutation.task.task_id if mutation.task is not None else ''
agent = LocalCodingAgent(
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
runtime_config=AgentRuntimeConfig(cwd=workspace),
)
tasks_result = agent.run('/tasks')
task_result = agent.run(f'/task {task_id}')
todo_result = agent.run('/todo in_progress')
self.assertIn('# Tasks', tasks_result.final_output)
self.assertIn(task_id, tasks_result.final_output)
self.assertIn('# Task', task_result.final_output)
self.assertIn('in_progress', task_result.final_output)
self.assertIn('# Tasks', todo_result.final_output)
def test_plan_command_renders_local_report(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
plan_runtime = PlanRuntime.from_workspace(workspace)
plan_runtime.update_plan(
[{'step': 'Inspect the plan command', 'status': 'in_progress'}],
explanation='Use the local plan runtime.',
)
agent = LocalCodingAgent(
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
runtime_config=AgentRuntimeConfig(cwd=workspace),
)
plan_result = agent.run('/plan')
self.assertIn('# Plan', plan_result.final_output)
self.assertIn('Inspect the plan command', plan_result.final_output)
def test_tools_and_status_commands_render_local_reports(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
agent = LocalCodingAgent(
@@ -66,6 +135,31 @@ class AgentSlashCommandTests(unittest.TestCase):
self.assertIn('# Status', status_result.final_output)
self.assertIn('Last run: none', status_result.final_output)
def test_hooks_and_trust_commands_render_local_reports(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
(workspace / '.claw-policy.json').write_text(
(
'{"trusted": false, '
'"managedSettings": {"reviewMode": "strict"}, '
'"safeEnv": ["HOOK_SAFE_TOKEN"]}'
),
encoding='utf-8',
)
with patch.dict('os.environ', {'HOOK_SAFE_TOKEN': 'demo-secret'}, clear=False):
agent = LocalCodingAgent(
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
runtime_config=AgentRuntimeConfig(cwd=workspace),
)
hooks_result = agent.run('/hooks')
trust_result = agent.run('/trust')
self.assertIn('# Hook Policy', hooks_result.final_output)
self.assertIn('Local hook/policy manifests', hooks_result.final_output)
self.assertIn('# Trust', trust_result.final_output)
self.assertIn('untrusted', trust_result.final_output)
self.assertIn('reviewMode=strict', trust_result.final_output)
self.assertIn('HOOK_SAFE_TOKEN=demo-secret', trust_result.final_output)
def test_clear_command_clears_saved_runtime_state(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
agent = LocalCodingAgent(
+109
View File
@@ -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)
+158
View File
@@ -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', ''))
+189
View File
@@ -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', ''))
+167
View File
@@ -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', ''))