Skip to content

symfonic

symfonic

symfonic — the canonical entry point.

from symfonic import Agent

agent = Agent(AnthropicProvider())
result = await agent.run("Say hello.")

That is the whole of the simple API: one required dependency, no FrameworkConfig, no tenant scope, no context manager, no background flush, and nothing printed about capabilities you did not ask for.

Everything here is resolved through a PEP 562 module __getattr__ and the module body executes no package import (FAC-1). Two consequences, both deliberate:

  • import symfonic.memory costs exactly what it cost before this module existed — the top level taxes nobody.
  • from symfonic import Agent imports only the facade chain, which is what makes the import-hermeticity contract (QUI-1: no symfonic.memory*, no fastapi, no mcp, …) mechanically checkable rather than aspirational.

__all__ is exactly the twelve names in T2.1.1's facade-surface.json. A thirteenth needs a preservation-matrix decision (API-ADR §2 A3) and a deprecation-registry row, which is the point of writing it down here.

Stability. Every new name enters at tier 2 (provisional), per API-ADR §1's rule for new surfaces: the import path is already stable, the shape may still change at a minor with a registry entry, and promotion to tier 1 is gated on W2 exit. Attachment and StructuredOutputError are tier-1 classes whose canonical paths are unchanged — the top-level alias is an additional path, never a move, so nothing here deprecates anything.

Reach for symfonic.agent.SymfonicAgent instead when you need HMS memory, tenant scope, sessions, checkpoints and resume, sub-agents, or the full callback surface. It is untouched and stays tier 1.

Agent

Agent(model_provider: Any, *, instructions: str | None = None, model: str | ModelConfig | None = None, tools: Sequence[Any] = (), capabilities: Sequence[CapabilityConfig] = (), max_model_rounds: int | None = None)

Bases: AgentContinuationMixin

A stateless, tool-capable agent.

Parameters:

Name Type Description Default
model_provider Any

Any object satisfying symfonic.core.ModelProviderAnthropicProvider, OpenAIProvider, MockModelProvider, or an adopter's own class. The sole required dependency.

required
instructions str | None

The system prompt, used verbatim. None sends no system message at all; the framework substitutes no default.

None
tools Sequence[Any]

A @symfonic_tool object, a LangChain BaseTool, or a plain annotated callable — fixed at construction.

()
capabilities Sequence[CapabilityConfig]

The typed optional-behavior seam. Absence means disabled; there are no boolean feature flags. Anything with a contribute(request) -> CapabilityContribution is accepted — MemoryCapability and PromptingCapability are the two that have one today. A GrantEffects in this sequence is removed before anything is asked to contribute, because a capability that could widen the grant set would be granting itself.

()
max_model_rounds int | None

How many provider round trips one run() may take before the kernel stops with stop_reason="tool_limit". None keeps the facade default (MAX_TOOL_ITERATIONS). A caller migrating a tool-heavy agent off the legacy body passes the budget it already had, because a loop bounded lower than the path it replaced returns a partial answer as if it were whole.

None

Construction validates and stores, and does nothing else (FAC-8): no connection, no task, no file, no environment read, no chat model. The first side effect of an Agent is the provider call inside run(). That is what makes async with and close() optional (LIF-1) — an agent that acquired nothing leaks nothing.

One instance may serve unlimited concurrent run/stream calls on one event loop, because no per-invocation state is stored on it (FAC-9). It is not documented as safe to share across event loops.

Source code in src/symfonic/agent/facade.py
def __init__(
    self,
    model_provider: Any,
    *,
    instructions: str | None = None,
    model: str | ModelConfig | None = None,
    tools: Sequence[Any] = (),
    capabilities: Sequence[CapabilityConfig] = (),
    max_model_rounds: int | None = None,
) -> None:
    require_provider(model_provider)
    require_instructions(instructions)
    resolved_model = as_model_config(model)
    require_round_budget(max_model_rounds)
    # GrantEffects is authority, not a capability; remove it before folding.
    adopter_grants, capabilities = partition_grants(tuple(capabilities))
    for capability in capabilities:
        if not callable(getattr(capability, "contribute", None)):
            raise ConfigurationError(
                f"{type(capability).__name__} is not a CapabilityConfig: it "
                "has no contribute(request) -> CapabilityContribution. The "
                "seam takes a declaration plus the handlers that answer it, "
                "so a capability cannot register a stage nothing runs."
            )

    self._closed = False
    sinks = tuple(
        sink for capability in capabilities
        if (sink := getattr(capability, "event_sink", None)) is not None
    )
    if len(sinks) > 1:
        raise ConfigurationError(
            "capabilities contributed more than one event sink; one run "
            "has one ordered observer stream."
        )
    self._event_sink = sinks[0] if sinks else None
    # Baseline authority comes from the invocation shape, never capabilities.
    baseline_grants = frozenset({"model_call"}) | (
        frozenset({"tool_call"}) if tools else frozenset()
    )
    authorized_effects = baseline_grants | adopter_grants
    # ``grants`` is dropped: already checked, and carrying it on as
    # ``capability_grants`` once left a field nothing read.
    folded = fold_contributions(tuple(capabilities), effect_grants=authorized_effects)
    stages, handlers, _grants, capability_tools, names, preconditions = folded
    self._capabilities = tuple(names)
    self._composition_manifest = composition_manifest(
        stages, capability_tools, names, preconditions
    )
    # One normalised set feeds binding, manifest, execution and grants (#31).
    merged = merge_capability_tools(tools, capability_tools)
    self._plans = AgentPlanFactory(
        model_provider=model_provider,
        instructions=instructions,
        model=resolved_model,
        tools=merged,
        **({} if max_model_rounds is None else {"max_model_rounds": max_model_rounds}),
        stages=stages,
        stage_handlers=handlers,
        # Never collected into a grant: a precondition can only refuse.
        tool_preconditions=preconditions,
        # Its own channel: not derived from contributions. Without it the
        # fold accepts memory-read and dispatch still refuses it -- green at
        # construction, dead at runtime.
        authorized_effects=authorized_effects,
        capability_names=names,
    )

