Skip to content

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:

  1. Global singletons -- hard to test, impossible to isolate per-tenant.
  2. Constructor injection -- works but creates deep constructor chains.
  3. 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:

scope = TenantScope(tenant_id="acme", sub_tenant_id="engineering")

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.