Files

254 lines
11 KiB
Markdown

# execution/ — Agent Runtime Engine
This package contains the runtime core of the agent system: the Claude Agent SDK query loop, prompt assembly, MCP server configuration, SDK hooks, and the human-in-the-loop approval flow.
## How It All Fits Together
```
run_agent_loop() ← entry point (called by AgentManager)
├─ build_prompt_content() ← prompt_builder.py
│ └─ resolve_context_paths() ← prompt_context.py
├─ create_sdk_hooks() ← agent_hooks.py
│ └─ request_approval() ← approval.py
│ └─ get_effective_policy() ← mcp_builder.py
├─ build_agent_options() ← agent_options.py
│ ├─ resolve_mode() ← prompt_builder.py
│ ├─ compose_system_prompt() ← prompt_builder.py
│ ├─ build_connected_tools_context()← prompt_context.py
│ ├─ build_browser_context() ← prompt_context.py
│ └─ build_mcp_servers() ← mcp_builder.py
└─ Claude Agent SDK query() ← streaming loop
├─ StreamEvent → ws_manager (real-time token streaming)
├─ AssistantMessage → Messages (text + tool calls)
└─ ResultMessage → cost/tokens (final accounting)
```
## Files
### `agent_loop.py` — Main Query Loop
The top-level entry point for running an agent. Orchestrates everything else.
**`run_agent_loop(sessions, session_id, prompt, ...)`** (async)
1. Builds the user prompt via `build_prompt_content()` — resolves context paths, forced tools, attached skills, and images
2. Creates SDK hooks via `create_sdk_hooks()` — permission checking, tool approval, result formatting
3. Builds the full options dict via `build_agent_options()` — system prompt, MCP servers, tool permissions, API config
4. Creates `ClaudeAgentOptions` and calls `query()` to start the streaming agent loop
5. Iterates the async stream, dispatching to three handlers:
| Event Type | Handler | What It Does |
|------------|---------|--------------|
| `StreamEvent` | `_handle_stream_event()` | Real-time text/tool streaming deltas → WebSocket |
| `AssistantMessage` | `_handle_assistant_message()` | Extracts text + tool_use blocks, creates Messages, emits via WS |
| `ResultMessage` | `_handle_result_message()` | Captures session ID, cost, token usage |
6. On completion: sets status to `completed`, persists session, fires analytics
7. On cancellation: sets status to `stopped`
8. On error: sets status to `error`, creates an error message
---
### `agent_hooks.py` — SDK Hook Factories
Creates the three hook functions the Claude Agent SDK needs for tool execution control.
**`create_sdk_hooks(session, session_id, sessions, builtin_perms, ...)`**
Returns a tuple of `(can_use_tool, pre_tool_hook, post_tool_hook)`:
**`can_use_tool(tool_name, input_data)`**
- Looks up the effective permission policy for the tool
- `always_allow` → auto-approve
- `deny` → auto-reject
- `ask` → triggers HITL approval (except for `AskUserQuestion` which is always allowed)
**`pre_tool_hook(input_data, tool_use_id)`**
- Enforces `deny` policy by returning a denial result
- For `ask` policy, calls `request_approval()` and blocks until user decides
- Tracks tool start time for duration analytics
**`post_tool_hook(input_data, tool_use_id)`**
- Calculates elapsed execution time
- Records `tool.executed` analytics (tool name, MCP server, duration, content length)
- Normalizes response content from the SDK
- Creates `tool_result` Message objects and appends to session
- Special handling for `Agent` tool results — creates sub-agent sessions via `_build_sub_agent_session()`
- Emits everything via WebSocket
**`_build_sub_agent_session(input_data, raw_response, content, session, ...)`**
- Parses the result of an `Agent` tool call
- Creates a child `AgentSession` with the sub-agent's messages
- Stores it in the sessions dict and broadcasts via WebSocket
---
### `agent_options.py` — ClaudeAgentOptions Builder
Assembles the complete configuration dict passed to the Claude Agent SDK.
**`build_agent_options(session, builtin_perms, hooks..., fork_session?, selected_browser_ids?)`** (async)
Builds a kwargs dict containing:
| Key | Source | Description |
|-----|--------|-------------|
| `system_prompt` | `compose_system_prompt()` | Global + mode + session + tool context + browser context |
| `mcp_servers` | `build_mcp_servers()` | User MCP tools (with OAuth2 refresh) |
| + browser MCP | stdio subprocess | `openswarm-browser-agent` (if not fully denied) |
| + invoke MCP | stdio subprocess | `openswarm-invoke-agent` (if not fully denied) |
| `allowed_tools` | `_compute_tool_permissions()` | Tools with `always_allow` or `ask` policy |
| `disallowed_tools` | `_compute_tool_permissions()` | Tools with `deny` policy |
| `model` | `resolve_model_id()` | Resolved from session model + provider |
| `api_key` or proxy | settings | Direct Anthropic key or 9Router proxy URL |
| `session_id` | session | For SDK resume/fork support |
**`_compute_tool_permissions(session, builtin_perms, mcp_servers, ...)`**
Maps permission policies to the SDK's `mcp__<server>__<tool>` naming convention:
- Builtins → direct policy lookup
- Browser/invoke MCP tools → mapped back to their builtin equivalents
- User MCP tools → per-server per-tool permission lookup
Special handling:
- `VIEW_BUILDER_SKILL` is injected when mode is `"view-builder"`
- API key vs. 9Router proxy configuration (with `bare` mode and `cc/` model prefix)
- Resume vs. fork session behavior
---
### `prompt_builder.py` — Prompt Composition
Stateless helpers for building the system prompt and user prompt content.
**`resolve_mode(mode_id, get_all_tool_names_fn)`**
- Loads mode definition (from the modes package)
- Returns `(allowed_tools, system_prompt, default_folder)`
- Falls back to all tools if mode not found
**`compose_system_prompt(default_prompt, mode_prompt, session_prompt, tools_ctx?, browser_ctx?)`**
- Joins non-empty prompt fragments with `\n\n`
- Returns `None` if all fragments are empty
**`resolve_forced_tools(forced_tools, load_all_tools_fn)`**
- Builds XML `<forced_tools>` block describing user-selected tools
- Includes tool descriptions, MCP server names, and connected account emails
**`resolve_attached_skills(attached_skills)`**
- Formats skill attachments as `[Using skill: name]\n\ncontent`
**`build_prompt_content(prompt, images?, context_paths?, forced_tools?, attached_skills?, ...)`**
- Orchestrator that calls all the above
- If images are present: returns a multimodal content list with base64 image blocks
- Otherwise: returns a plain string
---
### `prompt_context.py` — Context Building
Generates XML context blocks that get injected into the system or user prompt.
**`build_connected_tools_context(allowed_tools, ...)`**
- Generates `<connected_mcp_tools>` XML listing all MCP servers, their status, connected accounts, and available tools
- Also lists installed-but-not-connected tools
**`build_browser_context(dashboard_id, selected_browser_ids?)`**
- Generates `<browser_agent_instructions>` XML explaining the browser delegation tools
- Lists user-selected browser cards with IDs, titles, and current URLs
**`get_pre_selected_browser_ids(dashboard_id)`**
- Returns browser card IDs from the dashboard layout
**`resolve_context_paths(context_paths)`**
- For each path: reads file contents (up to 512KB) or builds directory tree (depth 4)
- Wraps in `<context_file>` or `<context_directory>` XML tags
**`build_dir_tree(root, max_depth=4, prefix="")`**
- Recursive directory listing, skipping dotfiles
---
### `mcp_builder.py` — MCP Server Config + Tool Policies
Manages MCP server configuration and resolves tool permission policies.
**Constants:**
- `FULL_TOOLS` — canonical list of ~22 built-in tool names (Read, Edit, Write, Bash, Glob, Grep, AskUserQuestion, WebSearch, WebFetch, NotebookEdit, TodoWrite, EnterPlanMode, ExitPlanMode, EnterWorktree, TaskOutput, TaskStop, CronCreate, CronList, CronDelete, RenderOutput, InvokeAgent, Agent)
**`build_mcp_servers(allowed_tools)`** (async)
- Iterates enabled MCP tools, filters by allowed list
- Skips fully-denied tools
- Refreshes OAuth2 tokens for Google tools
- Derives MCP server configs
- Returns `{server_name: config}` dict
**`get_effective_policy(tool_name, builtin_perms)`**
- Resolves policy for any tool name:
- Builtins → direct lookup in `builtin_perms`
- `mcp__openswarm-browser-agent__X` → maps to the builtin browser tool equivalent
- `mcp__openswarm-invoke-agent__X` → maps to the builtin InvokeAgent equivalent
- Other MCP tools → per-server per-tool permission lookup
- Default policy: `"ask"`
**`get_all_tool_names()`**
- Returns `FULL_TOOLS` (minus denied builtins) + `mcp:<name>` for enabled/connected MCP tools
Helper functions: `_get_denied_tool_names()`, `_get_all_known_tool_names()`, `_is_fully_denied()`
---
### `approval.py` — Human-in-the-Loop Approval
Shared approval flow used by both the main agent and browser sub-agents.
**`request_approval(session, tool_name, tool_input, timeout?, track_analytics?)`** (async)
1. Creates an `ApprovalRequest` with a unique ID
2. Adds it to the session's `pending_approvals`
3. Sets session status to `waiting_approval`, emits via WebSocket
4. Calls `ws_manager.send_approval_request()` — creates an `asyncio.Future` and waits
5. User's decision resolves the Future (or timeout triggers auto-deny)
6. Records `approval.requested` and `approval.resolved` analytics (with latency)
7. Cleans up, restores status to `running`
8. Returns `{"behavior": "allow"|"deny", "message": ..., "updated_input": ...}`
---
### `agent_mock.py` — Session Completion Analytics
**`fire_session_completed(session, sessions_dict)`**
Fires a comprehensive `session.completed` analytics event with:
- Model, provider, mode
- Total cost (USD), token usage (input + output)
- Message count, duration (seconds)
- Final status, tool usage counts
- Session title, first user message
- Sub-agent count and IDs
- Branch count
---
### `invoke_agent_mcp_server.py` — InvokeAgent MCP Server
A standalone stdio MCP server launched as a subprocess by the Claude Agent SDK.
**How it works:**
1. The SDK starts this as a child process
2. It reads JSON-RPC messages from stdin
3. For `tools/list`: returns the `InvokeAgent` tool schema
4. For `tools/call`: POSTs to `http://127.0.0.1:{port}/api/agents/invoke-agent/run`
5. The backend forks the source session, runs the agent loop, and returns the response
6. The MCP server formats and returns the result
**Environment variables it reads:**
- `OPENSWARM_PORT` — backend port (default 8325)
- `OPENSWARM_PARENT_SESSION_ID` — parent session for tracking
- `OPENSWARM_DASHBOARD_ID` — dashboard context
**No internal Python imports** — communicates with the backend purely via HTTP. This is necessary because it runs as a separate subprocess.