- src/ 页面与组件、public/ 静态资源 - 工程配置:vite/tsconfig/package 依赖
34 KiB
title, date, author, tags, cover, excerpt
| title | date | author | tags | cover | excerpt | ||||
|---|---|---|---|---|---|---|---|---|---|
| QwenPaw Runtime Architecture Upgrade: Design, Implementation & Extensibility | 2026-07-07 | QwenPaw Team |
|
/blog/runtime-architecture-upgrade-cover.png | QwenPaw Runtime was refactored from a 650-line god method into an 8-phase orchestration engine—enabling one-feature-one-directory extensibility with zero changes to Runtime core code. |
QwenPaw Runtime Architecture Upgrade: Design, Implementation & Extensibility
Table of Contents
-
- Background & Motivation — Why a Runtime Architecture Upgrade Was Needed
-
- Overall Architecture
-
- Runtime: The 8-Phase Orchestration Engine
-
- Agent Building & Execution
-
- Hook System — The Core Extensibility Mechanism
-
- Prompt, Tool & Command Registration
-
- Cross-Workspace Services: AppServiceManager
-
- End-to-End Walkthrough: A Request's Complete Journey
-
- Summary
1. Background & Motivation — Why a Runtime Architecture Upgrade Was Needed
Core Pain Points
Before the Runtime architecture upgrade, QwenPaw's request handling was concentrated in a single AgentRunner.query_handler() method — a god method exceeding 650 lines. Every new feature (mission mode, coding mode, plan mode, cron isolation, skill injection, etc.) required invasive modifications to this core function, continuously adding if/else branches and cross-cutting state management.
A concrete example: adding a /mission feature at the time required changes across 8 files, 2 of which were invasive modifications to core files. Each new feature added ~40 lines to runner.py. This development pattern created severe maintenance burden and merge conflicts.
Summary of specific pain points:
| # | Pain Point | Cost |
|---|---|---|
| 1 | Runner is a god method | ~40 lines of runner.py changed per new feature |
| 2 | Agent construction hardcodes tool dictionary | Adding a tool requires ~80 lines of invasive changes to the agent class |
| 3 | System prompt assembly is scattered | Injection points spread across mixin/runner/env |
| 4 | Three duplicate external interfaces | Command dispatch + API + CLI, each method written 3 times |
| 5 | New features require changes to 4–8 files | /mission changed 8 files, 2 with core invasions |
Goal
One feature = one directory. Zero lines of change to the Runtime body.
2. Overall Architecture
Layered Design
The upgraded architecture adopts an OS-style layered design:
The actual structure implemented in code:
AppServiceManager (FastAPI lifespan scope, shared across workspaces)
│ ├── TaskTracker (cross-workspace task tracking)
│ ├── ToolCoordinator (cross-workspace tool lifecycle management)
│ └── ApprovalCoordinator (sub-agent approval)
│
└── Workspace (per-agent)
├── Runtime (execution engine: 8-phase orchestration)
├── ServiceManager (per-agent resource lifecycle)
│ ├── MemoryManager / CronManager / ChatManager
│ ├── DriverManager (MCP / Channel / ACP / A2A)
│ └── SessionManager
└── WorkspacePlugins (per-workspace registries)
├── HookRegistry (8-phase hook orchestration)
├── SlashCommandRegistry (unified command dispatch)
├── ToolRegistry (declarative tool registration)
├── PromptManager (pluggable prompt construction)
└── modes: list[AgentMode] (behavior mode list)
Core Responsibility Separation
| Layer | Lifecycle | Responsibility |
|---|---|---|
| AppServiceManager | FastAPI lifespan | Three coordinators shared across workspaces |
| Workspace | per-agent (dynamic) | Single agent's resources + complete execution unit |
| Runtime | per-request | Execution engine: 8-phase hook orchestration + build + execute |
| ServiceManager | per-agent | Manages per-agent resource start/stop, dependency ordering, parallel initialization |
| WorkspacePlugins | Registered at startup | Hook / Command / Tool / Prompt / Mode registries |
Strict contract: AppServiceManager has exactly three fields — task_tracker, tool_coordinator, approval_coordinator. The code enforces this via __slots__; adding any other field violates the contract. Per-workspace state belongs to WorkspacePlugins / ServiceManager.
Actual constraint in code:
# src/pineagents/app/app_services/app_service_manager.py
class AppServiceManager:
__slots__ = (
"task_tracker",
"tool_coordinator",
"approval_coordinator",
)
3. Runtime: The 8-Phase Orchestration Engine
Core: Runtime.run()
The previous pain point was that the runner was a god method. The solution is to split request processing into 8 fixed phases, each serving as a hook mounting point.
The Runtime class (src/pineagents/runtime/runtime.py) is the per-workspace request orchestrator. Each request invokes run(), which produces SSE envelope objects through the 8-phase lifecycle.
class Runtime:
"""Per-workspace request orchestrator.
One ``Runtime`` instance per ``Workspace``. ``run()`` is called once
per ``AgentRequest`` and yields SSE envelope objects."""
The 8 Phases
Defined in src/pineagents/runtime/phases.py:
class Phase(str, Enum):
PRE_DISPATCH = "pre_dispatch" # Request normalization, before slash dispatch
POST_DISPATCH = "post_dispatch" # After slash dispatch finds no match
PRE_AGENT_BUILD = "pre_agent_build" # Session loading and other pre-build preparation
POST_AGENT_BUILD = "post_agent_build" # Agent built; inject mode context
PRE_EXECUTE = "pre_execute" # Bootstrap / prompt refresh
POST_RESPONSE = "post_response" # Session save / cron writeback
ON_ERROR = "on_error" # Exception normalization
FINALLY = "finally" # Idempotent cleanup
Execution Flow
The actual execution flow of Runtime.run() interleaves hook phases with fixed steps:
[Phase 1] PRE_DISPATCH hooks ← Pluggable
[Fixed 1] SlashCommandRegistry.dispatch ← Fixed: unified command dispatch
[Phase 2] POST_DISPATCH hooks ← Pluggable
[Phase 3] PRE_AGENT_BUILD hooks ← Pluggable (session load, etc.)
[Fixed 2] AgentBuilder.build(ctx) ← Fixed: build agent
[Phase 4] POST_AGENT_BUILD hooks ← Pluggable
[Phase 5] PRE_EXECUTE hooks ← Pluggable (bootstrap, etc.)
[Fixed 3] AgentExecutor.run(msgs) ← Fixed: execute agent
[Phase 6] POST_RESPONSE hooks ← Pluggable (session save, etc.)
[Phase 7] ON_ERROR hooks ← Triggered on exception
[Phase 8] FINALLY hooks ← Always executed (cleanup)
Key design: Phase positions are fixed; hooks registered at each phase are pluggable. Adding a new feature = adding a new hook, no modification to Runtime.run() needed.
Three Hook Actions
Each hook returns a HookResult containing one of three actions (src/pineagents/runtime/hooks.py):
class HookAction(str, Enum):
CONTINUE = "continue" # Continue to next hook / phase
SHORT_CIRCUIT = "short_circuit" # Return payload immediately, end current request
SKIP_AGENT = "skip_agent" # Skip agent build and execution, but subsequent hooks still run
For example, when a slash command is matched, Runtime.run() sets skip_agent = True — agent build and execution steps are skipped, but POST_RESPONSE and FINALLY hooks still execute, ensuring session persistence and cleanup.
4. Agent Building & Execution
The previous pain points were hardcoded tools in the agent class and scattered prompt assembly. The solution is declarative assembly via AgentBuilder and heartbeat-driven execution via AgentExecutor.
AgentBuilder — Per-Request Agent Assembly
AgentBuilder (src/pineagents/runtime/builder.py) is responsible for constructing a complete QwenPawAgent for each request. It integrates:
| Component | Source | Description |
|---|---|---|
| Toolkit | local_workspace.list_tools() |
Internally uses ToolRegistry for declarative filtering by mode/skill/feature |
| System Prompt | PromptManager.build_sync() |
Pluggable prompt contributors assembled by priority |
| Model | ProviderManager + model factory |
LLM model + formatter |
| Middlewares | ToolCoordinatorMiddleware + plugin middlewares |
Onion-model middleware |
| Governor | ResourceGovernor |
Governance policy layer (sandbox, permissions) |
| Session State | ctx.session_state (from SessionLoadHook) |
Session state restoration |
Key improvement: Tools are no longer hardcoded in the agent class. They are obtained via the workspace's list_tools(), which internally uses ToolRegistry (tools registered via the @tool_descriptor decorator) to filter per-request based on active modes, skills, and features.
AgentExecutor & Envelope
AgentExecutor (src/pineagents/runtime/executor.py) drives the agent's reply stream and wraps a heartbeat mechanism — ensuring that during long idle periods (e.g., tool-guard approval waits), keep-alive envelopes are emitted instead of letting the SSE connection drop.
Envelope (src/pineagents/runtime/envelope.py) is an SSE state machine that translates agentscope EventType events into the frontend's streaming envelope protocol, tracking per-request state (text blocks, reasoning blocks, tool calls, data blocks) and producing the correct event sequence.
5. Hook System — The Core Extensibility Mechanism
This is the core extensibility mechanism of this architecture upgrade. Previously, adding a new feature required invasive changes to the runner; now you only need to register a hook.
Two Types of Hooks
The upgraded architecture provides two hook base classes for different scenarios:
| Base Class | Definition Location | Execution Timing | Purpose |
|---|---|---|---|
LifecycleHook |
hooks/base.py |
Every request reaching that phase | Infrastructure: session load/save, bootstrap, skill env, error handling |
ModeGatedHook |
modes/base.py |
Only when the owning mode is active | Behavior modes: mission state, coding mode context, goal persistence |
Both inherit from HookBase in runtime/hooks.py. ModeGatedHook automatically checks self.owner_mode.is_active(ctx) in run(), skipping execution if the condition is not met. This design eliminates a recurring bug in the old code: every hook forgetting to add a mode activation check.
HookRegistry — Topological Sorting
HookRegistry (src/pineagents/runtime/hooks.py) manages hook registration and execution:
- Hooks are grouped by
Phase - Within the same phase, hooks are topologically sorted via
before/afterconstraints, with priority as tiebreaker - Cycle detection at startup throws
HookCycleError - Sort results are cached and automatically invalidated on new registration
class HookBase:
phase: Phase
name: str
priority: int = 100
before: tuple[str, ...] = () # "run before these hooks"
after: tuple[str, ...] = () # "run after these hooks"
For example, SessionLoadHook declares priority = 10 (executes earlier in PRE_AGENT_BUILD), while MissionStateLoadHook declares after = ("session_load",), ensuring task state loads after session state.
Built-in LifecycleHooks
The project currently includes the following built-in LifecycleHooks (many appear as setup/cleanup pairs across different phases):
| Hook | Phase | Priority | Responsibility |
|---|---|---|---|
ContextVarsSetupHook |
PRE_DISPATCH | 10 | Inject per-request ContextVars (workspace_dir, session_id, etc.) |
CronContextHook |
PRE_DISPATCH | 5 | Mark cron-originated requests |
SessionLoadHook |
PRE_AGENT_BUILD | 10 | Load session state from persistent storage |
MediaProcessHook |
PRE_EXECUTE | 5 | Process file/media blocks in input messages |
CronMemoryIsolateHook |
PRE_EXECUTE | 10 | Snapshot and clear agent context (cron isolation) |
LangfuseTraceHook |
PRE_EXECUTE | 12 | Open Langfuse root trace span |
BootstrapHook |
PRE_EXECUTE | 20 | Inject BOOTSTRAP.md guidance |
SkillEnvHook |
PRE_EXECUTE | 40 | Push skill-declared environment variables |
CronMemoryRestoreHook |
POST_RESPONSE | 80 | Restore cron snapshot and append new messages |
SessionSaveHook |
POST_RESPONSE | 90 | Write back agent state to session storage |
ErrorNormalizeHook |
ON_ERROR | 10 | Normalize provider exceptions to user-readable messages |
CancelCleanupHook |
ON_ERROR | 20 | Clean up approvals and interrupt agent on cancellation |
SkillEnvCleanupHook |
FINALLY | 40 | Pop skill environment variable stack |
LangfuseTraceCleanupHook |
FINALLY | 50 | Close Langfuse root trace span |
A notable pattern: setup/cleanup hook pairs (e.g., SkillEnvHook in PRE_EXECUTE / SkillEnvCleanupHook in FINALLY) pass context manager handles between phases via the ctx.extras dictionary.
AgentMode — Feature Bundle
AgentMode (src/pineagents/modes/base.py) is a behavior bundle: commands, tools, hooks, and prompt contributors packaged together.
class AgentMode:
name: str
def setup(self, workspace):
for spec in self.commands():
workspace.plugins.slash_command_registry.register(spec)
for desc in self.tools():
workspace.plugins.tool_registry.register(desc)
for hook in self.hooks():
workspace.plugins.hook_registry.register(hook)
for contributor in self.prompt_contributors():
workspace.plugins.prompt_manager.register(contributor)
def commands(self) -> list[CommandSpec]: ...
def tools(self) -> list[ToolDescriptor]: ...
def hooks(self) -> list[HookBase]: ...
def prompt_contributors(self) -> list[PromptContributor]: ...
def is_active(self, ctx: HookContext) -> bool: ...
setup(workspace) is the sole entry point for registration. "Which mode owns what" can be easily derived from the four content methods.
WorkspacePlugins — Registry Container
All per-workspace registries reside in WorkspacePlugins (src/pineagents/app/workspace/workspace_plugins.py):
@dataclass
class WorkspacePlugins:
slash_command_registry: SlashCommandRegistry
hook_registry: HookRegistry
tool_registry: ToolRegistry
prompt_manager: PromptManager
modes: list[AgentMode]
def register_mode(self, mode: AgentMode, workspace):
# Actual code includes duplicate name detection; duplicate registration raises ValueError
self.modes.append(mode)
mode.setup(workspace) # one call registers everything
def active_mode_names(self, ctx) -> set[str]:
return {m.name for m in self.modes if m.is_active(ctx)}
6. Prompt, Tool & Command Registration
PromptManager — Composable System Prompt
Previously, system prompt construction was scattered across mixin, runner, and env modules; adding a new prompt fragment required finding the correct injection point. Now, PromptManager (src/pineagents/runtime/prompt_manager.py) declaratively assembles the system prompt from ordered PromptContributor instances:
class PromptContributor:
name: str
priority: int = 100 # lower = appears earlier
async def contribute(self, ctx) -> str | None:
raise NotImplementedError
class PromptManager:
async def build(self, ctx) -> str:
parts = []
for c in self._contributors: # sorted by priority
fragment = await c.contribute(ctx)
if fragment:
parts.append(fragment.strip())
return "\n\n".join(parts)
The project includes 9 built-in contributors:
| Contributor | Priority | Content |
|---|---|---|
AgentIdentityContributor |
5 | Agent ID identity |
AgentsMdContributor |
10 | AGENTS.md (including heartbeat / memory handling) |
SoulMdContributor |
20 | SOUL.md personality file |
ProfileMdContributor |
30 | PROFILE.md configuration file |
MultimodalHintContributor |
80 | Multimodal capability hints |
CodingModeContributor |
85 | Coding Mode personality block |
ScrollContextContributor |
86 | Scroll context strategy hints |
DriverPolicyHintContributor |
88 | Driver policy guidance |
EnvContextContributor |
90 | Environment context (time / session / OS) |
Adding a new prompt fragment = writing a PromptContributor and registering it. No changes to other files needed. Notably, PromptManager.build() includes built-in fault tolerance — if a single contributor throws an exception, it is logged and skipped without causing the entire prompt build to fail.
ToolRegistry — Declarative Tool Registration
The previous pain point was that tools were hardcoded in the agent class; adding a tool required ~80 lines of invasive changes to the agent class. ToolRegistry (src/pineagents/runtime/tool_registry.py) replaces the old hardcoded tool dictionary with a declarative, filterable registry.
Each tool is described by a ToolDescriptor (core gating fields shown below; the full definition also includes async_execution, description, metadata, etc.):
@dataclass(frozen=True)
class ToolDescriptor:
name: str
func: Callable
enabled_by_default: bool = True
requires_modes: tuple[str, ...] = () # Required active modes (any match)
requires_skills: tuple[str, ...] = () # Required active skills (any match)
requires_features: tuple[str, ...] = () # Required feature flags (all must match)
requires_sandbox: tuple[str, ...] = () # Sandbox resource requirements
# Also includes: async_execution, description, metadata, etc.
Built-in tools use the @tool_descriptor decorator for automatic collection at import time:
@tool_descriptor(
requires_modes=("goal",),
description="Get the current goal status.",
)
async def get_goal(...):
...
At build time, ToolRegistry.filter() selects the correct tools based on the current request's mode, skill, and feature — no if/else needed.
SlashCommandRegistry — Unified Command Dispatch
Previously, QwenPaw had four parallel command mechanisms: conversation, control, daemon, and skill, each method written 3 times. SlashCommandRegistry (src/pineagents/runtime/slash_command_registry.py) unifies them into a single dispatch point.
@dataclass(frozen=True)
class CommandSpec:
name: str
handler: CommandHandler # async (ctx, args) -> Msg | None
aliases: tuple[str, ...]
category: str # "builtin" / "daemon" / "control" / "skill" / "user"
help_text: str
@api_action — Triple Auto-Generation
Addressing the previously listed pain point of "three duplicate external interfaces," @api_action (src/pineagents/api_action.py) fundamentally solves this problem: one decorator simultaneously generates HTTP API, CLI subcommands, and slash commands.
Manager classes inherit from ManagerBase and mark methods with @api_action:
class CronManager(ManagerBase):
endpoint_prefix = "crons"
@api_action(
methods={"http", "cli", "slash"},
http_method="GET",
http_path="/crons/jobs",
slash_command="cron-list",
)
async def list_jobs(self) -> list[CronJobSpec]:
return await self._repo.list_jobs()
The triple generation implementation is distributed across three auto-registrars:
| Generation Path | Implementation File | Mechanism |
|---|---|---|
| HTTP Route | app/_api_action_routes.py |
register_http_routes() scans ManagerRegistry, auto-mounts FastAPI endpoints |
| CLI Subcommand | cli/auto.py |
_LazyAutoGroup lazy-loads; generated CLI commands call the server as HTTP clients |
| Slash Command | app/_api_action_routes.py |
collect_slash_specs_from_api_actions() generates CommandSpec registered to per-workspace registry |
Adding a new management interface = writing one @api_action method. Triple registration completes automatically, zero duplicate code.
7. Cross-Workspace Services: AppServiceManager
AppServiceManager holds exactly three cross-workspace coordinators, enforced via __slots__ — adding any other field violates the contract. Per-workspace state belongs to Workspace.plugins / Workspace.service_manager.
TaskTracker — Cross-Agent Session Tracking
TaskTracker tracks in-progress streaming requests across all workspaces. It supports graceful shutdown (waiting for active sessions to complete) and provides visibility into agents currently processing requests.
ToolCoordinator — Tool Call Lifecycle
ToolCoordinator (src/pineagents/tool_calls/) provides single tool call granularity control. In the old system, /stop could only kill the entire agent. Now individual tool calls can be tracked, cancelled, or backgrounded.
Key components include ToolCallEntry (per-call state), ToolCallStatus (PENDING → RUNNING → DONE / CANCELLED state machine), ToolCoordinatorMiddleware (injected into agent middleware stack), ToolResultPruningMiddleware (tool result pruning), ToolStream (streaming tool output), ToolCallContext / ToolHookRegistry (HITL lifecycle hooks), etc.
ApprovalCoordinator — Cross-Agent Approval
ApprovalCoordinator handles sub-agent approval chains. Because it resides in AppServiceManager (FastAPI lifespan scope), it naturally spans all workspaces — sub-agent approval requests are visible to the parent agent.
ServiceManager — Per-Agent Resource Lifecycle
ServiceManager (src/pineagents/app/workspace/service_manager.py) manages per-agent services, supporting:
- Declarative descriptors — Each service has a
ServiceDescriptorwith priority, dependencies, start/stop methods - Priority-based parallel startup — Same-priority services start concurrently
- Reusable services — Marked services survive workspace reload (e.g., MemoryManager)
- Optional services — Optional service failures are logged only, without aborting the workspace
@dataclass
class ServiceDescriptor:
name: str
service_class: type | Callable[[Workspace], type] # Supports dynamic class factory
init_args: Callable[[Workspace], dict]
post_init: Callable[[Workspace, Any], None] # Optional post-creation hook
start_method: str
stop_method: str
reusable: bool = False
reload_func: Callable[[Workspace, Any], None] # Reusable service reload callback
dependencies: List[str] = []
priority: int = 100
concurrent_init: bool = True
optional: bool = False
8. End-to-End Walkthrough: A Request's Complete Journey
Having introduced each module separately, we now tie them all together with a concrete scenario — watching a user message travel from entering the system to returning a response, seeing what role each module plays.
Scenario: User Sends a Conversation Message in Mission Mode
Assume the user previously started a task via /mission start "Deploy an ECS instance", and Mission Mode is now active. The user sends a follow-up message:
"First, help me check what ECS specifications are currently available"
This message is not a slash command, so it follows the complete agent execution path. Here is its full journey through the system:
Stop 1: Workspace Receives the Request
Workspace is the per-agent resource and execution unit. It holds Runtime, ServiceManager, and WorkspacePlugins.
Upon receiving the request, Workspace calls Runtime.run(), beginning the 8-phase orchestration.
Stop 2: Runtime.run() — 8 Phases Executed Sequentially
Phase 1 + Fixed 1 — Request Normalization & Command Dispatch
LifecycleHooks inject per-request context variables (workspace_dir, session_id, etc.). Then SlashCommandRegistry attempts to match the user message — "First, help me check..." is not a slash command, so skip_agent remains False, continuing into the agent build flow.
Phase 3 · PRE_AGENT_BUILD — ModeGatedHook + Topological Sorting in Action
This demonstrates two core designs of the new architecture:
SessionLoadHook(LifecycleHook) loads session state and conversation history firstMissionStateLoadHook(ModeGatedHook,after=("session_load",)) then loads task state
Key point: MissionStateLoadHook declares after=("session_load",), and HookRegistry's topological sorting ensures it always executes after SessionLoadHook. Meanwhile, because it is a ModeGatedHook, it is automatically skipped when Mission Mode is inactive — developers need not write any if/else.
Fixed 2 · AgentBuilder — Three Registries Working Together
AgentBuilder declaratively assembles the agent for this request: ToolRegistry.filter() filters the correct tool set based on currently active modes; PromptManager.build_sync() assembles the complete system prompt by priority; ToolCoordinatorMiddleware is injected to make every tool call trackable and cancellable.
→ Tools are not hardcoded, prompts are not hand-assembled — everything is declarative and filtered per-request.
Phase 5 — Pre-Execution Preparation
Multiple LifecycleHooks collaborate to prepare the environment: media processing, Langfuse trace opening, Bootstrap guidance injection, Skill environment variable pushing, etc.
Fixed 3 · AgentExecutor — ToolCoordinator Cross-Layer Control
The agent begins execution and decides to call alicloud_cli to query ECS specifications. This tool call passes through ToolCoordinatorMiddleware, creating a ToolCallEntry and tracking its status. If the user sends /stop at this point, ToolCoordinator (at the AppServiceManager layer) can precisely cancel this one tool call instead of killing the entire agent. Envelope translates output events into the SSE envelope protocol, and the heartbeat mechanism ensures the connection stays alive during long executions.
Phase 6 + Phase 8 — Persistence & Cleanup
After execution completes, MissionStateSaveHook (ModeGatedHook) saves task state, and SessionSaveHook writes back the session — symmetric with the load in Phase 3. Finally, the FINALLY phase always executes cleanup (environment variable stack pop, trace closing), regardless of whether the request succeeded or failed.
Comparison: If the User Sends a Slash Command
If the user sends /mission status instead of a regular message:
- Phase 1 — Same as above, ContextVars injection
- Fixed 1 —
SlashCommandRegistry.dispatch()matches themissioncommand → calls_mission_handler→ returns status Msg →skip_agent = True - Phase 2 — Command matched successfully, POST_DISPATCH branch not entered
- Phase 3 ~ Fixed 3 — Due to
skip_agent = True, agent build and execution are skipped - Phase 6 —
SessionSaveHookstill executes, ensuring state persistence - Phase 8 — Cleanup hooks still execute
Key point: skip_agent skips Phase 3 ~ Fixed 3 (build and execution), but POST_RESPONSE and FINALLY phase hooks always execute. This is why the phase design is safer than a simple "early return" — cleanup and persistence are never missed.
Comparison: Developer Wants to Add a New Mode
Suppose you are a developer wanting to add a new Mode to QwenPaw. You do not need to modify a single line of Runtime code.
Taking the existing GoalMode in the project as an example, its directory structure is:
modes/goal/
├── __init__.py # Imports
├── goal_mode.py # GoalMode(AgentMode) + setup()
├── tools.py # Goal-specific tools: get_goal, create_goal, update_goal, etc.
└── contributor.py # GoalPromptContributor(PromptContributor)
One call to GoalMode.setup(workspace) registers commands / tools / prompt into the WorkspacePlugins registries.
Worth noting: Not every Mode needs to use all extension points. The four content methods of AgentMode (commands(), tools(), hooks(), prompt_contributors()) are all optional:
- GoalMode uses tools + prompt contributor, no hooks — it controls iteration loops via
StopHandler's Gate mechanism (GoalIterationGate,GoalBudgetGate) - MissionMode uses command + hooks + prompt contributor, managing task state load/save via
ModeGatedHook - Future new Modes can freely combine these extension points as needed
When GoalMode is active:
ToolRegistryautomatically filters tools withrequires_modes=("goal",)PromptManagerautomatically includes GoalMode's prompt contributor- Runtime code changes: zero lines
9. Summary
Before vs. After Comparison
| Aspect | Before | After |
|---|---|---|
| Adding a mode | Modify runner.py ~40 lines + 3 wiring files | 1 directory, setup() registers once |
| Adding a tool | ~80 lines invasive change to agent class | @tool_descriptor decorator |
| Adding a command | Write 3 dispatch implementations | 1 CommandSpec, or @api_action triple auto-generation |
| Adding a management interface | Write HTTP + CLI + slash 3 times | One @api_action decoration, auto-generates all three |
| Adding a prompt fragment | Find insertion point in runner | 1 PromptContributor |
| Runtime code changes | Every feature modifies runtime | Zero lines |
What This Architecture Upgrade Achieved
- OS-style Layering —
AppServiceManagermanages cross-workspace shared services;Workspaceper-agent;Runtimeper-request pure execution - One Feature, One Module — AgentMode bundles commands + tools + hooks + prompt; LifecycleHook handles cross-mode infrastructure
- Zero Runtime Invasion — 8-phase hook mechanism for extension; Runtime has only three fixed steps (dispatch, build, execute)
- Declarative Agent Construction — Tools via
ToolRegistry+@tool_descriptor; Prompts viaPromptManager+PromptContributor - Unified Command Dispatch —
SlashCommandRegistryreplaces four parallel mechanisms - @api_action Triple Auto-Generation — One decorator simultaneously generates HTTP API + CLI + slash command, completely eliminating interface duplication
- Single Tool Call Granularity Control —
ToolCoordinatorsupports tracking, cancellation, and backgrounding of individual tool calls - Topologically Sorted Hooks —
before/afterconstraints + cycle detection, replacing fragile manual ordering - Lightweight Isolation —
ServiceManagerhandles per-agent service lifecycle, supporting dependency-ordered startup and reusable services