rfc_id: RFC-0050 title: "AgentWolf v1.0 Foundation Architecture: Top-Level Abstractions and Relationships" status: DRAFT author: yuchen.liu reviewers: [] created: 2026-07-08 last_updated: 2026-07-09 (revision 5: Oracle-revised Gantt — defer config split to Phase 3, Phase 1a-light runtime extraction, critical path 44d, HostContext prompt_manager fix) decision_date: related_rfcs: - RFC-0042 (Unified Lifecycle Architecture — six pluggable dimensions) - RFC-0041 (Run vs Turn Separation — Phase 1 prerequisite) - RFC-0038 (Eliminate Pool-Level Agents) - RFC-0024 (Agent Stateless Refactor — related architectural direction) related_specs: - openspec/changes/introduce-anyio-structured-concurrency/ (CancelScope hierarchy) - docs/design/lifecycle-analysis.md (cross-framework research basis)
RFC-0050: AgentWolf v1.0 Foundation Architecture — Top-Level Abstractions and Relationships¶
Overview¶
AgentPool has grown organically across 40+ RFCs, accumulating multiple entry points, parallel abstraction layers (ResourceProvider vs pydantic-ai Capability), and fragmented lifecycle management. This RFC defines the top-level foundation architecture for AgentPool v1.0 (codename "AgentWolf") — establishing six orthogonal layers, their boundaries, and their relationships.
This is a baseline architecture RFC: it does not design any single layer in detail. Instead, it defines the conceptual model that all future sub-RFCs align with. Think of it as the "zoomed-out" view — each layer merits its own deep-dive RFC, but the relationships between layers must be agreed upon first.
Thesis: AgentPool v1.0 is decomposed into six layers, each with a single responsibility and a well-defined interface to adjacent layers. All execution modes (standalone, protocol session, long-running task, channel wake-up), all protocol servers (ACP, OpenCode, AG-UI, OpenAI API, MCP), and all configuration patterns (single-file, multi-config, multi-tenant) are expressible as compositions of these six layers.
Table of Contents¶
- Background & Context
- Problem Statement
- Goals & Non-Goals
- Evaluation Criteria
- Options Analysis
- Recommendation
- Technical Design
- Security Considerations
- Implementation Plan
- Open Questions
- Decision Record
- References
Background & Context¶
Current State¶
AgentPool's current architecture has grown through incremental RFCs, each solving a specific problem but without a unifying top-level model. The result is a system where:
- Five entry points exist for agent execution (standalone
run(),SessionController,BackgroundTaskProvider,WatchCommand, future channel gateway), each with its own lifecycle management - Two parallel abstraction layers coexist for tool/capability management: the deprecated
ResourceProvidersystem and pydantic-ai's nativeCapability/Toolsetsystem - Config-to-runtime mapping is implicit —
AgentPoolserves as both registry and factory, with no clear boundary between "what the YAML describes" and "what the runtime manages" - Multi-tenant support is absent — a single
AgentPoolinstance loads one config and serves all users identically - Protocol servers are tightly coupled to Python — no language-agnostic boundary exists
The codebase has 9 top-level packages under src/:
| Package | Role |
|---|---|
agentpool/ |
Core agent framework (agents, delegation, messaging, tools, models, prompts, storage, hooks, observability) |
agentpool_config/ |
YAML schema definitions (config models, separated for clean imports) |
agentpool_server/ |
Protocol servers (ACP, OpenCode, AG-UI, OpenAI API, MCP) |
agentpool_toolsets/ |
Reusable toolset implementations (builtin, MCP discovery, specialized) |
agentpool_storage/ |
Storage providers (SQL, Zed, Claude, OpenCode) |
agentpool_cli/ |
Command-line interface |
agentpool_commands/ |
Command implementations |
agentpool_prompts/ |
Prompt templates |
acp/ |
ACP protocol implementation (client, agent, schema, bridge, transports) |
Cross-Framework Research Summary¶
Research across six frameworks (documented in docs/design/lifecycle-analysis.md) reveals common patterns:
| Framework | Config Pattern | Runtime Pattern | Protocol Pattern |
|---|---|---|---|
| pydantic-ai | Programmatic + YAML | Agent + Capability/Toolset | None (library) |
| hermes-agent | YAML + Python | Narrow waist core + plugins | Gateway adapters |
| opencode | Programmatic | Session + Durable EventV2 | Protocol server |
| ACP v2 | Protocol-defined | Session state machine | Protocol-native |
| Temporal | Code-first workflows | Workflow + Activity | gRPC |
| K8s | YAML (CRD) | Controller + Reconcile | API server |
Key insight: Every mature system separates (1) configuration (what to build), (2) runtime infrastructure (where to run), and (3) execution (how to run). AgentPool currently conflates all three in AgentPool.
Historical Context¶
| Date | Change | Relevance |
|---|---|---|
| 2026-04-26 | RFC-0029: inject_prompt()/queue_prompt() |
First attempt at idle/wake |
| 2026-06-15 | RFC-0037: Unify steer/followup | Recognized dual-system redundancy |
| 2026-06-27 | RFC-0041: Run vs Turn separation | Restructured RunHandle to persistent state machine |
| 2026-07-08 | RFC-0042: Unified Lifecycle Architecture | Six pluggable dimensions (RunLoop, TriggerSource, Journal, SnapshotStore, CommChannel, EventTransport) |
| 2026-07-08 | RFC-0050 (this document) | Top-level foundation architecture |
Glossary¶
| Term | Definition |
|---|---|
| ConfigRegistry | Named pool of versioned configurations. The "etcd" of AgentWolf — stores YAML manifests, handles versioning, emits change notifications. |
| AgentManifest | Immutable parsed YAML configuration. The "CRD" — describes what agents, tools, teams, and servers to create. |
| AgentHost | Mutable runtime infrastructure for a single config. The "Controller" — owns MCP server processes, storage connections, skill registries, capability caches. One per (config_id, tenant_id). |
| AgentFactory | Compiles an AgentManifest + AgentHost into runnable agents. The "Reconcile loop" — creates/updates/deletes agents, teams, and graphs. |
| RunLoop | Core execution loop. Owns the idle/running/done state machine. Executes Turns. Defined by RFC-0041/0042. |
| Turn | Single reactive cycle: prompt → model → tools → response. Agent-type-specific (NativeTurn, ACPTurn). |
| RunScope | Strong-typed request context: (config_id, tenant_id, user_id, session_id). Routes protocol requests to the correct Host + Factory. |
| Capability | pydantic-ai's AbstractCapability — the plugin primitive. Bundles instructions, tools, toolsets, and lifecycle hooks. |
| Toolset | pydantic-ai's AbstractToolset — a collection of tools with shared lifecycle. |
| MessageNode | Base abstraction for all processing units (Agents, Teams). Provides unified process() interface and connection management. |
| ProtocolServer | Translates between external wire protocols (ACP, OpenCode, AG-UI, OpenAI API, MCP) and internal RunLoop execution. |
| StorageProvider | Analytical storage — interaction history, sessions, messages. Permanent, relational-query-oriented. |
| Journal | Event-layer persistence (WAL pattern). Append + upsert. Crash recovery via replay. Owned by CommChannel. |
| SnapshotStore | Loop-layer state persistence (checkpoint pattern). Turn boundary snapshots. Idempotency keys. Owned by RunLoop. |
Problem Statement¶
Problem 1: No Top-Level Architecture Document¶
AgentPool has 40+ RFCs each addressing specific features, but no document defines the top-level abstractions and their relationships. New contributors must reverse-engineer the mental model from code. This RFC fills that gap.
Problem 2: Config/Runtime/Execution Conflation¶
AgentPool currently serves as:
- Config loader (parses YAML)
- Runtime registry (holds agent instances)
- Factory (creates agents from config)
- Infrastructure manager (owns MCP servers, storage)
- Protocol server host (runs ACP/OpenCode/AG-UI)
These five roles are entangled in one class. Multi-tenant support, dynamic config reloading, and independent scaling are all blocked by this conflation.
Problem 3: Dual Abstraction Layers¶
The deprecated ResourceProvider system coexists with pydantic-ai's Capability/Toolset system. This creates:
- Conceptual confusion (which to use?)
- Maintenance burden (two parallel code paths)
- A bridge layer (as_capability()) that adds complexity without value
Problem 4: No Multi-Tenant/Multi-Config Support¶
A single AgentPool instance loads one config and serves all users identically. There is no way to:
- Serve multiple configs from one server process
- Isolate tenants (different MCP servers, storage, skills per tenant)
- Dynamically load/unload configs without restart
- Route protocol requests to the correct config
Problem 5: Protocol Layer Coupled to Python¶
All protocol servers are Python-only, in-process. No language-agnostic boundary exists for polyglot deployments.
Impact of Inaction¶
- Architecture remains undocumented; every new contributor faces the same learning curve
- Multi-tenant support requires ad-hoc hacks rather than structural design
- Dual abstraction layers continue to diverge
- Each new execution mode requires a new entry point
- Protocol layer cannot be replaced or polyglot-deployed
Goals & Non-Goals¶
Goals (In Scope)¶
- Define six top-level layers with clear boundaries and interfaces
- Establish the conceptual model that all future sub-RFCs align with
- Map existing concepts (AgentPool, ResourceProvider, Agent, Protocol) to the new layers
- Define multi-tenant architecture via ConfigRegistry + HostRegistry + RunScope
- Establish migration direction from current architecture to v1.0
- Use mermaid diagrams to make relationships visually clear
Non-Goals (Out of Scope)¶
- Detailed layer design: Each layer gets its own deep-dive RFC
- Implementation code: This RFC defines interfaces and relationships, not implementations
- Migration plan: Deferred to individual sub-RFCs
- RFC-0042 replacement: This RFC complements RFC-0042 (lifecycle dimensions) by providing the top-level context RFC-0042 sits within
- Specific protocol implementations: ACP/OpenCode/AG-UI details are separate efforts
- Storage schema design: StorageProvider/Journal/SnapshotStore interfaces only
Evaluation Criteria¶
| Criterion | Weight | Measurement |
|---|---|---|
| Conceptual clarity | High | A new contributor can understand the architecture from this RFC alone |
| Layer orthogonality | High | Changing one layer doesn't require changing others |
| Multi-tenant support | High | Multiple configs + tenants served from one process |
| Backward compatibility | High | Existing YAML configs and Python APIs continue to work |
| Migration feasibility | Medium | Each layer can be migrated independently |
| Alignment with pydantic-ai | High | Leverages native Capability/Toolset, not parallel abstractions |
| Diagram quality | Medium | Mermaid diagrams clearly show all key relationships |
Options Analysis¶
Option 1: Six-Layer Separation (Recommended)¶
Description: Decompose AgentPool into six orthogonal layers, each with a single responsibility:
Advantages: - Each layer is independently swappable and testable - Multi-tenancy is a natural composition (ConfigRegistry holds multiple configs, HostRegistry holds multiple Hosts) - Aligns with K8s CRD→Controller pattern (proven at scale) - pydantic-ai Capability/Toolset fits cleanly as the Agent Core's extension mechanism - RFC-0042's six dimensions sit entirely within the RunLoop layer
Disadvantages: - 6 new abstractions to learn (but each is well-understood from existing frameworks) - Requires significant refactoring from current monolithic AgentPool - AgentHost and AgentFactory boundaries may shift during implementation
Option 2: Monolithic AgentPool with Mode Parameter¶
Description: Keep AgentPool as a single class, add mode parameter for different behaviors.
Advantages: - Simpler mental model (one class) - Less refactoring required - Easy to understand for new users
Disadvantages: - Not orthogonal: changing one concern requires modifying AgentPool - Multi-tenancy requires multiple AgentPool instances (heavy) - Violates open/closed principle - Does not align with cross-framework research
Option 3: Plugin-Based Architecture¶
Description: AgentPool has a plugin system; each concern (config, runtime, protocol) is a plugin.
Advantages: - Extensible without code changes - Community can contribute plugins
Disadvantages: - Plugin discovery and lifecycle adds complexity - Too loose: no clear contract between plugins - Overlaps with pydantic-ai Capability system - Does not solve multi-tenancy
Comparison Matrix¶
| Criterion | Option 1 (6 Layers) | Option 2 (Mode Param) | Option 3 (Plugins) |
|---|---|---|---|
| Conceptual clarity | ✅ Clear | ⚠️ One class, many modes | ❌ Too abstract |
| Layer orthogonality | ✅ Full | ❌ Coupled | ⚠️ Partial |
| Multi-tenant support | ✅ Natural | ❌ Multiple instances | ❌ No improvement |
| Backward compatibility | ✅ Wrappers | ✅ Add mode param | ⚠️ Plugin migration |
| Migration feasibility | ✅ Phased | ✅ Simple | ⚠️ Plugin system first |
| pydantic-ai alignment | ✅ Native | ❌ Parallel | ⚠️ Overlaps |
Recommendation¶
Option 1: Six-Layer Separation — ConfigRegistry → AgentHost → AgentFactory → RunLoop → Agent Core → ProtocolServer.
This is the only option that achieves full orthogonality while supporting multi-tenancy structurally rather than as an afterthought. The six-layer model maps cleanly to proven patterns (K8s CRD→Controller, Temporal Workflow→Worker, pydantic-ai Agent→Capability) and provides a natural home for RFC-0042's lifecycle dimensions within the RunLoop layer.
Key design decisions:
- ConfigRegistry is the single source of truth — named, versioned configs. All runtime objects derive from a registered config.
- AgentHost is the infrastructure boundary — one per (config_id, tenant_id). Owns MCP servers, storage connections, skill registries.
- AgentFactory is the compilation step — transforms immutable manifest + host into runnable agents. Pre-compiles graphs by default.
- RunLoop is RFC-0042's domain — the six lifecycle dimensions (TriggerSource, Journal, SnapshotStore, CommChannel, EventTransport) are injected here.
- Agent Core is pydantic-ai's domain — Capability/Toolset system is the extension mechanism. ResourceProvider is deleted.
- ProtocolServer is the polyglot boundary — translates external protocols to RunLoop operations. May be in-process or MQ-decoupled.
Technical Design¶
The Six Layers¶
The following diagram shows the complete architecture from YAML config to protocol client:
graph TB
subgraph "Layer 1: ConfigRegistry"
CR[ConfigRegistry]
CM1["Config: 'default' v3"]
CM2["Config: 'team-a' v1"]
CM3["Config: 'team-b' v2"]
CR --> CM1
CR --> CM2
CR --> CM3
end
subgraph "Layer 2: AgentHost Registry"
HR[HostRegistry]
H1["Host: 'default' / 'tenant-1'"]
H2["Host: 'team-a' / 'tenant-1'"]
H3["Host: 'team-b' / 'tenant-2'"]
HR --> H1
HR --> H2
HR --> H3
end
subgraph "Layer 3: AgentFactory"
AF1["Factory / compiles manifest to agents"]
AF2["Factory / compiles manifest to agents"]
AF3["Factory / compiles manifest to agents"]
end
subgraph "Layer 4: RunLoop Pool"
RL1["RunLoop / session-001"]
RL2["RunLoop / session-002"]
RL3["RunLoop / session-003"]
end
subgraph "Layer 5: Agent Core"
AC1["NativeAgent / & Capabilities"]
AC2["ACPAgent / & Capabilities"]
AC3["NativeAgent / & Capabilities"]
end
subgraph "Layer 6: Protocol Servers"
PS1[ACP Server]
PS2[OpenCode Server]
PS3[AG-UI Server]
end
CR -.->|1. load config| HR
HR -.->|2. create host| AF1
HR -.->|3. create host| AF2
HR -.->|4. create host| AF3
AF1 -.->|5. compile to agents| AC1
AF2 -.->|6. compile to agents| AC2
AF3 -.->|7. compile to agents| AC3
AC1 -.->|8. create turn| RL1
AC2 -.->|9. create turn| RL2
AC3 -.->|10. create turn| RL3
PS1 -.->|11. route by RunScope| HR
PS2 -.->|12. route by RunScope| HR
PS3 -.->|13. route by RunScope| HR
style CR fill:#e1f5fe
style HR fill:#f3e5f5
style AF1 fill:#e8f5e9
style AF2 fill:#e8f5e9
style AF3 fill:#e8f5e9
style RL1 fill:#fff3e0
style RL2 fill:#fff3e0
style RL3 fill:#fff3e0
style AC1 fill:#fce4ec
style AC2 fill:#fce4ec
style AC3 fill:#fce4ec
style PS1 fill:#f5f5f5
style PS2 fill:#f5f5f5
style PS3 fill:#f5f5f5
Layer 1: ConfigRegistry¶
Responsibility: Store, version, and serve YAML configurations.
graph LR
subgraph ConfigRegistry
Store["ConfigStore / in-memory / file / etcd"]
Reg[ConfigRegistry]
V1["Config 'default' v1"]
V2["Config 'default' v2"]
V3["Config 'default' v3"]
Current["Config 'default' CURRENT = v3"]
Store --> V1
Store --> V2
Store --> V3
V3 --> Current
end
YAML1["agents.yml (file)"] -->|load| Store
YAML2["agents.yml (HTTP)"] -->|load| Store
PY["config.py (programmatic)"] -->|load| Store
Reg -->|get(config_id='default')| Current
Reg -->|list()| List["['default', 'team-a', 'team-b']"]
Reg -->|watch()| Notify["change notifications"]
style Store fill:#e1f5fe
style Reg fill:#e1f5fe
style Current fill:#b3e5fc
Interface:
class ConfigRegistry:
async def register(self, config_id: str, manifest: AgentsManifest) -> ConfigVersion
async def get(self, config_id: str) -> AgentManifest
async def list(self) -> list[str]
async def watch(self, config_id: str) -> AsyncIterator[ConfigChange]
async def unregister(self, config_id: str) -> None
Key properties: - Configs are immutable once registered; updates create new versions - ConfigRegistry is the single source of truth — all runtime objects derive from a registered config - Config changes emit notifications → AgentHosts can hot-reload - Backed by ConfigStore (in-memory for dev, file for standalone, etcd/DB for production)
Analogy: K8s etcd — stores CRDs, emits watch events, versioned.
Config Split: HostConfig vs AgentManifest¶
The root config AgentsManifest splits into two sections with different owners, change costs, and multi-tenancy implications:
graph TB
subgraph "AgentsManifest (root config)"
subgraph "host: HostConfig"
MCP["mcp_servers / definitions"]
ST["storage / DB connections"]
OBS["observability / logging"]
SK["skills / discovery paths"]
PROTO["protocol / server settings"]
MODELS["models / providers, aliases, defaults"]
end
subgraph "agents + teams + graph: AgentManifest"
AG["agents / definitions + refs"]
TM["teams / topology"]
GR["graph / workflow"]
RS["responses / schemas"]
end
end
Host["AgentHost / reads HostConfig"] -.->|infrastructure| MCP
Host -.-> ST
Host -.-> OBS
Host -.-> SK
Host -.-> PROTO
Host -.-> MODELS
Factory["AgentFactory / reads AgentManifest"] -.->|agent defs| AG
Factory -.-> TM
Factory -.-> GR
Factory -.-> RS
style Host fill:#f3e5f5
style Factory fill:#e8f5e9
Separation rationale:
| Dimension | HostConfig → AgentHost | AgentManifest → AgentFactory |
|---|---|---|
| Manages | Infrastructure (processes, connections, discovery) | Agent definitions (models, capabilities, topology) |
| Change cost | High (restart MCP processes, reconnect DB) | Low (recompile agents, diff-based) |
| Multi-tenant | Per-tenant different (different MCP, storage) | Can be shared across tenants |
| Multi-config | Can be shared across configs | Per-config different |
| Hot-reload | AgentHost.reload() — rebuild infrastructure |
AgentFactory.recompile() — rebuild agents |
Key design: reference, not definition. MCP server definitions live in host.mcp_servers (infrastructure). Agents reference them by name (- mcp: filesystem) in their capability list. This separates "what an MCP server IS" from "which agent uses WHICH server":
# === Host Configuration (Layer 1-2: AgentHost) ===
host:
mcp_servers:
filesystem: # DEFINITION: command, args, lifecycle
command: "uvx mcp-server-filesystem"
args: ["/tmp"]
git:
command: "uvx mcp-server-git"
storage:
provider: sql
database: "sqlite:///agentpool.db"
observability:
logfire: {enabled: true}
skills:
discovery_paths:
- "~/.claude/skills"
- ".claude/skills"
protocol:
acp: {port: 8080}
opencode: {port: 8081}
models:
providers: ... # see Model Configuration below
aliases: ... # see Model Configuration below
defaults: ... # see Model Configuration below
# === Agent Manifest (Layer 3: AgentFactory) ===
agents:
coder:
type: native
model: smart # REFERENCE: alias, resolved by host
capabilities:
- mcp: filesystem # REFERENCE: name, resolved by host
- mcp: git
- skill: example-skill
- subagent: true
reviewer:
type: native
model: claude # different alias
capabilities:
- mcp: filesystem # same server, shared connection
teams:
review_pipeline:
mode: sequential
members: [coder, reviewer]
Config model:
class AgentsManifest(BaseModel):
"""Root config. Contains both host and agent sections."""
host: HostConfig = HostConfig()
agents: dict[str, AgentConfig] = {}
teams: dict[str, TeamConfig] = {}
graph: GraphConfig | None = None
responses: dict[str, ResponseSchema] = {}
class HostConfig(BaseModel):
"""Infrastructure config. Maps to AgentHost (Layer 2).
Changes trigger AgentHost.reload() — expensive:
restart MCP processes, reconnect storage, re-scan skills.
"""
mcp_servers: dict[str, MCPServerConfig] = {}
storage: StorageConfig = StorageConfig()
observability: ObservabilityConfig = ObservabilityConfig()
skills: SkillsConfig = SkillsConfig()
protocol: ProtocolConfig = ProtocolConfig()
models: ModelsConfig = ModelsConfig()
class AgentConfig(BaseModel):
"""Agent definition. Maps to AgentFactory.compile() (Layer 3).
Changes trigger AgentFactory.recompile() — cheap:
diff old vs new, recreate only affected agents.
"""
type: Literal["native", "acp", "file"]
model: str | ModelSpec | None = None # alias or full spec, None = host default
capabilities: list[CapabilityRef] = [] # references, not definitions
# ...
Backward compatibility: v0.x flat config (no host: section) auto-migrates. mcp_servers, storage, observability at root level are extracted into HostConfig by a model validator.
Implementation Timing
The config split (HostConfig vs AgentManifest) is deferred to Phase 3 of the implementation plan. Phase 1a extracts only the runtime structures (HostContext dataclass + AgentFactory) from AgentPool without modifying the config model. The flat AgentsManifest works throughout Phase 1 and Phase 2 — AgentFactory.compile() reads agent/team/graph sections from the flat manifest. When Phase 3 introduces multi-config versioning, the host: section is added as an optional field with auto-migration. See Implementation Plan for details.
Multi-tenancy value: Different tenants can share the same AgentManifest with different HostConfig (different MCP servers, storage, API keys). Different configs can share the same HostConfig with different AgentManifest (same infrastructure, different agents).
Model Configuration¶
Model config has three layers of concern that must not be mixed:
| Layer | Concern | Location | Example |
|---|---|---|---|
| Provider config | API keys, base URLs, proxies, timeouts | host.models.providers |
openai: {api_key: ...} |
| Model aliases | Name resolution, fallback chains, defaults | host.models.aliases |
smart: openai:gpt-4o |
| Agent selection | Which alias to use, temperature, max_tokens | agent.model, agent.temperature |
model: smart, temperature: 0.2 |
host:
models:
# === Provider config (infrastructure: secrets, connections) ===
providers:
openai:
api_key: ${OPENAI_API_KEY}
base_url: https://api.openai.com/v1
anthropic:
api_key: ${ANTHROPIC_API_KEY}
azure:
api_key: ${AZURE_API_KEY}
base_url: https://myazure.openai.azure.com
api_version: "2024-06-01"
# === Model aliases (shared definitions: change once, affects all) ===
aliases:
fast: openai:gpt-4o-mini
smart: openai:gpt-4o
claude: anthropic:claude-sonnet-4-0
resilient:
type: fallback
models: [openai:gpt-4o, anthropic:claude-sonnet-4-0]
# === Defaults (used when agent doesn't specify) ===
defaults:
model: smart
temperature: 0.7
max_tokens: 4096
agents:
coder:
model: smart # alias → resolved by host
temperature: 0.2 # override default
reviewer:
model: claude # different alias
max_tokens: 8192
simple_task:
# no model specified → uses host.models.defaults.model (smart)
# no temperature → uses defaults.temperature (0.7)
Resolution flow:
graph TB
AC["AgentConfig.model = 'smart' / alias or full spec"]
AC --> MR["ModelRegistry.resolve / host.models.aliases"]
MR --> MS["ModelSpec / provider='openai' / model_name='gpt-4o'"]
MS --> MC["ModelCache.get_model / host.model_cache"]
MC --> PC["ProviderConfig / api_key, base_url"]
PC --> Model["pydantic-ai Model instance / with connection pool"]
Model --> Agent["NativeAgent / injected by AgentFactory"]
style MR fill:#fff9c4
style MC fill:#e8f5e9
style Model fill:#fce4ec
AgentFactory is the resolution point — it reads both AgentConfig and HostConfig to produce a pydantic-ai Model instance:
class AgentFactory:
async def compile(self, manifest, host_context):
for name, agent_config in manifest.agents.items():
# 1. Resolve model alias → full spec
model_spec = host_context.model_registry.resolve(
agent_config.model or host_context.model_registry.default_model
)
# 2. Get/create Model instance (cached, shared)
model = await host_context.model_cache.get_model(model_spec)
# 3. Merge parameters: agent override > host default
params = {
"temperature": agent_config.temperature
or host_context.model_registry.default_temperature,
"max_tokens": agent_config.max_tokens
or host_context.model_registry.default_max_tokens,
}
# 4. Construct agent
agent = NativeAgent(model=model, model_params=params, ...)
ModelCache — connection sharing:
Model instances are expensive to create (HTTP client init, connection pooling). Same-host agents using the same model share a single Model instance:
class ModelCache:
"""Lazy-created, shared pydantic-ai Model instances.
Owned by AgentHost (Layer 2). AgentFactory (Layer 3) calls get_model().
Keyed by (provider, model_name) — not by alias.
Two agents using aliases 'smart' and 'gpt4' that both resolve to
'openai:gpt-4o' share the same Model instance.
"""
def __init__(self, providers: dict[str, ProviderConfig]):
self._providers = providers
self._cache: dict[tuple[str, str], Model] = {}
async def get_model(self, spec: ModelSpec) -> Model:
key = (spec.provider, spec.model_name)
if key not in self._cache:
provider_config = self._providers[spec.provider]
self._cache[key] = self._construct(spec, provider_config)
return self._cache[key]
Fallback models: Fallback is model composition, not a separate provider. AgentFactory resolves the fallback chain from the alias, then uses ModelCache for each individual model:
class AgentFactory:
async def _resolve_model(self, spec, host_context):
if spec.type == "fallback":
models = [
await host_context.model_cache.get_model(s)
for s in spec.models
]
return FallbackModel(models)
return await host_context.model_cache.get_model(spec)
Multi-tenant model isolation: Different tenants can have different API keys or providers while sharing the same AgentManifest. Agent model: smart resolves differently per tenant:
# Tenant A: direct OpenAI
host:
models:
providers:
openai: {api_key: ${TENANT_A_OPENAI_KEY}}
# Tenant B: Azure OpenAI (same model name, different endpoint)
host:
models:
providers:
openai: {api_key: ${TENANT_B_AZURE_KEY}, base_url: https://tenant-b.openai.azure.com}
# Both tenants share the same AgentManifest — agent writes model: smart
Dynamic model selection (runtime): If an agent needs to switch models based on task complexity, this is runtime behavior handled by a Capability, not config:
class ModelRoutingCapability(AbstractCapability):
"""Dynamically selects model based on task complexity.
NOT config — it's runtime behavior. Agent still has a default
model in config; this capability can override it per-turn.
"""
async def before_model_request(self, ctx, prompt):
complexity = assess_complexity(prompt)
if complexity == "high":
ctx.deps.session.override_model = "smart"
else:
ctx.deps.session.override_model = "fast"
Backward compatibility: v0.x model: "openai:gpt-4o" (direct string) still works. ModelRegistry detects strings that aren't aliases and treats them as provider:model specs directly.
Layer 2: AgentHost (via HostRegistry)¶
Responsibility: Own mutable runtime infrastructure for one (config_id, tenant_id) pair.
graph TB
subgraph AgentHost
AH["AgentHost / config_id='default' / tenant_id='tenant-1'"]
subgraph "Infrastructure"
MCP["MCP Server Pool / subprocess lifecycle"]
SK["Skills Registry / SKILL.md discovery"]
ST["Storage Manager / DB connections"]
CC["Capability Cache / shared capability instances"]
SR["Skill Resource Provider / URI resolver"]
end
AH --> MCP
AH --> SK
AH --> ST
AH --> CC
AH --> SR
end
Manifest["AgentManifest / (immutable config)"] -->|compiled into| AH
Config["ConfigRegistry"] -->|provides manifest| Manifest
AH -->|provides context| HostCtx["HostContext / (dataclass: deps only)"]
style AH fill:#f3e5f5
style MCP fill:#fce4ec
style SK fill:#fce4ec
style ST fill:#fce4ec
style CC fill:#fce4ec
style SR fill:#fce4ec
style HostCtx fill:#e8f5e9
Interface:
class AgentHost:
config_id: str
tenant_id: str
manifest: AgentManifest # immutable reference
# Infrastructure (mutable, shared across all agents in this host)
mcp_servers: MCPServerPool
skills_registry: SkillsRegistry
storage: StorageManager
capability_cache: CapabilityCache
prompt_manager: PromptManager
model_registry: ModelRegistry # alias resolution + defaults
model_cache: ModelCache # shared pydantic-ai Model instances
# NOTE: AgentFactory is NOT owned by AgentHost.
# It is a standalone service that receives (manifest, host_context) as parameters.
# See Layer 3 for details.
async def start(self) -> None
async def stop(self) -> None
async def reload(self, new_manifest: AgentManifest) -> None # hot-reload
def get_context(self) -> HostContext # produce dependency injection bundle
HostContext — the dependency injection bundle that replaces MessageNode.agent_pool:
@dataclass(frozen=True)
class HostContext:
"""Carries only what agents need — not the full AgentHost.
This replaces the v0.x `MessageNode.agent_pool` backdoor where every agent
could reach the entire pool (storage, mcp, skills, connection_registry).
HostContext carries only specific dependency handles, enforcing layer
boundaries and enabling tenant isolation.
"""
mcp: MCPServerPool
storage: StorageManager
skills_registry: SkillsRegistry
capability_cache: CapabilityCache
prompt_manager: PromptManager
model_registry: ModelRegistry # alias resolution
model_cache: ModelCache # shared Model instances
config_id: str
tenant_id: str
Key properties:
- One AgentHost per (config_id, tenant_id) pair — tenant isolation boundary
- Owns shared infrastructure: MCP server processes, storage connections, skill registries
- Mutable: can hot-reload when config changes (stop old agents, start new ones)
- HostRegistry manages lifecycle: create on demand, cache, evict on idle
- The AgentPool class from v0.x becomes a thin wrapper around a single AgentHost
- Does NOT own AgentFactory — Factory is a standalone service (see Layer 3)
- HostContext replaces agent_pool reference — agents receive HostContext, not AgentHost, preventing backdoor access to pool-wide state
Critical migration blocker: MessageNode.agent_pool is currently a direct reference to the full AgentPool on every agent and team. This backdoor spans Layers 2-5 and must be replaced with HostContext injection before tenant isolation is meaningful. This is the single highest-priority refactor in Phase 1.
Analogy: K8s Controller — one controller per CRD instance, owns reconciler loop, manages child resources.
Layer 3: AgentFactory¶
Responsibility: Compile an AgentManifest + AgentHost into runnable agents, teams, and graphs.
graph TB
subgraph AgentFactory
AF[AgentFactory]
subgraph "Compilation Steps"
S1["Parse agent configs from manifest"]
S2["Resolve shared deps from AgentHost"]
S3["Build capabilities (pydantic-ai native)"]
S4["Build toolsets (pydantic-ai native)"]
S5["Create agent instances (Native/ACP/File)"]
S6["Compile teams/graphs (pydantic-graph)"]
S7["Register in AgentRegistry (name to agent)"]
end
AF --> S1
S1 --> S2
S2 --> S3
S3 --> S4
S4 --> S5
S5 --> S6
S6 --> S7
end
Manifest["AgentManifest"] --> AF
HostCtx["HostContext / (from AgentHost)"] --> AF
AF --> Registry["AgentRegistry / name to MessageNode"]
Registry --> Agent1["NativeAgent 'coder' / & MCPToolset / & SubagentToolset / & SkillCapability"]
Registry --> Agent2["ACPAgent 'goose' / & MCPToolset"]
Registry --> Team1["SequentialTeam / 'review_pipeline' / (compiled graph)"]
style AF fill:#e8f5e9
style Registry fill:#e8f5e9
style Agent1 fill:#fce4ec
style Agent2 fill:#fce4ec
style Team1 fill:#fce4ec
Interface:
class AgentFactory:
"""Standalone compilation service — NOT owned by AgentHost.
Receives (manifest, host_context) as method parameters.
Maintains an internal compilation cache (read-only state) for
diff-based recompile efficiency. This is NOT 'pure function' —
caching is necessary because pydantic-graph compilation is expensive.
"""
async def compile(
self, manifest: AgentManifest, host: HostContext
) -> AgentRegistry:
"""Full compilation from manifest + host context."""
...
async def recompile(
self, new_manifest: AgentManifest, host: HostContext
) -> AgentRegistry:
"""Diff-based hot-reload. Only recreate agents whose config changed.
Uses internal cache to diff old vs new manifest.
"""
...
Key properties:
- Standalone service — not owned by AgentHost. Receives (manifest, host_context) as parameters.
- Compilation cache — maintains previous compilation results internally for diff-based recompile. This is read-only state, not mutable infrastructure. A truly stateless factory would recompile everything on every call — unacceptable for hot-reload performance.
- No I/O side effects — compilation is pure transformation: manifest + host_context → registry. Does not start MCP servers, open database connections, or write to storage.
- Pre-compiles graphs by default — teams become pydantic-graph workflows at compile time, not runtime.
- Capability-based — uses pydantic-ai's AbstractCapability and AbstractToolset natively. No ResourceProvider.
- Diff-based recompile — on config change, only affected agents are recreated.
- The factory is the only place where config → runtime mapping happens.
Design clarification: "Standalone service" means lifecycle decoupling — Factory's lifecycle is not tied to Host's. It does NOT mean "pure function with no internal state". The compilation cache is essential for performance. The distinction that matters is: Factory doesn't own infrastructure (MCP processes, storage connections) — it only transforms config + dependency handles into agent instances.
Analogy: K8s Reconcile loop — watches config changes, creates/updates/deletes runtime objects to match desired state.
Layer 4: RunLoop¶
Responsibility: Drive the idle → running → idle | done cycle for a single session.
This layer is entirely defined by RFC-0042. The six pluggable dimensions (TriggerSource, Journal, SnapshotStore, CommChannel, EventTransport) are injected into the RunLoop.
graph TB
subgraph RunLoop
RL["RunLoop / session_id='session-001'"]
subgraph "State Machine"
Idle[("idle")]
Running[("running")]
Done[("done")]
Idle -->|prompt arrives| Running
Running -->|turn complete| Idle
Idle -->|close()| Done
Running -->|close()| Done
end
subgraph "RFC-0042 Six Dimensions (injected)"
TS["TriggerSource / how prompts arrive"]
J["Journal / event persistence"]
SS["SnapshotStore / state checkpoints"]
CC["CommChannel / event delivery & feedback"]
ET["EventTransport / wire protocol"]
end
RL --> Idle
RL --> TS
RL --> J
RL --> SS
RL --> CC
RL --> ET
end
Agent["Agent Core / (from Factory)"] -->|create_turn()| RL
RL -->|turn.execute()| Agent
Scope["RunScope / config_id / tenant_id / / user_id / session_id"] --> RL
style RL fill:#fff3e0
style TS fill:#ffe0b2
style J fill:#ffe0b2
style SS fill:#ffe0b2
style CC fill:#ffe0b2
style ET fill:#ffe0b2
Key properties: - RunLoop is session-scoped — one per session, created on demand, destroyed on session end. - The six dimensions are configuration choices, not architectural decisions. - RunLoop owns SnapshotStore; CommChannel owns Journal. (RFC-0042 Revision 4.) - RunLoop is identical across all execution modes — standalone, protocol, long-running, channel wake-up. - See RFC-0042 for full design.
Layer 5: Agent Core¶
Responsibility: The agent itself — model interaction, tool execution, streaming.
graph TB
subgraph "Agent Core (pydantic-ai native)"
Agent["MessageNode / process() to ChatMessage"]
subgraph "Agent Types"
Native["NativeAgent / PydanticAI-based"]
ACP["ACPAgent / external subprocess"]
File["FileAgent / file-based prompts"]
end
subgraph "Extension Mechanism (pydantic-ai native)"
Cap1["Capability: MCP / MCPToolset"]
Cap2["Capability: Skills / SkillCapability"]
Cap3["Capability: Subagent / SubagentToolset"]
Cap4["Capability: Code Mode / WrapperToolset"]
Cap5["Capability: Web / WebFetch & WebSearch"]
Cap6["Capability: Custom / user-defined"]
end
Agent --> Native
Agent --> ACP
Agent --> File
Native --> Cap1
Native --> Cap2
Native --> Cap3
Native --> Cap4
Native --> Cap5
Native --> Cap6
end
subgraph "DELETED (v0.x)"
RP["ResourceProvider / DELETED"]
AS["as_capability() / DELETED"]
end
style Agent fill:#fce4ec
style Native fill:#f8bbd0
style ACP fill:#f8bbd0
style File fill:#f8bbd0
style Cap1 fill:#e8f5e9
style Cap2 fill:#e8f5e9
style Cap3 fill:#e8f5e9
style Cap4 fill:#e8f5e9
style Cap5 fill:#e8f5e9
style Cap6 fill:#e8f5e9
style RP fill:#ffcdd2
style AS fill:#ffcdd2
Key properties:
- MessageNode is the base abstraction — both agents and teams inherit from it.
- pydantic-ai Capability/Toolset is the only extension mechanism. ResourceProvider is deleted.
- Each agent type (Native, ACP, File) implements create_turn() which returns a Turn.
- Turns are executed by RunLoop — the agent itself doesn't drive the loop.
- Skills are a Capability (SkillCapability), not a separate system.
- Subagent delegation is a Capability (SubagentCapability with SubagentToolset).
Migration from ResourceProvider:
| v0.x ResourceProvider | v1.0 pydantic-ai Equivalent |
|---|---|
MCPResourceProvider |
MCPToolset + MCP capability |
StaticResourceProvider |
FunctionToolset (direct @tool functions) |
FilteringResourceProvider |
FilteredToolset (pydantic-ai native) |
AggregatingResourceProvider |
CombinedToolset (pydantic-ai native) |
PoolResourceProvider |
SubagentCapability + SubagentToolset (new) |
CodeModeResourceProvider |
CodeModeCapability + WrapperToolset (new) |
LocalResourceProvider |
SkillCapability (already exists) |
Dynamic change notification — replacing ResourceProvider signals:
ResourceProvider had 4 signal types (tools_changed, prompts_changed, resources_changed, skills_changed). pydantic-ai's AbstractCapability has no equivalent. Rather than building a central CapabilityRegistry (which would recreate ResourceProvider's pattern under a new name), we extend AbstractCapability itself:
class AbstractCapability:
# Existing: 40+ lifecycle hooks, middleware chain, ordering
# NEW: optional change notification — delegated to Capability, not centralized
def on_change(self) -> AsyncIterator[ChangeEvent] | None:
"""If this capability supports dynamic changes, yield ChangeEvents.
Returns None for static capabilities (default).
Only dynamic capabilities implement this:
- MCPToolset: yields when MCP server tool list changes
- SkillCapability: yields when SKILL.md files are added/removed
- Static capabilities (FunctionToolset, etc.): return None
"""
return None
AgentFactory subscribes to on_change() streams from compiled capabilities. When a change event arrives, Factory performs a local hot-swap — only the affected agent's capability is replaced, not the entire Host. This is finer-grained than the old ResourceProvider signal system, which triggered full rebuilds.
Why not a central CapabilityRegistry: A central registry that tracks all capabilities and broadcasts change events is architecturally identical to AggregatingResourceProvider + signal forwarding — the exact pattern we're deleting. Change notification should be the responsibility of the Capability that knows when its own tools change, not a central authority.
Knowledge management — future consideration:
Domain knowledge (vector retrieval, graph reasoning, episodic memory) is NOT a Layer 2 infrastructure component in v1.0. It starts as a Capability (KnowledgeCapability) at Layer 5, accessed per-agent. This follows YAGNI — don't build infrastructure abstraction until there's a proven cross-agent sharing requirement. When multiple agents demonstrably need to share the same vector store / knowledge graph, promote to Layer 2 as KnowledgeProvider alongside StorageManager. Premature abstraction risks recreating the ResourceProvider dual-abstraction problem.
ResourceSource — Orthogonal Data Abstraction¶
pydantic-ai's Capability system manages behavior (tools, hooks, instructions). But agents also need access to data — MCP resources, skill content, files, knowledge base entries. pydantic-ai has no data abstraction. This gap forces resources to be awkwardly wrapped as tools, losing semantic clarity.
ResourceSource is a new protocol orthogonal to Capability — not parallel to it. An object CAN implement both: MCPCapability provides tools (behavior, via Capability) AND resources (data, via ResourceSource). Two interfaces, two concerns, same object.
graph LR
subgraph "Behavior Axis (pydantic-ai)"
Cap["AbstractCapability"]
Cap --> Tools["Tools / actions"]
Cap --> Hooks["Hooks / lifecycle"]
Cap --> Instr["Instructions / directives"]
end
subgraph "Data Axis (new)"
RS["ResourceSource"]
RS --> List["list() / discover"]
RS --> Read["read(uri) / content"]
RS --> Exists["exists(uri) / validate"]
RS --> Change["on_change() / subscribe"]
end
MCP["MCPCapability"] -.->|implements| Cap
MCP -.->|implements| RS
Skill["SkillCapability"] -.->|implements| Cap
Skill -.->|implements| RS
style MCP fill:#e8f5e9
style Skill fill:#e8f5e9
style Cap fill:#bbdefb
style RS fill:#fff9c4
Protocol definition:
@runtime_checkable
class ResourceSource(Protocol):
"""Unified read-only data access. Orthogonal to Capability.
NOT a Capability — it's a separate concern:
- Capability = behavior (tools, hooks, instructions)
- ResourceSource = data (files, knowledge, skill content)
An object CAN implement both (e.g., MCPCapability provides tools
AND is a ResourceSource for its MCP resources).
"""
async def list(self) -> list[Resource]:
"""List all available resources from this source."""
...
async def read(self, uri: str) -> ResourceContent:
"""Read resource content by URI."""
...
async def exists(self, uri: str) -> bool:
"""Check if a resource exists."""
...
def on_change(self) -> AsyncIterator[ResourceChange] | None:
"""Subscribe to resource changes. None for static sources."""
...
@dataclass(frozen=True)
class Resource:
"""A discoverable data item."""
uri: str # e.g. "mcp://filesystem/path", "skill://my-skill"
name: str
mime_type: str
source: str # which ResourceSource provided this
@dataclass(frozen=True)
class ResourceContent:
"""Content of a read resource."""
uri: str
content: str | bytes
mime_type: str
Implementations:
| Implementation | Wraps | URI Scheme | Source of Data |
|---|---|---|---|
MCPResourceSource |
MCPCapability |
mcp://{server_name}/{path} |
MCP server resources/list + resources/read |
SkillResourceSource |
SkillCapability |
skill://{skill_name} |
SKILL.md file content |
FileResourceSource (future) |
UPath root |
file://{path} |
Local or remote filesystem |
MCPCapability implements both AbstractCapability and ResourceSource. The same object exposes tools (behavior) and resources (data) through two interfaces. This is not dual-abstraction — it's two axes on one object:
class MCPCapability(AbstractCapability, ResourceSource):
"""Owns one MCP server connection.
Two orthogonal interfaces:
- AbstractCapability: provides tools, hooks, instructions (behavior)
- ResourceSource: provides resources (data)
"""
def __init__(self, mcp_server: MCPServer):
self._server = mcp_server
# --- AbstractCapability (behavior) ---
def get_toolset(self) -> AbstractToolset:
return MCPToolset(self._server) # auto-discovers MCP tools
async def get_instructions(self) -> list[str]:
prompts = await self._server.list_prompts()
return [p.description for p in prompts]
def on_change(self) -> AsyncIterator[ChangeEvent] | None:
# Capability change: MCP tool list changed
...
# --- ResourceSource (data) ---
async def list(self) -> list[Resource]:
resources = await self._server.list_resources()
return [
Resource(
uri=f"mcp://{self._server.name}/{r.uri}",
name=r.name,
mime_type=r.mime_type,
source=self._server.name,
)
for r in resources
]
async def read(self, uri: str) -> ResourceContent:
# Strip prefix, delegate to MCP server
mcp_uri = uri.removeprefix(f"mcp://{self._server.name}/")
content = await self._server.read_resource(mcp_uri)
return ResourceContent(uri=uri, content=content, mime_type="text/plain")
async def exists(self, uri: str) -> bool:
resources = await self._server.list_resources()
mcp_uri = uri.removeprefix(f"mcp://{self._server.name}/")
return any(r.uri == mcp_uri for r in resources)
Composition — AggregatedResourceSource:
AgentFactory collects all ResourceSource implementations from the agent's compiled capabilities and composes them into a unified interface:
class AggregatedResourceSource(ResourceSource):
"""Unified resource access across all sources.
Created by AgentFactory at compile time — scoped to the agent's
authorized resources. NOT a global registry.
"""
def __init__(self, sources: list[ResourceSource]):
self._sources = sources
async def list(self) -> list[Resource]:
all_resources = []
for source in self._sources:
all_resources.extend(await source.list())
return all_resources
async def exists(self, uri: str) -> bool:
return any(await s.exists(uri) for s in self._sources)
async def read(self, uri: str) -> ResourceContent:
for source in self._sources:
if await source.exists(uri):
return await source.read(uri)
raise ResourceNotFound(uri)
Factory wiring:
class AgentFactory:
async def compile(self, manifest, host_context):
mcp_cap = MCPCapability(server) # is-a Capability AND ResourceSource
skill_cap = SkillCapability(registry) # is-a Capability AND ResourceSource
# Collect ResourceSources from capabilities that implement it
resource_sources = [
rs for rs in [mcp_cap, skill_cap]
if isinstance(rs, ResourceSource)
]
aggregated = AggregatedResourceSource(resource_sources)
# AgentContext receives the aggregated source
agent = NativeAgent(
capabilities=[mcp_cap, skill_cap],
resource_source=aggregated,
)
AgentContext update:
@dataclass(frozen=True)
class AgentContext:
# ... existing fields (delegation, session, scope) ...
# Resource access (compile-time scoped, read-only)
resources: ResourceSource | None = None
# None if agent has no resource sources (e.g., pure computation agent)
Usage scenarios:
# 1. URI validation in capability hook (cross-capability query)
class URIValidationCapability(AbstractCapability):
async def after_model_response(
self, ctx: RunContext[AgentContext], response: str
) -> str:
if ctx.deps.resources is None:
return response
for uri in extract_uris(response):
if not await ctx.deps.resources.exists(uri):
response = response.replace(uri, f"{uri} [INVALID]")
return response
# 2. Context injection (RAG-like)
class ContextInjectionCapability(AbstractCapability):
async def before_model_request(
self, ctx: RunContext[AgentContext], prompt: str
) -> str:
if ctx.deps.resources is None:
return prompt
all_resources = await ctx.deps.resources.list()
relevant = [r for r in all_resources if matches(prompt, r.name)]
if relevant:
contents = [await ctx.deps.resources.read(r.uri) for r in relevant[:3]]
context_block = "\n".join(c.content for c in contents)
return f"Context:\n{context_block}\n\n{prompt}"
return prompt
# 3. Tool that searches resources
@tool
async def search_resources(
ctx: RunContext[AgentContext], query: str
) -> str:
"""Search available resources."""
if ctx.deps.resources is None:
return "No resources available"
resources = await ctx.deps.resources.list()
matched = [r for r in resources if query.lower() in r.name.lower()]
return "\n".join(f"- {r.uri}: {r.name}" for r in matched)
ResourceSource vs deleted ResourceProvider:
| Dimension | ResourceProvider (v0.x, DELETED) | ResourceSource (proposed) |
|---|---|---|
| What it manages | Tools + prompts + resources (all) | Only resources (data) |
| Relationship to Capability | Parallel (duplicates, needs as_capability() bridge) | Orthogonal (data vs behavior, same object implements both) |
| Construction | Runtime registration + signal broadcast | Compile-time composition by Factory |
| Scope | Global (pool-level) | Per-agent (compile-time scoped) |
| Change notification | Central signal system (4 signal types) | Per-source on_change() (delegated, not centralized) |
| pydantic-ai overlap | YES (tool management duplicated) | NO (pydantic-ai has no data abstraction) |
Implementation strategy (YAGNI incremental):
- Define
ResourceSource+Resource+ResourceContentprotocols — zero cost - Implement
ResourceSourceonMCPCapability— MCP resources no longer伪装 as tools - Implement
ResourceSourceonSkillCapability— skill content queryable as data - Build
AggregatedResourceSourcewhen 2+ implementations exist and cross-source queries are needed AgentContext.resourcesdefaults toNone— only injected when agent has ResourceSource capabilitiesFileResourceSource,KnowledgeResourceSourceetc. — when concrete use cases emerge
Layer 6: ProtocolServer¶
Responsibility: Translate between external wire protocols and internal RunLoop operations.
graph TB
subgraph "Protocol Layer"
subgraph "In-Process (Python)"
ACP["ACP Server / serve-acp"]
OC["OpenCode Server / serve-opencode"]
AGUI["AG-UI Server / serve-agui"]
OAI["OpenAI API Server / serve-api"]
MCP_S["MCP Server / serve-mcp"]
end
subgraph "Remote (Language-Agnostic, future)"
MQ["MessageQueue Transport / via EventTransport"]
RS["Rust/Go/TS Server / consumes EventEnvelopes"]
end
end
subgraph "Protocol Request Flow"
Client["Protocol Client / Zed / OpenCode TUI / curl"]
-->|initialize(config_id, tenant_id)| Server[ProtocolServer]
Server -->|extract RunScope| HR[HostRegistry]
HR -->|get/create Host| Host[AgentHost]
Host -->|get Factory| Factory[AgentFactory]
Factory -->|get agent| Agent[Agent Core]
Server -->|create RunLoop| RL[RunLoop]
RL -->|run(session_id, prompt)| Agent
end
style ACP fill:#f5f5f5
style OC fill:#f5f5f5
style AGUI fill:#f5f5f5
style OAI fill:#f5f5f5
style MCP_S fill:#f5f5f5
style MQ fill:#e1f5fe
style RS fill:#e1f5fe
Key properties:
- ProtocolServer is a thin translation layer — it does not own agents or state.
- Each protocol initialize call carries a RunScope (config_id, tenant_id, user_id).
- RunScope routes to the correct AgentHost via HostRegistry.
- ProtocolServer creates a RunLoop per session, injects appropriate dimensions.
- Future: EventTransport + EventEnvelope enables language-agnostic protocol servers via message queue.
RunScope: The Request Router¶
RunScope is the cross-cutting context that flows through all layers:
graph LR
subgraph "RunScope"
RS["RunScope / config_id / tenant_id / / user_id / session_id"]
end
RS -->|Layer 1: selects config| CR[ConfigRegistry]
RS -->|Layer 2: selects host| HR[HostRegistry]
RS -->|Layer 3: scopes factory| AF[AgentFactory]
RS -->|Layer 4: scopes runloop| RL[RunLoop]
RS -->|Layer 5: scopes agent| AC[Agent Core]
RS -->|Layer 6: scopes session| PS[ProtocolServer]
style RS fill:#fff9c4
Isolation levels:
| Level | Scope | Isolation |
|---|---|---|
| Config-level | config_id |
Different agent definitions, teams, MCP servers |
| Tenant-level | config_id + tenant_id |
Different infrastructure (MCP processes, storage, skills) |
| User-level | config_id + tenant_id + user_id |
Different sessions, interaction history |
| Session-level | session_id |
Different RunLoop, message queue, Turn state |
Complete Request Flow¶
The following sequence diagram shows a typical protocol request from client to response:
sequenceDiagram
participant C as Client (Zed)
participant PS as ACP Server
participant HR as HostRegistry
participant H as AgentHost
participant F as AgentFactory
participant A as Agent Core
participant RL as RunLoop
participant CC as CommChannel
C->>PS: initialize(config_id='default', tenant_id='tenant-1')
PS->>HR: get_or_create_host('default', 'tenant-1')
HR->>H: start() [MCP servers, storage, skills]
H-->>PS: host ready
C->>PS: session/prompt(session_id='sess-1', 'Write a function')
PS->>H: get_agent('coder')
H->>F: registry['coder']
F-->>PS: Agent Core
PS->>RL: create RunLoop(agent, session_id='sess-1')
RL->>CC: attach (ProtocolChannel)
RL->>RL: start() [resume from snapshot if exists]
RL->>A: create_turn(prompt='Write a function')
loop Turn execution
A-->>RL: event: PartDeltaEvent
RL->>CC: publish(event) [journal.append()]
CC-->>C: stream delta
A-->>RL: event: ToolCallStartEvent
RL->>CC: publish(event) [journal.append()]
CC-->>C: tool call start
A-->>RL: event: ToolCallCompleteEvent
RL->>CC: publish(event) [journal.upsert()]
CC-->>C: tool call complete
end
A-->>RL: turn complete
RL->>RL: snapshot_store.save() [checkpoint]
RL->>CC: publish(StateUpdate(idle))
CC-->>C: session idle
Storage Architecture¶
Three separate persistence layers, each with a distinct purpose:
graph TB
subgraph "Persistence Layers"
subgraph "StorageProvider (Analytical)"
SP[StorageManager]
DB[(SQL Database)]
SP --> DB
SP -->|Interaction history| T1["messages / sessions / / projects / commands"]
SP -->|Query pattern| Q1["relational queries"]
SP -->|Retention| R1["permanent"]
SP -->|Owner| O1["AgentHost"]
end
subgraph "Journal (Event Log)"
J[Journal]
WAL[(WAL / Event Log)]
J --> WAL
J -->|Event persistence| T2["deltas / tool calls / / state updates"]
J -->|Query pattern| Q2["append & replay"]
J -->|Retention| R2["compactible"]
J -->|Owner| O2["CommChannel"]
end
subgraph "SnapshotStore (Checkpoint)"
SS[SnapshotStore]
CKPT[(Checkpoint Store)]
SS --> CKPT
SS -->|State at Turn boundaries| T3["RunState, turn results"]
SS -->|Query pattern| Q3["save/load at boundaries"]
SS -->|Retention| R3["turn-scoped"]
SS -->|Owner| O3["RunLoop"]
end
end
style SP fill:#e8f5e9
style J fill:#fff3e0
style SS fill:#f3e5f5
Database analogy: - StorageProvider = Database tables (permanent, queryable, relational) - Journal = WAL / transaction log (append-only, replayable, compactible) - SnapshotStore = Checkpoint file (periodic state image, fast recovery)
These three are complementary, not conflicting. They serve different access patterns, have different owners, and use different retention strategies. Keep separate in v1.0.
Tiered consistency model:
Journal is the source of truth for crash recovery, but NOT all events require synchronous journaling. A blanket "journal every event before delivery" policy creates unacceptable write amplification for high-frequency streaming deltas. Instead, consistency is tiered by event importance:
| Event Type | Journal | StorageProvider | SnapshotStore | Consistency |
|---|---|---|---|---|
| Turn-level (StateUpdate, StreamComplete) | Synchronous | Synchronous | Save after Turn | Strong — Turn not complete until both write |
| Tool execution records | Synchronous | Async projection | — | Journal authoritative |
| Delta-level (PartDelta, ToolCallProgress) | Async batch | Not written | — | Eventual — lost on crash, acceptable |
| Session metadata (title, summary) | — | Synchronous | — | StorageProvider authoritative |
Turn commit protocol:
1. CommChannel.journal.append(turn_complete_event) ← MUST succeed, else Turn not complete
2. RunLoop.snapshot_store.save(state) ← Failure tolerated: next recovery replays from Journal
3. StorageProvider.write(turn_result) ← Synchronous for turn-level; async for deltas
Conflict resolution: On crash recovery, Journal is authoritative. If Journal has entries but SnapshotStore has no turn_result → Turn was in-flight, replay events to consumer. If StorageProvider is behind Journal → re-project from Journal replay. StorageProvider never leads Journal — it is a read model, not a write-ahead store.
Why not "Journal as absolute truth for everything": Delta events (PartDelta — streaming text chunks) can fire dozens per second. Synchronous journaling of every delta would add ~1-5ms latency per token, making streaming unresponsive. Deltas are ephemeral — losing the last few tokens of an interrupted stream on crash is acceptable. Turn-level events (completion, state transitions) are NOT ephemeral and must be strongly consistent.
Package Structure (v1.0)¶
The proposed package structure maps to the six layers:
graph TB
subgraph "v1.0 Package Structure"
subgraph "Layer 1: Config"
CF["agentwolf_config/ / AgentsManifest / AgentConfig / / TeamConfig etc."]
end
subgraph "Layer 2: Host"
HT["agentwolf_host/ / AgentHost / HostRegistry / / ConfigRegistry / ConfigStore"]
end
subgraph "Layer 3: Factory"
FT["agentwolf_factory/ / AgentFactory / AgentRegistry / / GraphBuilder / CapabilityResolver"]
end
subgraph "Layer 4: RunLoop"
RL_pkg["agentwolf_loop/ / RunLoop / Turn / / RFC-0042 dimensions / / TriggerSource / Journal / / SnapshotStore / CommChannel / / EventTransport"]
end
subgraph "Layer 5: Agent Core"
AC_pkg["agentwolf_agents/ / MessageNode / NativeAgent / / ACPAgent / FileAgent"]
end
subgraph "Layer 6: Protocol"
PS_pkg["agentwolf_server/ / ACP / OpenCode / AG-UI / / OpenAI API / MCP servers"]
end
subgraph "Cross-cutting"
TS_pkg["agentwolf_toolsets/ / MCPToolset / SubagentToolset / / SkillCapability etc."]
ST_pkg["agentwolf_storage/ / StorageProvider / SQLProvider"]
CLI_pkg["agentwolf_cli/ / CLI commands"]
PR_pkg["agentwolf_prompts/ / Prompt templates"]
end
end
CF --> HT
HT --> FT
FT --> AC_pkg
AC_pkg --> RL_pkg
RL_pkg --> PS_pkg
TS_pkg -.->|capabilities for| AC_pkg
ST_pkg -.->|storage for| HT
CLI_pkg -.->|commands for| PS_pkg
PR_pkg -.->|templates for| FT
style CF fill:#e1f5fe
style HT fill:#f3e5f5
style FT fill:#e8f5e9
style RL_pkg fill:#fff3e0
style AC_pkg fill:#fce4ec
style PS_pkg fill:#f5f5f5
Relationship to RFC-0042¶
RFC-0042 (Unified Lifecycle Architecture) sits entirely within Layer 4 (RunLoop):
graph TB
subgraph "RFC-0050 Top-Level Architecture"
L1["Layer 1: ConfigRegistry"]
L2["Layer 2: AgentHost"]
L3["Layer 3: AgentFactory"]
L4["Layer 4: RunLoop"]
L5["Layer 5: Agent Core"]
L6["Layer 6: ProtocolServer"]
L1 --> L2 --> L3 --> L5 --> L4 --> L6
end
subgraph "RFC-0042 Scope (Layer 4 internals)"
RL[RunLoop]
TS[TriggerSource]
J[Journal]
SS[SnapshotStore]
CC[CommChannel]
ET[EventTransport]
RL --> TS
RL --> J
RL --> SS
RL --> CC
RL --> ET
end
L4 -.->|contains| RL
style L4 fill:#fff3e0
style RL fill:#ffe0b2
style L1 fill:#e1f5fe
style L2 fill:#f3e5f5
style L3 fill:#e8f5e9
style L5 fill:#fce4ec
style L6 fill:#f5f5f5
RFC-0042 defines the six pluggable dimensions inside the RunLoop. RFC-0050 defines the six layers around the RunLoop. Together, they form a complete 12-dimension architecture:
| Layer (RFC-0050) | Dimension (RFC-0042) |
|---|---|
| 1. ConfigRegistry | — |
| 2. AgentHost | — |
| 3. AgentFactory | — |
| 4. RunLoop | TriggerSource, Journal, SnapshotStore, CommChannel, EventTransport |
| 5. Agent Core | — (extension via pydantic-ai Capability) |
| 6. ProtocolServer | — (uses CommChannel + EventTransport) |
Multi-Tenant Architecture¶
The six-layer model enables multi-tenancy through registries at each layer:
graph TB
subgraph "Single Process, Multiple Tenants"
subgraph "Protocol Layer"
ACP["ACP Server / :3000"]
OC["OpenCode Server / :3001"]
end
subgraph "Host Registry"
HR[HostRegistry]
H1["Host: 'default' / 'tenant-1' / MCP servers, storage, skills"]
H2["Host: 'default' / 'tenant-2' / MCP servers, storage, skills"]
H3["Host: 'team-a' / 'tenant-1' / MCP servers, storage, skills"]
HR --> H1
HR --> H2
HR --> H3
end
subgraph "Config Registry"
CR[ConfigRegistry]
C1["Config: 'default' v3"]
C2["Config: 'team-a' v1"]
CR --> C1
CR --> C2
end
end
Client1["Tenant-1 User / (config=default)"] -->|initialize(default, tenant-1)| ACP
Client2["Tenant-2 User / (config=default)"] -->|initialize(default, tenant-2)| ACP
Client3["Tenant-1 User / (config=team-a)"] -->|initialize(team-a, tenant-1)| OC
ACP --> HR
OC --> HR
HR -.-> CR
style HR fill:#f3e5f5
style CR fill:#e1f5fe
style H1 fill:#e8f5e9
style H2 fill:#e8f5e9
style H3 fill:#e8f5e9
Key points: - One process serves multiple tenants and multiple configs - Each (config_id, tenant_id) pair gets an isolated AgentHost - AgentHost isolation: separate MCP server processes, storage connections, skill registries - Session isolation: each session gets its own RunLoop with independent state - Config changes in ConfigRegistry trigger Host reload via watch notifications
Plugin / Extension Architecture¶
pydantic-ai's Capability system IS the plugin system. No separate plugin registry needed:
graph TB
subgraph "Extension Mechanism"
subgraph "pydantic-ai Native"
AC["AbstractCapability / 40+ lifecycle hooks"]
AT["AbstractToolset / tool collections"]
CC2["CombinedCapability / composition & ordering"]
end
subgraph "Built-in Capabilities"
MCP_C["MCPCapability / & MCPToolset"]
SK_C["SkillCapability / & SkillToolset"]
SA_C["SubagentCapability / & SubagentToolset"]
CM_C["CodeModeCapability / & WrapperToolset"]
WB_C["WebCapability / & WebFetch & WebSearch"]
end
subgraph "User Extensions"
Custom1["CustomCapability / entry-point: agentwolf.capabilities"]
Custom2["CustomToolset / entry-point: agentwolf.toolsets"]
end
end
subgraph "YAML Declarative"
YAML["agents.yml: capabilities section"]
end
subgraph "Python Escape Hatch"
PY["agent = Agent(capabilities=[...])"]
end
YAML -->|parsed by Factory| AC
PY -->|direct injection| AC
AC --> MCP_C
AC --> SK_C
AC --> SA_C
AC --> CM_C
AC --> WB_C
AC --> Custom1
AC --> Custom2
style AC fill:#e8f5e9
style AT fill:#e8f5e9
style CC2 fill:#e8f5e9
style YAML fill:#e1f5fe
style PY fill:#fff3e0
Key points:
- AbstractCapability is the plugin primitive — 40+ lifecycle hooks, middleware chain, ordering constraints
- CombinedCapability handles composition with topological sort
- Entry-point registration: agentwolf.capabilities and agentwolf.toolsets groups
- YAML-first (95% of cases), Python escape hatch for advanced (4% hooks, 1% custom capability)
- ResourceProvider is deleted — all its functionality is replaced by native pydantic-ai equivalents
Concurrency Model: Asyncio, Multi-Process, Distributed¶
The six-layer architecture supports three levels of concurrency. Each level is a configuration choice at a specific layer boundary, not a separate architecture.
graph TB
subgraph "Level 1: Asyncio (Single Process)"
L1A["Layer 1: ConfigRegistry / (async interface)"]
L2A["Layer 2: AgentHost / (async MCP subprocess mgmt)"]
L3A["Layer 3: AgentFactory / (async compile)"]
L4A["Layer 4: RunLoop / (async event loop)"]
L5A["Layer 5: Agent Core / (async model calls)"]
L6A["Layer 6: ProtocolServer / (async I/O)"]
L1A --> L2A --> L3A --> L5A --> L4A --> L6A
end
subgraph "Level 2: Multi-Process (Single Machine)"
L6B["Layer 6: ProtocolServer / (Process A)"]
L2B["Layer 2: AgentHost / (Process B) / MCP subprocesses (Process C, D)"]
ET_IPC["EventTransport / (IPC / Unix socket / shared memory)"]
L6B -->|EventTransport| ET_IPC
ET_IPC -->|EventTransport| L2B
end
subgraph "Level 3: Distributed (Multi-Machine)"
L6C["Layer 6: ProtocolServer / (Machine A)"]
L1C["Layer 1: ConfigRegistry / ConfigStore = etcd/DB / (Machine B)"]
L2C["Layer 2: HostRegistry / routes to remote Hosts / (Machine C)"]
ET_MQ["EventTransport / (MessageQueueTransport / Redis/NATS/Kafka)"]
L6C -->|EventEnvelope via MQ| ET_MQ
ET_MQ -->|EventEnvelope via MQ| L2C
L1C -.->|watch notifications| L2C
end
style L1A fill:#e1f5fe
style L2A fill:#f3e5f5
style L3A fill:#e8f5e9
style L4A fill:#fff3e0
style L5A fill:#fce4ec
style L6A fill:#f5f5f5
style L6B fill:#f5f5f5
style L2B fill:#f3e5f5
style ET_IPC fill:#fff9c4
style L6C fill:#f5f5f5
style L1C fill:#e1f5fe
style L2C fill:#f3e5f5
style ET_MQ fill:#fff9c4
Level 1: Asyncio (Single Process) — Default¶
All six layers run in a single asyncio event loop. This is the default and the current behavior.
| Layer | Asyncio Role |
|---|---|
| ConfigRegistry | Async interface (async def get/register/watch) |
| AgentHost | Manages MCP subprocesses via asyncio.create_subprocess_exec() |
| AgentFactory | Async compile (MCP server initialization, skill discovery) |
| RunLoop | Async event loop — async for event in turn.execute() |
| Agent Core | Async model calls (PydanticAI native), async tool execution |
| ProtocolServer | Async I/O — handles multiple concurrent sessions via asyncio |
Key constraint: RunLoop is always single-process, single-event-loop by design. It owns a state machine (idle/running/done) that is not safe to share across processes. This is an explicit design decision — the RunLoop is the "narrow waist" that stays simple.
Level 2: Multi-Process (Single Machine)¶
Multi-process support exists at two layer boundaries:
Boundary A: AgentHost → MCP Subprocesses (already exists)
AgentHost (Layer 2) spawns MCP servers as separate OS processes via ProcessManager. This is the existing pattern — each MCP server runs as a subprocess with stdio communication.
graph LR
subgraph "Process 1: Main"
AH["AgentHost"]
PM["ProcessManager / (asyncio subprocess mgmt)"]
AH --> PM
end
subgraph "Process 2"
MCP1["MCP Server: filesystem"]
end
subgraph "Process 3"
MCP2["MCP Server: git"]
end
subgraph "Process 4"
MCP3["MCP Server: database"]
end
PM -->|stdio / JSON-RPC| MCP1
PM -->|stdio / JSON-RPC| MCP2
PM -->|stdio / JSON-RPC| MCP3
style AH fill:#f3e5f5
style PM fill:#f3e5f5
style MCP1 fill:#e8f5e9
style MCP2 fill:#e8f5e9
style MCP3 fill:#e8f5e9
Boundary B: ProtocolServer ↔ RunLoop via EventTransport (RFC-0042)
ProtocolServer (Layer 6) and RunLoop (Layer 4) can run in separate processes, connected by EventTransport with IPC (Unix domain socket, shared memory, or named pipe).
graph LR
subgraph "Process A: Protocol Server"
PS["ProtocolServer / (ACP/OpenCode/AG-UI)"]
ET_R["EventTransport / (read side)"]
PS --> ET_R
end
subgraph "Process B: Agent Execution"
RL["RunLoop"]
ET_W["EventTransport / (write side)"]
CC["CommChannel / (owns Journal)"]
RL --> CC
CC --> ET_W
end
ET_R <-->|EventEnvelope via IPC / Unix socket| ET_W
style PS fill:#f5f5f5
style ET_R fill:#fff9c4
style RL fill:#fff3e0
style ET_W fill:#fff9c4
style CC fill:#ffe0b2
Why split processes? - Isolation: Protocol parsing bugs don't crash agent execution - CPU parallelism: Each process has its own GIL — CPU-bound tool execution doesn't block protocol I/O - Resource isolation: Memory limits, CPU affinity per process - Crash recovery: Protocol server crash doesn't lose agent state (Journal persists)
Level 3: Distributed (Multi-Machine)¶
Distributed support exists at three layer boundaries:
graph TB
subgraph "Machine A: Edge"
Client["Client / (Zed / curl / TUI)"]
PS["ProtocolServer / (Layer 6)"]
Client --> PS
end
subgraph "Machine B: Config"
CR["ConfigRegistry / (Layer 1) / ConfigStore = etcd cluster"]
PS -.->|load config| CR
end
subgraph "Machine C: Execution"
HR["HostRegistry / (Layer 2)"]
Host["AgentHost / + AgentFactory / + RunLoop / + Agent Core"]
HR --> Host
end
subgraph "Message Queue"
MQ["Redis / NATS / Kafka / (EventTransport backend)"]
end
PS -->|EventEnvelope / via MQ| MQ
MQ -->|EventEnvelope / via MQ| HR
CR -.->|watch notifications / via MQ| HR
style PS fill:#f5f5f5
style CR fill:#e1f5fe
style HR fill:#f3e5f5
style Host fill:#e8f5e9
style MQ fill:#fff9c4
Boundary 1: Layer 1 (ConfigRegistry) — Distributed Config Store
ConfigRegistry's ConfigStore can be backed by etcd or a distributed database. Config changes propagate to all AgentHosts via watch notifications (pub/sub).
| ConfigStore Implementation | Scope | Use Case |
|---|---|---|
MemoryConfigStore |
Single process | Development, testing |
FileConfigStore |
Single machine | Standalone deployment |
EtcdConfigStore |
Multi-machine | Production, HA config |
DBConfigStore |
Multi-machine | Production with audit trail |
Boundary 2: Layer 6 ↔ Layer 4 (EventTransport) — Distributed Protocol
ProtocolServer (Layer 6) and RunLoop (Layer 4) communicate via EventTransport. When EventTransport is MessageQueueTransport, they can be on different machines.
| EventTransport Implementation | Scope | Latency | Use Case |
|---|---|---|---|
InProcessTransport |
Single process | <0.1ms | Default, development |
IPCTransport |
Single machine | ~0.5ms | Multi-process isolation |
MessageQueueTransport |
Multi-machine | ~2-5ms | Distributed, polyglot |
The EventEnvelope (JSON + schema versioning) is the wire format. Any language can consume it — a Rust/Go/TypeScript protocol server reads EventEnvelopes from the message queue and translates to its own protocol.
Boundary 3: Layer 2 (HostRegistry) — Distributed Host Routing
HostRegistry can route to AgentHosts on remote machines. When a ProtocolServer receives a request, it queries HostRegistry for the correct Host — which may be local or remote.
graph TB
subgraph "Machine A"
PS["ProtocolServer"]
HR["HostRegistry"]
PS --> HR
end
subgraph "Machine B"
H1["AgentHost / config='default' tenant='t1'"]
RL1["RunLoop / session='s1'"]
H1 --> RL1
end
subgraph "Machine C"
H2["AgentHost / config='default' tenant='t2'"]
RL2["RunLoop / session='s2'"]
H2 --> RL2
end
HR -->|route to local| H1
HR -->|route to remote / via EventTransport| H2
style PS fill:#f5f5f5
style HR fill:#f3e5f5
style H1 fill:#e8f5e9
style H2 fill:#e8f5e9
style RL1 fill:#fff3e0
style RL2 fill:#fff3e0
What Stays Single-Process?¶
| Component | Why It Stays Single-Process |
|---|---|
| RunLoop | Owns a state machine (idle/running/done) — not safe to share. One event loop per session. |
| Turn | Single reactive cycle — prompt → model → tools → response. Must be atomic. |
| Agent Core | PydanticAI agent runs in a single event loop. Model calls are async but not parallel. |
| CommChannel | Owns Journal (append/upsert). Must be co-located with RunLoop for crash safety. |
| SnapshotStore | Called by RunLoop at Turn boundaries. Must be co-located for consistency. |
The invariant: one RunLoop = one process = one session. Distribution happens around the RunLoop, not within it.
Concurrency Level Selection Matrix¶
| Deployment Mode | ConfigRegistry | AgentHost | RunLoop | ProtocolServer | EventTransport |
|---|---|---|---|---|---|
| Dev / Testing | Memory | Single process | Asyncio | In-process | InProcess |
| Standalone | File | Single process | Asyncio | In-process | InProcess |
| Server (current) | File | Single process | Asyncio | In-process | InProcess |
| Server (isolated) | File | Multi-process | Asyncio | Separate process | IPC |
| Multi-tenant server | File/DB | Per-tenant process | Asyncio | In-process | InProcess |
| Distributed (future) | etcd | Per-machine | Asyncio | Separate machine | MessageQueue |
| Polyglot (future) | etcd | Any language | Asyncio | Rust/Go/TS | MessageQueue |
Progressive adoption: Each level is opt-in. The default is Level 1 (asyncio, single process). Level 2 (multi-process) is a configuration change to EventTransport. Level 3 (distributed) requires ConfigStore + EventTransport + HostRegistry changes — but no RunLoop or Agent Core changes.
Tenant Isolation¶
- Each AgentHost is a process-level isolation boundary — separate MCP server subprocesses, separate storage connections
- RunScope is validated at every layer boundary — a tenant-1 request cannot access tenant-2's Host
- StorageProvider enforces tenant_id filtering at the query level
Config Provenance¶
- ConfigRegistry tracks config versions and provenance (source: file, HTTP, programmatic)
- Config changes are auditable — who changed what, when
- Unsigned remote configs should be rejected in production (future: config signing)
Protocol Security¶
- ProtocolServer validates RunScope from protocol
initializemessage - ACP session tokens include tenant_id and config_id — cannot be forged
- EventTransport (future MQ) uses TLS + auth for cross-process communication
Implementation Plan¶
Phased Migration¶
The migration uses Adapter Pattern + Gradual Migration (not Strangler Fig — there is no request-level routing; migration is per-Agent-instance). The existing AdapterToolsetFactory already serves as the bridge between old and new abstraction layers.
gantt
title AgentWolf v1.0 Migration Phases
dateFormat YYYY-MM-DD
axisFormat %b
section Phase 1a Runtime Extraction
Extract HostContext dataclass from AgentPool fields :p1a, 2026-07-01, 7d
Extract AgentFactory from agent creation logic :p1a2, 2026-07-01, 10d
AgentPool becomes facade delegating to Factory :p1a3, 2026-07-08, 7d
section Phase 1b Backdoor Removal
Replace MessageNode.agent_pool with HostContext :p1b, 2026-07-15, 21d
25 call sites across messagenode agent base_team :p1b2, 2026-07-15, 14d
section Phase 1c Capability Bridge
Build 7 ToolsetFactory equivalents :p1c, 2026-07-15, 21d
AdapterToolsetFactory bridges un-migrated providers :p1c2, 2026-07-15, 7d
section Phase 2 Lifecycle
RFC-0041 Run vs Turn separation :p2a, 2026-07-15, 30d
RFC-0042 six pluggable dimensions :p2b, 2026-08-01, 45d
section Phase 1d Cleanup
Delete ResourceProvider all call sites migrated :p1d, 2026-09-01, 7d
section Phase 3 Multi-Config and Config Split
Split HostConfig and AgentManifest in YAML schema :p3a, 2026-10-01, 14d
ConfigRegistry versioning and watch :p3b, 2026-10-01, 21d
HostRegistry lazy creation and eviction :p3c, 2026-10-08, 21d
RunScope routing in ProtocolServer :p3d, 2026-10-22, 14d
section Phase 4 Multi-Tenant
Tenant isolation per-host EventBus and storage :p4a, 2026-11-01, 21d
Tenant filtering in StorageProvider :p4b, 2026-11-01, 14d
section Phase 5 Polyglot
gRPCTransport for multi-process isolation :p5a, 2026-12-01, 21d
MessageQueueTransport for distributed polyglot :p5b, 2026-12-15, 30d
Phase 1a split rationale (Oracle revision): The original Phase 1a conflated two independent concerns:
- 1a-runtime: Extract HostContext dataclass + AgentFactory from AgentPool (runtime restructuring)
- 1a-config: Split AgentsManifest into HostConfig + AgentManifest (YAML schema separation)
Oracle analysis found that 1b, 1c, and Phase 2 depend on 1a-runtime only. The config split (1a-config) is only needed by Phase 3 (multi-config versioning + hot-reload granularity). Deferring config split to Phase 3: - Reduces Phase 1a from 21d to ~14d (no config model refactoring, no YAML migration, no backward-compat validator) - Allows Phase 2 to start ~7 days earlier - Simplifies blast radius — Phase 1 touches only runtime code, not config schema
Phase 1 sub-phases:
- 1a (runtime extraction, 14d): Create HostContext dataclass from existing AgentPool fields. Extract AgentFactory. AgentPool becomes facade. NO config model changes, NO YAML schema changes.
- 1b (backdoor removal, 21d): Replace MessageNode.agent_pool with HostContext. 25 call sites across 4 files. Can overlap with Phase 2.
- 1c (capability bridge, 21d): Build ToolsetFactory equivalents using AdapterToolsetFactory as bridge. Can overlap with Phase 2.
- 1d (cleanup, 7d): Delete ResourceProvider after all call sites migrated.
Critical path: Phase 1a (14d) → Phase 2 (30d) = 44 days to RunLoop. Phase 1b and 1c run in parallel with early Phase 2.
Dependencies¶
Real dependency graph (Oracle-revised):
1a-runtime (HostContext + AgentFactory extraction)
├──→ 1b (backdoor removal: needs HostContext to exist)
├──→ 1c (capability bridge: needs AgentFactory to exist)
└──→ Phase 2 (RunLoop: needs AgentFactory to create turns)
1a-config (HostConfig/AgentManifest split) — DEFERRED to Phase 3
└──→ Phase 3 (multi-config: needs config versioning + split for hot-reload)
└──→ Phase 4 (multi-tenant: needs config split for per-tenant HostConfig)
1c → 1d (ResourceProvider deletion)
- Phase 1a has no dependencies — can start immediately. Only extracts runtime structures, no config changes.
- Phase 1b depends on 1a (HostContext must exist). Does NOT depend on config split. Can overlap with Phase 2.
- Phase 1c depends on 1a (AgentFactory must exist). Does NOT depend on config split. Can overlap with Phase 2.
- Phase 1d depends on 1c (all call sites must have replacements before deletion).
- Phase 2 depends on 1a only (AgentFactory must exist before RunLoop can be injected). Phase 2 does NOT depend on 1b, 1c, or config split — RunLoop calls
agent.create_turn()regardless of how agents get their dependencies. - Phase 2a and 2b can partially overlap — RFC-0041 defines RunLoop skeleton with dimension injection points, RFC-0042 fills them in.
- Phase 3 depends on Phase 2 (RunLoop must be stable). THIS is where config split happens —
HostConfig/AgentManifestseparation is introduced for multi-config versioning and hot-reload granularity. - Phase 4 depends on Phase 3 (multi-config must work before multi-tenant).
- Phase 5a (gRPC) depends on Phase 2 (EventTransport is an RFC-0042 dimension).
- Phase 5b (MQ) depends on Phase 5a (gRPC transport proves the interface before adding MQ).
- RFC-0041 is a prerequisite for Phase 2.
- RFC-0042 is a prerequisite for Phase 2 and Phase 5.
Why config split can be deferred: HostContext is a runtime bundle carrying handles to already-instantiated infrastructure objects. Whether those objects were configured from a flat AgentsManifest or a split HostConfig + AgentManifest is irrelevant to the consumer. AgentFactory.compile(manifest, host_context) works with a flat manifest — Factory only reads agent/team/graph sections. When the split happens in Phase 3, manifest narrows from AgentsManifest to AgentManifest (a subset) — a parameter type refinement, not a signature change.
Rollback Strategy¶
Each phase is independently deployable:
- Phase 1a: AgentPool class remains as a facade wrapper — existing async with AgentPool(...) API unchanged. Config model is NOT modified — flat AgentsManifest still works.
- Phase 1b: MessageNode.agent_pool property remains as a compatibility shim returning HostContext — gradual migration per agent subclass.
- Phase 1c: AdapterToolsetFactory bridges old ResourceProvider to new ToolsetFactory — both paths work in parallel.
- Phase 1d: ResourceProvider code remains in tree (deprecated) for one release cycle before physical deletion.
- Phase 2: Old RunHandle path remains as fallback until RunLoop is stable.
- Phase 3: Single-config mode (current behavior) is the default; multi-config is opt-in. Config split is additive — host: section is optional, flat config auto-migrates via model validator.
- Phase 4: Single-tenant mode (current behavior) is the default; multi-tenant is opt-in.
- Phase 5a: In-process transport is the default; gRPC transport is opt-in.
- Phase 5b: gRPC is the default distributed transport; MQ is opt-in for polyglot scenarios.
Fallback if config deferral fails: If some code path genuinely needs the config split before Phase 3, add the host: section as OPTIONAL to AgentsManifest with auto-migration via model validator. This is a ~2 day addition, not a phase rewrite.
Open Questions¶
-
AgentHost lifecycle: Should Hosts be eagerly created at startup or lazily created on first request? (Recommendation: lazy with warm pool for known-active tenants.)
-
Config hot-reload granularity: When a config changes, should the entire Host reload, or only affected agents? (Recommendation: diff-based — only recreate agents whose config section changed.)
-
RunScope propagation: Should RunScope be explicitly passed to every method, or stored in a context variable (contextvars)? (Recommendation: context variable for ergonomics, explicitly passed at layer boundaries.)
-
Package naming: Should v1.0 use
agentwolf_*or keepagentpool_*package names? (Recommendation: keepagentpool_*for backward compatibility;agentwolfis an internal codename.) -
AgentFactory pre-compilation: Should teams/graphs always be pre-compiled, or allow lazy compilation for dynamic topologies? (Recommendation: pre-compile by default,
factory_mode: lazyopt-in for dynamic use cases.) -
EventEnvelope versioning: How should EventEnvelope schema versions be negotiated between RunLoop and remote consumers? (Deferred to RFC-0042 Phase 5.)
-
StorageProvider tenant isolation: Should tenant_id be a column in shared tables, or separate databases per tenant? (Recommendation: column for v1.0, separate DB option for v1.1.)
-
EventTransport ownership: Is EventTransport owned by RunLoop (Layer 4) and configured by ProtocolServer at creation time, or owned by ProtocolServer (Layer 6) with RunLoop publishing to it? (Recommendation: RunLoop-owned, ProtocolServer configures at creation, then consumes via read-only interface. This keeps the state machine single-owner.)
-
EventBus → CommChannel migration: 4 protocol servers use
ProtocolEventConsumerMixinwhich subscribes to EventBus directly. Should CommChannel replace EventBus entirely, or coexist? (Recommendation: coexist initially — Journal is append-only for crash recovery, EventBus is in-memory pub/sub for real-time streaming. Converge post-v1.0.) -
TurnRunner extraction: Turn execution logic is currently scattered across SessionController and SessionPool (no standalone TurnRunner class exists). Should TurnRunner be extracted before or during RFC-0041 implementation? (Recommendation: during — RFC-0041 defines the Turn boundary, extraction happens as part of that work.)
Decision Record¶
| Date | Decision | Rationale |
|---|---|---|
| 2026-07-08 | Six-layer architecture (ConfigRegistry → AgentHost → AgentFactory → RunLoop → Agent Core → ProtocolServer) | Achieves full orthogonality, supports multi-tenancy structurally, aligns with K8s/Temporal proven patterns |
| 2026-07-08 | Delete ResourceProvider, adopt pydantic-ai Capability/Toolset | ResourceProvider already deprecated; pydantic-ai system is richer, native, and eliminates bridge layer |
| 2026-07-08 | Keep StorageProvider, Journal, SnapshotStore separate | Three different access patterns, owners, and retention strategies; database analogy (tables vs WAL vs checkpoint) |
| 2026-07-08 | RunScope as cross-cutting context (config_id, tenant_id, user_id, session_id) | Enables multi-tenancy without structural changes; each layer becomes a registry |
| 2026-07-08 | pydantic-ai Capability system IS the plugin system | No separate plugin registry needed; entry-point registration + YAML declarative + Python escape hatch |
| 2026-07-09 | AgentFactory is standalone service, NOT owned by AgentHost | Prevents config/runtime boundary blur; Factory receives (manifest, host_context) as parameters; has internal compilation cache but no infrastructure ownership |
| 2026-07-09 | HostContext dataclass replaces MessageNode.agent_pool | Eliminates backdoor from Layer 5 to Layer ½; carries only dependency handles, not full pool reference; prerequisite for tenant isolation |
| 2026-07-09 | Change notification via AbstractCapability.on_change(), NOT central CapabilityRegistry | Avoids recreating ResourceProvider signal pattern under new name; change notification is Capability's own responsibility, not a central authority's |
| 2026-07-09 | Tiered consistency for Journal/Storage/Snapshot | Turn-level events: strong consistency (synchronous dual-write). Delta events: eventual consistency (async batch journal). Avoids write amplification while preserving crash recovery |
| 2026-07-09 | Knowledge management starts as Capability (Layer 5), not Layer 2 infrastructure | YAGNI — no proven cross-agent sharing requirement yet. Promote to Layer 2 KnowledgeProvider when sharing is demonstrated. Prevents premature abstraction |
| 2026-07-09 | EventTransport evolution: InProcess → gRPC → MessageQueue | Not a binary choice. gRPC for single-machine multi-process (low latency, strong typing). MQ for multi-machine polyglot (multi-consumer, persistence). Each step is opt-in |
| 2026-07-09 | Migration uses Adapter + Gradual Migration, not Strangler Fig | No request-level routing; migration is per-Agent-instance. AdapterToolsetFactory already exists as bridge. Phase 1 split into 1a/1b/1c/1d to reduce blast radius |
| 2026-07-09 | ResourceSource protocol — orthogonal data abstraction | pydantic-ai has no data concept (only tools/instructions). ResourceSource fills the gap without recreating ResourceProvider: orthogonal (data vs behavior) not parallel. MCPCapability implements both interfaces. Compile-time scoped, per-agent |
| 2026-07-09 | Config split: HostConfig + AgentManifest in AgentsManifest | Different change costs (infrastructure reload vs agent recompile), different multi-tenancy semantics. MCP/model definitions in host, agent references in manifest. v0.x flat config auto-migrates |
| 2026-07-09 | Model config three-layer: providers + aliases + agent selection | Secrets in providers (host), name resolution in aliases (host), per-agent tuning in AgentConfig. ModelCache shares Model instances across agents. Alias enables model migration in one place |
| 2026-07-09 | Defer config split (HostConfig/AgentManifest) to Phase 3 | Oracle analysis: Phase 1b/1c/Phase 2 depend on 1a-runtime only (HostContext + AgentFactory extraction), NOT on config schema split. Config split only needed for Phase 3 (multi-config versioning). Deferring reduces Phase 1a from 21d to 14d, simplifies blast radius. Flat config works throughout Phase 1-2 |
| 2026-07-09 | HostContext includes prompt_manager | Oracle found 2 call sites accessing agent_pool.prompt_manager in native_agent. Original RFC omitted this field. Added to HostContext and AgentHost |
References¶
- RFC-0042: Unified Lifecycle Architecture — Six pluggable dimensions within RunLoop
- RFC-0041: Run vs Turn Separation — Phase 1 prerequisite
- Lifecycle Analysis — Cross-framework research
- pydantic-ai Documentation — Capability/Toolset system
- K8s Controller Pattern — CRD → Controller analogy
- Akka Persistence — Journal + SnapshotStore model
- Temporal Workflow Model — Workflow + Activity + Worker pattern
Appendix A: Architecture Feasibility Assessment¶
Evaluated by Oracle (read-only high-IQ reasoning specialist) against the current codebase.
Dimension A: Layer Boundary Soundness — MEDIUM RISK¶
Key findings:
- The six-layer chain is conceptually sound and maps cleanly to K8s CRD→Controller analogy
- Layer ⅘ numbering inconsistency: the data flow is Factory → Agent Core → RunLoop → Protocol (L3→L5→L4→L6), not strictly sequential. This is a documentation issue, not a design flaw
- MessageNode.agent_pool backdoor: every agent holds a reference to the full AgentPool, accessing storage, MCP, skills, and connection registry through it. This spans Layers 2-5 and prevents clean separation
- AgentHost ↔ AgentFactory boundary is blurry: the RFC says Host "owns" Factory, but Factory also needs the immutable manifest. If Host owns Factory, Host has both config and infra — blurring the config/runtime separation
Recommendations:
1. Fix layer numbering or document the non-linear flow explicitly
2. Define a HostContext dataclass replacing MessageNode.agent_pool — carries only what the agent needs (mcp, storage, skills_registry, connection_registry)
3. Make AgentFactory standalone (not owned by AgentHost) — receives (manifest, host) as method parameters
Cost: L (Large) — MessageNode decoupling touches every agent and team subclass
Dimension B: Dependency Order & Phasing — MEDIUM RISK¶
Key findings:
- Phase dependency chain is mostly correct
- RFC-0041 and RFC-0042 can be partially parallelized (~50% overlap): RFC-0041 defines the RunLoop skeleton with dimension injection points, RFC-0042 fills them in
- Phase 1 is overloaded: splitting AgentPool AND deleting ResourceProvider (7 providers, 19 as_capability() call sites) simultaneously creates massive blast radius
- ToolsetFactory protocol + AdapterToolsetFactory already exist as a bridge — migration can be incremental
Recommendations:
1. Split Phase 1 into: 1a (AgentPool split), 1b (remove MessageNode.agent_pool backdoor), 1c (build all 7 ToolsetFactory equivalents), 1d (delete ResourceProvider)
2. Parallelize RFC-0041 and RFC-0042
Cost: L (Large) — Phase 1 is the critical path
Dimension C: ResourceProvider Deletion Risk — HIGH RISK¶
Key findings:
- 5 of 7 providers have clean pydantic-ai equivalents
- PoolResourceProvider → SubagentToolset is riskiest: deeply coupled to session orchestration (ctx.create_child_session(), session_pool.sessions.get_or_create_session_agent())
- CodeModeResourceProvider has no pydantic-ai equivalent: wraps all tools into single meta-tool — WrapperToolset doesn't exist yet
- Change signals are the critical gap: ResourceProvider has 4 signal types (tools_changed, prompts_changed, resources_changed, skills_changed). AbstractCapability has no equivalent. Dynamic skill registration (register_skill_provider → skills_changed → _rebuild_skill_capabilities) breaks without a replacement
Recommendations:
1. Don't delete ResourceProvider until all 7 ToolsetFactory equivalents are built and tested
2. Design a ChangeNotifier mixin for ToolsetFactory implementations, replacing the 4 signal types
3. Build SubagentToolset (highest risk) and CodeModeCapability (second highest) first
4. Move XML wrapping logic from SkillsInstructionProvider into a SkillsInjectionCapability
Cost: XL (Extra Large) — single riskiest part of the entire RFC
Dimension D: Multi-Tenancy Structural Soundness — MEDIUM RISK¶
Key findings:
- Process-level isolation is sufficient for MCP subprocesses (already per-pool)
- Storage isolation is the main concern: StorageManager has no tenant_id awareness. Every query method needs a tenant_id parameter
- SessionPool → HostRegistry mapping is non-trivial: SessionController (1460 lines) deeply depends on pool.manifest, pool.storage, pool.skills, pool.mcp
- Shared EventBus is a risk: if multiple tenants share a process, they share an EventBus — session-scoped subscriptions could leak if session IDs collide
- MessageNode.agent_pool backdoor (again): tenant isolation is fiction as long as agents can reach the shared pool
Recommendations:
1. Give each AgentHost its own EventBus, ProcessManager, and StorageManager
2. Add tenant_id as required parameter to all StorageManager query methods
3. Remove MessageNode.agent_pool reference (prerequisite for real isolation)
4. Scope SessionController and SessionPool per-host
Cost: L (Large) — touches storage, session management, and event bus
Dimension E: RFC-0042 Integration Risk — MEDIUM RISK¶
Key findings:
- Layer 4 correctly contains RFC-0042's six dimensions
- CommChannel owns Journal, RunLoop owns SnapshotStore — sound split
- EventTransport dual-role conflict: listed as RunLoop dimension (L4) but also "enables polyglot ProtocolServer" (L6). If RunLoop owns it, ProtocolServer can't independently choose transport
- EventBus already partially implements EventTransport: scoped subscriptions, bounded queues, replay buffers — migration is more rename + interface extraction than new implementation
- ProtocolEventConsumerMixin currently subscribes to EventBus directly — would need rewriting to consume from CommChannel
Recommendations: 1. Clarify EventTransport ownership: RunLoop-owned, ProtocolServer configures at creation time, then consumes via read-only interface 2. Design CommChannel as EventBus replacement, not wrapper 3. Keep Journal and EventBus separate initially — Journal is append-only (crash recovery), EventBus is in-memory pub/sub (real-time streaming)
Cost: M (Medium) — containment is sound, main work is EventTransport ownership clarification
Overall Assessment¶
Top 3 risks:
1. ResourceProvider change signals have no pydantic-ai equivalent — breaks dynamic tool/skill management
2. MessageNode.agent_pool backdoor spans Layers 2-5 — prevents clean separation and tenant isolation
3. Phase 1 is overloaded with simultaneous structural + abstraction changes
Top 3 opportunities:
1. SkillCapability already exists and works (362 lines) — LocalResourceProvider migration is essentially done
2. ToolsetFactory + AdapterToolsetFactory already exist as a bridge — incremental migration is possible
3. EventBus → EventTransport is mostly a rename — the current implementation already has the needed features
Recommended phase reordering:
Phase 1a: Split AgentPool → ConfigRegistry + AgentHost + AgentFactory [3-4 weeks]
Phase 1b: Remove MessageNode.agent_pool backdoor, inject HostContext [2-3 weeks]
Phase 1c: Build all 7 ToolsetFactory equivalents [2-3 weeks]
Phase 1d: Delete ResourceProvider (all call sites have replacements) [1 week]
Phase 2a: RFC-0041 Run vs Turn separation (can start during 1c) [3-4 weeks]
Phase 2b: RFC-0042 six dimensions (stubs during 2a, full impl after) [4-6 weeks]
Phase 3: Multi-config (ConfigRegistry + HostRegistry + RunScope) [2-3 weeks]
Phase 4: Multi-tenant (per-host EventBus, storage tenant_id, isolation) [2-3 weeks]
Phase 5: Polyglot (EventTransport MQ, EventEnvelope) [4-5 weeks]
Go/No-Go: CONDITIONAL GO. Three conditions: 1. Design a change-notification mechanism for ToolsetFactory before ResourceProvider deletion 2. Split Phase 1 into structural refactor (1a/1b) and abstraction migration (1c/1d) 3. Resolve EventTransport ownership question (RunLoop-owned vs ProtocolServer-owned)
Appendix B: Codebase Migration Cost Assessment¶
Evaluated by Explore agent against the actual codebase file counts and references.
Migration Cost Matrix¶
| Dimension | Files Affected | Classes Modified | Tests Updated | Risk | Effort |
|---|---|---|---|---|---|
| D1: AgentPool → 3-layer split | 151+ files import AgentPool | 18+ responsibilities in 825-line class | ~85 test files | HIGH | XL (2-3 weeks) |
| D2: ResourceProvider deletion | 14 files (~3,860 LOC) + 52 consumers | 10 providers + 19 as_capability() |
Many integration tests | MED-HIGH | L (1-2 weeks) |
| D3: SessionController → RunLoop | ~4,960 LOC orchestrator + ~2,886 LOC server | SessionController (1460), SessionPool (1391), RunHandle (610), EventBus (613), Turn (251) | Most orchestrator + server tests | VERY HIGH | XL (3-4 weeks) |
| D4: ProtocolServer RunScope | 6 servers (~12,500 LOC total) | 6 server classes + 8 manager classes | All E2E/integration tests | VERY HIGH | XL (3-5 weeks) |
| D5: Storage 3-layer separation | ~2,400 LOC storage code | StorageProvider (615) + 6 subclasses + StorageManager (1095) + CheckpointManager (297) | Minimal if interface stable | LOW-MED | L (1-2 weeks) |
Key Codebase Metrics¶
AgentPool (pool.py):
- 825 lines, 18+ responsibilities
- 151+ files import it across the codebase
- 85 test files directly use async with AgentPool(...)
- Partially decomposed already: pool-level agent creation removed, SessionPool separated
ResourceProvider:
- 10 implementations across 14 files (~3,860 LOC)
- 19 as_capability() implementations
- 52 files import from resource_providers/
- 14 entry-point registrations in pyproject.toml
- ToolsetFactory protocol already exists (194 LOC) with AdapterToolsetFactory as bridge
- SkillCapability already exists (362 LOC)
Orchestrator (RFC-0042 target):
- SessionController: 1,460 lines
- SessionPool: 1,391 lines
- RunHandle: 610 lines
- EventBus: 613 lines
- Turn ABC: 251 lines
- Total: ~4,960 LOC across 11 files
- 22 server files directly reference SessionController
- OpenCode session_pool_integration.py: 1,442 lines (largest server file)
Protocol Servers:
- 6 servers, ~12,500 LOC total
- All take single pool: AgentPool[Any] in constructor
- No config_id or tenant_id support anywhere
- All share one AgentPool instance across all sessions
- All use ProtocolEventConsumerMixin (assumes single EventBus)
Storage: - StorageProvider base: 615 lines, 7 method families - 6 implementations (SQL, Memory, OpenCode, Zed, ACP, File) - StorageManager: 1,095 lines - CheckpointManager: 297 lines (already exists, maps to SnapshotStore) - Checkpoint infrastructure already active in 3 providers
Critical Blockers¶
- MCP connections are pool-scoped, not session-scoped — multi-tenancy requires per-host MCP process partitioning
- Skill registration is pool-level — skill MCP servers are keyed by pool, not by tenant
AggregatingResourceProviderchange signals (skills_changed) cannot map to multi-pool targets without redesign- No
TurnRunnerclass exists — turn execution logic is embedded across overlapping methods in SessionController and SessionPool
Total Estimate¶
9-15 developer-weeks (optimistic, for someone familiar with the codebase):
- Code migration: 3-5 weeks
- Test cleanup: 3-5 weeks (~85 test files need AgentPool(...) boilerplate rewritten)
- Backward compatibility shims: 1-2 weeks
- Integration verification: 2-3 weeks
Appendix C: Cross-Framework Reference — DeerFlow¶
Source:
~/src/deer-flow/— ~228K LOC Python, 306 files underbackend/packages/harness/deerflow/
Architecture Summary¶
DeerFlow is a monolithic package + singleton-config architecture built on LangGraph. Key characteristics:
| Aspect | DeerFlow Pattern | AgentPool Comparison |
|---|---|---|
| Config | Single config.yaml (~1200 lines) with 38 sections, reflection-based loading (use: module:Class) |
AgentPool uses Pydantic-based YAML models — better type safety |
| Execution | Single LangGraph compiled graph with 9-middleware chain | AgentPool uses pydantic-graph with Step abstraction |
| Sub-agents | ThreadPoolExecutor-based, max 3 concurrent, 30-min timeout (1,043 LOC executor) | AgentPool uses asyncio throughout |
| Session | LangGraph checkpointer (SQLite/Postgres) + in-memory RunManager | AgentPool has SessionController + SessionPool + EventBus |
| Multi-tenancy | Per-user agent configs (users/{user_id}/agents/), OIDC auth |
AgentPool has no multi-tenant support (this RFC adds it) |
| Extension | Community tools (community/<provider>/tools.py), MCP, SOUL.md agents |
AgentPool has ResourceProvider → pydantic-ai Capability |
Patterns to Adopt¶
-
Reflection-based DI for provider initialization: DeerFlow's
use: module:Classpattern in YAML allows swapping implementations in pure config. AgentPool could add this for custom providers without forking. -
Pre-computed budget and execution termination: DeerFlow has recursion limits, token budget with soft/hard stops, loop detection (
warn_threshold: 3, hard_limit: 5), and tool progress state machines. AgentPool v1.0 needs all of these for production readiness. -
Tool output externalization: When tool results exceed 12K chars, DeerFlow persists to disk and replaces with file reference. AgentPool should bake this into Layer 4 as a
ToolResultExternalizationMiddleware. -
Middleware chain as core loop architecture: DeerFlow's 9-middleware pipeline ensures single responsibility per middleware. AgentPool's hook system should move toward fully orderable middleware, not just
pre_turn/post_turn.
Anti-Patterns to Avoid¶
-
Global singleton config access:
get_app_config()is called everywhere, destroying testability and isolation. AgentPool must inject config explicitly, never use module-level singletons. -
Monolithic execution files:
subagents/executor.py(1,043 LOC) andruntime/runs/worker.py(1,383 LOC) mix config, tool building, message handling, and error handling. AgentPool's six-layer separation prevents this. -
No formal provider contract: Community tools have no consistent interface base class — failures are runtime-only. AgentPool's
AbstractCapability+ Pydantic schema generation enforces contracts at definition time.
Appendix D: Cross-Framework Reference — Zed¶
Source:
~/src/zed/— Rust codebase, agent system spans ~15 crates
Architecture Summary¶
Zed's agent system follows an entity-component model built on GPUI (their GUI framework). Key characteristics:
| Aspect | Zed Pattern | AgentPool Comparison |
|---|---|---|
| Config | Layered settings (defaults < user < project), JSON-based, hot-reloadable | AgentPool uses single YAML manifest — less flexible |
| Profiles | Built-in profiles (write/ask/minimal), per-profile tool toggles, context servers, model defaults | AgentPool has no profile concept — agent config is fixed at definition |
| Session | Thread entity: messages, tools, model, subagents, compaction in one struct |
AgentPool has SessionController + RunHandle dual-track |
| Protocol | ACP v1 only, stdio-based, 7,600-line AcpThread |
AgentPool supports ACP, OpenCode, AG-UI, OpenAI API, MCP |
| Skills | SKILL.md discovery, 50KB catalog budget, <skill_content> envelope, project-overrides-global |
AgentPool has SkillsInstructionProvider — similar but less disciplined |
| Persistence | ThreadStore SQLite, write-amplified via GPUI frame observation |
AgentPool uses event-driven persistence with explicit save points |
Patterns to Adopt¶
-
Profile overlay as runtime-dynamic selection: Zed's
AgentProfiledecouples "what an agent is" from "how it's configured right now." AgentPool could add execution-time profiles that swap tool sets, models, and context servers per-run without rebuilding the graph. -
Thread as unified session primitive: Zed's
Threadis a single struct holding conversation state, model, tools, subagent references, permissions, and compaction. AgentPool's SessionController + TurnRunner dual-track could be simplified into a singleAgentRunState. -
SettingsStore layered config cascade: Three-tier settings (defaults → user → project) with reactive subscriptions and hot reload. AgentPool could adopt cascading config where
pool.ymloverridesdefaults.yml, overridden by environment variables. -
Skills progressive disclosure: Zed's 50KB catalog budget, on-demand body loading,
disable-model-invocationflag, and project-overrides-global are more disciplined than AgentPool's skill injection modes. The<skill_content>envelope is directly adaptable. -
Tool permission system with regex-based rules: Per-tool regex patterns with precedence chain: hardcoded deny > always deny > always confirm > always allow > default confirm. More sophisticated than AgentPool's hook-based permission flow.
Anti-Patterns to Avoid¶
-
GPUI coupling prevents standalone serving: Zed's entire agent infrastructure requires a GPUI
Appcontext.NativeAgentis a GPUIEntity. This makes it impossible to run as a standalone server. AgentPool's protocol-agnostic design is fundamentally superior here. -
Session object duality:
Sessionholds bothThreadANDAcpThread. For native agents,AcpThreadis synthetic; for external agents,Threaddoesn't exist. This creates persistent synchronization problems. AgentPool's singleSessiondesign is cleaner. -
Frame-bound persistence write amplification:
ThreadStoreis exercised on every GPUI frame update viacx.observe, coupling persistence to render cycles. AgentPool's event-driven persistence with explicit save points is better. -
No test seam for agents: Zed lacks a
TestModelequivalent. All agent tests spin up real GPUI contexts and simulate UI interactions. AgentPool'sTestModel(from PydanticAI) is a significant advantage.