capabilities property

capabilities: tuple[str, ...]

Names actually folded into this agent's invocation plan.

composition_manifest property

composition_manifest: Mapping[str, Any]

Payload-free attestation of the capability fold this agent retained.

close async

close() -> None

Release facade-owned resources. Idempotent, and never raises.

In W1 the facade owns nothing, so this only marks the instance closed. It never closes anything the adopter passed in — the provider outlives the agent. An in-flight run() is allowed to finish; close() does not cancel it (LIF-4).

Source code in src/symfonic/agent/facade.py
async def close(self) -> None:
    """Release facade-owned resources. Idempotent, and never raises.

    In W1 the facade owns nothing, so this only marks the instance closed.
    It never closes anything the adopter passed in — the provider outlives
    the agent. An in-flight ``run()`` is allowed to finish; ``close()``
    does not cancel it (LIF-4).
    """
    self._closed = True

run async

run(prompt: str, *, attachments: Sequence[Attachment] = ..., history: Sequence[Message] = ..., state: Mapping[str, Any] | None = ..., session_id: str = ...) -> AgentResult[None]
run(prompt: str, *, attachments: Sequence[Attachment] = ..., history: Sequence[Message] = ..., state: Mapping[str, Any] | None = ..., session_id: str = ..., output_type: type[OutputT]) -> AgentResult[OutputT]
run(prompt: str, *, attachments: Sequence[Attachment] = (), history: Sequence[Message] = (), state: Mapping[str, Any] | None = None, session_id: str = '', output_type: type[BaseModel] | None = None) -> AgentResult[Any]

Run one non-streaming turn.

history is how a stateless agent takes a second turn: the adopter holds the transcript and passes result.messages back.

Source code in src/symfonic/agent/facade.py
async def run(
    self,
    prompt: str,
    *,
    attachments: Sequence[Attachment] = (),
    history: Sequence[Message] = (),
    state: Mapping[str, Any] | None = None,
    session_id: str = "",
    output_type: type[BaseModel] | None = None,
) -> AgentResult[Any]:
    """Run one non-streaming turn.

    ``history`` is how a stateless agent takes a second turn: the adopter
    holds the transcript and passes ``result.messages`` back.
    """
    return await dispatch(
        self._kernel,
        self._prepare,
        turn_for(prompt, attachments, history, state, session_id=session_id),
        output_type,
    )

stream

stream(prompt: str, *, attachments: Sequence[Attachment] = ..., history: Sequence[Message] = ..., state: Mapping[str, Any] | None = ..., session_id: str = ...) -> AsyncIterator[AgentEvent]
stream(prompt: str, *, attachments: Sequence[Attachment] = ..., history: Sequence[Message] = ..., state: Mapping[str, Any] | None = ..., session_id: str = ..., output_type: type[OutputT]) -> AsyncIterator[AgentEvent]
stream(prompt: str, *, attachments: Sequence[Attachment] = (), history: Sequence[Message] = (), state: Mapping[str, Any] | None = None, session_id: str = '', output_type: type[BaseModel] | None = None) -> AsyncIterator[AgentEvent]

Run one streaming turn.

A plain def, not an async generator function, on purpose: an async generator defers argument validation to the first __anext__, which would surface a ConfigurationError after the caller believed the stream had started. This validates eagerly and raises at the call site, while async for event in agent.stream(...) still reads identically (EVT-8).

Source code in src/symfonic/agent/facade.py
def stream(
    self,
    prompt: str,
    *,
    attachments: Sequence[Attachment] = (),
    history: Sequence[Message] = (),
    state: Mapping[str, Any] | None = None,
    session_id: str = "",
    output_type: type[BaseModel] | None = None,
) -> AsyncIterator[AgentEvent]:
    """Run one streaming turn.

    A plain ``def``, not an async generator function, on purpose: an async
    generator defers argument validation to the first ``__anext__``, which
    would surface a ``ConfigurationError`` *after* the caller believed the
    stream had started. This validates eagerly and raises at the call site,
    while ``async for event in agent.stream(...)`` still reads identically
    (EVT-8).
    """
    return stream_dispatch(
        self._kernel,
        self._prepare,
        turn_for(prompt, attachments, history, state, session_id=session_id),
        output_type,
    )

