Skip to content

symfonic.platform.host

host

Who owns an Agent, and for how long — the contracts (task-1-2-1).

PLAT-ADR's rule holds here as everywhere in this package: the platform derives who the caller is, decides whether the request may proceed, and hands the request to the public facade; it implements nothing an invocation does. A host composes and caches agents. It never takes a turn, never reaches into one, and never learns what a capability does.

Why this exists. symfonic.Agent is deliberately minimal: a provider, instructions, a model, tools, capabilities. Its invocations take a prompt and nothing about tenancy, and the published facade decision forbids adding a per-turn scope — an agent is bound to one scope or it is not scoped at all. A multi-tenant product therefore needs one agent per scope, and something has to own that mapping. Today the generated scaffold owns it by building a single SymfonicAgent and deriving a scope per request, which is the shape this package replaces.

The four contracts the host fixes, because everything built later consumes them:

  1. Resource ownership. Pools, backends and embedders belong to the host, not to an agent. Agents are cheap and per-scope; a connection pool is not. An agent that owned a pool would open one per tenant and close none.
  2. Tenant resolution. One agent per scope, cached deterministically, keyed on the scope's canonical identity — the same identity memory writes, flushes and forgets with. Two callers naming the same scope get the same agent; two scopes never share one.
  3. Immutability. Composition is an argument, not a mutation. Nothing is loaded into an agent after it is built, which is what makes a compiled plan trustworthy — and it is why load_plugin() has no equivalent here.
  4. Shutdown. Closing is idempotent and closes shared resources exactly once. Closing while turns are in flight has a declared answer rather than a race.

Not here: transport, sessions, checkpoints, workers, observability. Each is a separate slice, and each consumes this one. Fixing the composition root first is what stops them from each inventing their own.

AgentComposer

Bases: Protocol

Turns a scope and the process's shared resources into one Agent.

The adopter's composition root, expressed as a callable so the host never learns which capabilities a deployment folds. It receives the scope because capabilities are scope-bound at construction -- a memory capability is built for one tenant -- and that is precisely why an agent cannot be shared across scopes.

Synchronous on purpose. Composition assembles objects the host already opened; a composer that needed to await would be opening a resource of its own, which is the ownership this contract places elsewhere.

HostClosed

Bases: RuntimeError

Raised when an agent is requested from a host that has been closed.

A distinct type rather than a bare RuntimeError because the caller's correct response differs: a closed host during shutdown is expected and should end the request, while a closed host during normal operation is a lifecycle bug in the composition root. A message alone cannot be branched on without matching strings, which is the pattern this codebase has been removing.

PlatformAgentHost

Bases: Protocol

Owns the process's shared resources and the registry over them.

The composition root a generated project's main builds once and closes once. Everything a request needs comes from here; nothing a request does happens here.

aclose async

aclose() -> None

Close the registry and then the shared resources, exactly once.

Idempotent, because shutdown paths run twice more often than they run cleanly: a lifespan that closes and an atexit that closes are both correct and both fire.

Source code in src/symfonic/platform/host.py
async def aclose(self) -> None:
    """Close the registry and then the shared resources, exactly once.

    Idempotent, because shutdown paths run twice more often than they run
    cleanly: a lifespan that closes and an atexit that closes are both
    correct and both fire.
    """
    ...

agent_for async

agent_for(scope: Any) -> Any

The agent bound to scope.

Raises:

Type Description
HostClosed

if the host has been closed.

Source code in src/symfonic/platform/host.py
async def agent_for(self, scope: Any) -> Any:
    """The agent bound to ``scope``.

    Raises:
        HostClosed: if the host has been closed.
    """
    ...

resume async

resume(token: str, answer: Any) -> Any

Resume an explicitly configured durable human continuation.

Source code in src/symfonic/platform/host.py
async def resume(self, token: str, answer: Any) -> Any:
    """Resume an explicitly configured durable human continuation."""
    ...

start async

start() -> None

Open shared resources. Idempotent: starting twice opens once.

Source code in src/symfonic/platform/host.py
async def start(self) -> None:
    """Open shared resources. Idempotent: starting twice opens once."""
    ...

SharedResources

Bases: Protocol

What every agent in a process borrows and none of them owns.

Deliberately opaque: the host holds it, hands it to the composer, and closes it. Naming its members here would make the host know what a deployment's memory stack is, which is the coupling this package exists to avoid -- an adopter with a different backend implements this and changes nothing else.

aclose async

aclose() -> None

Release everything this object owns. Called once, by the host.

Source code in src/symfonic/platform/host.py
async def aclose(self) -> None:
    """Release everything this object owns. Called once, by the host."""
    ...

TenantAgentRegistry

Bases: Protocol

The scope -> agent mapping, and the only place agents are created.

Concurrency is part of the contract, not an implementation detail: two coroutines asking for the same scope at once receive the same agent, and the composer runs once. A registry that raced would give one tenant two agents with two capability sets over one store, and the second would silently win.

aclose async

aclose() -> None

Drop every agent. Idempotent. Does not close shared resources.

Source code in src/symfonic/platform/host.py
async def aclose(self) -> None:
    """Drop every agent. Idempotent. Does not close shared resources."""
    ...

agent_for async

agent_for(scope: Any) -> Any

The agent bound to scope, composing it on first use.

Raises:

Type Description
HostClosed

if the registry has been closed.

Source code in src/symfonic/platform/host.py
async def agent_for(self, scope: Any) -> Any:
    """The agent bound to ``scope``, composing it on first use.

    Raises:
        HostClosed: if the registry has been closed.
    """
    ...