Capabilities and Dependency Injection¶
symfonic-core uses a protocol-based dependency injection pattern centered on
BaseAgentDeps. This document explains how it works and why.
The Problem¶
AI agent frameworks typically wire dependencies through one of:
- Global singletons -- hard to test, impossible to isolate per-tenant.
- Constructor injection -- works but creates deep constructor chains.
- Service locators -- runtime errors when keys are misspelled.
symfonic-core uses protocol-based registration instead: you register concrete implementations against protocol types, and consumers resolve by protocol. The type system catches mismatches at development time.
How It Works¶
1. Define a protocol¶
from typing import Protocol
class DocumentStore(Protocol):
async def read(self, doc_id: str) -> Document | None: ...
async def write(self, doc: Document) -> None: ...
async def search(self, query: str) -> list[Document]: ...
2. Implement the protocol¶
class InMemoryDocumentStore:
"""Reference implementation -- no external dependencies."""
async def read(self, doc_id: str) -> Document | None:
return self._store.get(doc_id)
# ...
3. Register at the entry point¶
deps = BaseAgentDeps()
deps.register(DocumentStore, InMemoryDocumentStore())
deps.register(ModelProvider, MockModelProvider(response="Hi"))
4. Resolve anywhere in the graph¶
doc_store = deps.resolve(DocumentStore) # typed as DocumentStore
doc = await doc_store.read("my-doc")
If DocumentStore was never registered, MissingCapabilityError is raised
with a clear message naming the missing protocol.
Capability-Based Tool Filtering¶
The ToolRegistry extends this pattern to tools. Each tool declares what
capabilities it needs:
class DocReadTool:
name = "doc_read"
requires: list[type] = [DocumentStore]
async def __call__(self, state, **kw):
doc_store = state["deps"].resolve(DocumentStore)
return await doc_store.read(kw["doc_id"])
At runtime, registry.get_active_tools(deps) returns only tools whose
requires are all satisfied by the current deps. Tools with missing
capabilities are silently excluded rather than failing at call time.
This means the same agent codebase can run in different environments: - Development: InMemory stores, MockModelProvider - Staging: PostgreSQL stores, real Anthropic provider - Production: same as staging but with different config
The tool set automatically adjusts based on what is registered.
Multi-Tenant Isolation¶
TenantScope wraps a tenant/sub-tenant pair and is passed through the graph
state. Storage implementations use the scope to isolate data:
This ensures that agents running for different tenants never share state, even when running in the same process.
Testing¶
The testing module provides MockModelProvider which implements the
ModelProvider protocol with configurable responses. Combined with
InMemory* stores, you can test any agent graph without network calls
or API keys.