AgentEvent

Bases: BaseModel

One event of an Agent.stream() (EVT-1…EVT-10).

A projection of the same invocation the non-streaming path runs — not a second pipeline. Field population per kind:

============== =================================================== kind populated fields ============== =================================================== thinking text (reasoning delta) text_delta text (answer delta) tool_call tool_call with result/error both None tool_result tool_call with result or error set done result error error cancelled (none) ask_user interrupt interrupt interrupt stage stage, and stage.counts where it counted ============== ===================================================

stage is what a capability's execution reported: which stage ran, in which phase, for which capability, with what outcome and why. It shares the dense index with everything else, so a consumer can place a retrieval against the round it fed. It carries counts and never contents -- see :class:StageRecord.

AgentResult

Bases: BaseModel, Generic[OutputT]

The result of one Agent.run() (RES-1…RES-8).

text is never None — a run that produced no text yields "". AgentResponse.final_response's None-vs-"" tri-state has no meaning a caller can act on and forces a null check on the happiest path.

messages is the whole turn in order and is designed to be fed straight back as the next call's history; that round trip is how a stateless agent takes a second turn.

Attachment

Bases: BaseModel

Non-text content to send alongside a query.

The semantic query string is used for HMS hydration and embeddings; attachments are forwarded to the LLM as additional content blocks but do NOT participate in memory hydration or tool routing in v1.

Attributes:

Name Type Description
kind Literal['image', 'document']

Content type -- "image" or "document".

source_type Literal['url', 'base64']

How data is encoded -- "url" (http/https only -- other schemes such as file:// are rejected to prevent SSRF) or "base64".

data str

URL string or base64-encoded content.

media_type str

MIME type (e.g. "image/png", "application/pdf"). Defaults to image/png for kind="image" and application/pdf for kind="document".

filename str | None

Optional filename for documents / PDFs.

CapabilityConfig

Bases: Protocol

Marker protocol for the API-ADR A5 optional-behavior seam (FAC-7).

Optional behavior attaches by passing a typed configuration object, and absence means disabled — there are no boolean feature flags on the facade, ever. W1 registers zero capabilities, so the parameter exists solely so the first real capability is an additive change rather than a signature change.

ConfigurationError

Bases: SymfonicError

Raised when capability flags are violated at compile time.

Promoted from symfonic.core.graph (FERR-2). The facade raises it for bad constructor input, an unregistrable capability, and a provider that cannot satisfy a requested structured output — always before the first provider call (FERR-3).

ContractViolationError

Bases: SymfonicError

Raised when a documented facade contract is misused.

W1's only use is LIF-4: run()/stream() after close(). Closed is terminal and there is no reopen, so continuing would be a silent lie about the agent's state.

A PRE_MODEL, POST_MODEL, PRE_TOOL, POST_TOOL or FINALIZE stage handler may preserve an adopter-defined refusal type across the :class:symfonic.Agent boundary by subclassing this error and setting preserve_contract_identity = True on the subclass. Without that explicit opt-in, a handler exception is wrapped in this base type and retained as its __cause__. PROMPT_ASSEMBLY always wraps, including opted-in subclasses, through its separate prompt-assembly failure guard.

Message

Bases: BaseModel

One typed conversation message (RES-2).

content is text. Attachments are deliberately not replayed through history — the catalogued rule is that they do not participate in hydration or routing, so a round trip through history is lossless for text and documented-lossy for attachments.

StructuredOutputError

StructuredOutputError(message: str)

Bases: SymfonicAgentError

Raised when a structured-output extraction cannot be satisfied.

Carries code="unprocessable" so the FastAPI layer maps it to a 422 rather than a generic 500 — the request was well-formed, but the model could not produce a value matching the requested schema.

Source code in src/symfonic/agent/structured.py
def __init__(self, message: str) -> None:
    super().__init__(message, code="unprocessable")

SymfonicError

Bases: Exception

Root of the simple-facade error taxonomy (FERR-1).

Never raised directly. It exists so an adopter can write one except SymfonicError and catch every failure the facade is documented to raise — and nothing else. Provider SDK exceptions deliberately do not inherit from it: W1 propagates them unchanged rather than shipping a half-built wrapper that swallows the provider's own diagnostics (FERR-4).

TokenUsage

Bases: BaseModel

What the provider reported (RES-4).

0 means "unreported", not "free": the facade never estimates token counts, so a provider that reports nothing leaves the zero value.

ToolCall

Bases: BaseModel

One tool invocation and its outcome (RES-3).

On a completed run exactly one of result / error is non-None. Both are None only on the in-flight tool_call stream event, which is what makes id the join key to the matching tool_result.