Skip to content

symfonic.agent.backend

backend

The simple facade's adapter onto the W2 invocation kernel.

This package is what replaced symfonic.agent._bootstrap. The bootstrap was a second way to set up an invocation, sanctioned in W1 only because the legacy engine could not back a hermetic, quiet facade, and chartered to disappear the moment a kernel existed. It has: the loop it carried now lives once, in symfonic.kernel, and what remains here is adaptation — the wire format, the provider handshake, and the facade's own value types.

Nothing in this package drives a round loop, decides how many provider calls an invocation may make, or assigns event indices. Those are kernel decisions, and keeping them there is the difference between an adapter and a second engine.

AgentPlanFactory dataclass

AgentPlanFactory(model_provider: Any, instructions: str | None, tools: NormalizedTools, max_model_rounds: int = MAX_TOOL_ITERATIONS, model: ModelConfig | None = None, role_models: Mapping[str, ModelConfig] = dict(), stages: tuple[Any, ...] = (), stage_handlers: Mapping[str, Any] = dict(), tool_preconditions: tuple[Any, ...] = (), authorized_effects: frozenset[str] = frozenset({'model_call'}), capability_names: tuple[str, ...] = ())

Turns the facade's four construction inputs into a compiled plan.

compile

compile(output_type: type[BaseModel] | None, *, sink_factory: Callable[[ModelResolution], Any] | None = None) -> InvocationPlan

Validate eagerly, bind the ports, and compile exactly one plan.

sink_factory is the seam ServiceBindings.event_sink reserved. It is called at most once, here, with the ModelResolution this plan was compiled with, and whatever it returns is bound as the sink; None — the simple facade's answer, which has no callback input — leaves the binding unset, and InvocationRunner then constructs no CallbackEventAdapter at all.

A factory rather than a sink because the two things a sink needs are produced on opposite sides of this call: the caller owns the run's identity (its id, its session, its tenant) and this method owns the model that identity has to name. Handing the resolution out rather than making the caller re-derive it keeps one answer to "which model ran?" — the caller's copy cannot drift from the plan's, because it is the plan's.

Binding it here rather than on the caller's side is also what makes the sink's reference frozen at compile time along with every other port (IPL-4). equality_key excludes bindings, so a bound sink changes neither the config digest nor plan identity (IPL-7): two runs of the same agent still compile the same plan shape with different sinks.

Source code in src/symfonic/agent/backend/plan.py
def compile(
    self,
    output_type: type[BaseModel] | None,
    *,
    sink_factory: Callable[[ModelResolution], Any] | None = None,
) -> InvocationPlan:
    """Validate eagerly, bind the ports, and compile exactly one plan.

    ``sink_factory`` is the seam ``ServiceBindings.event_sink`` reserved.
    It is called at most once, here, with the ``ModelResolution`` this plan
    was compiled with, and whatever it returns is bound as the sink;
    ``None`` — the simple facade's answer, which has no callback input —
    leaves the binding unset, and ``InvocationRunner`` then constructs no
    ``CallbackEventAdapter`` at all.

    A *factory* rather than a sink because the two things a sink needs are
    produced on opposite sides of this call: the caller owns the run's
    identity (its id, its session, its tenant) and this method owns the
    model that identity has to name. Handing the resolution out rather than
    making the caller re-derive it keeps one answer to "which model ran?" —
    the caller's copy cannot drift from the plan's, because it *is* the
    plan's.

    Binding it here rather than on the caller's side is also what makes the
    sink's reference frozen at compile time along with every other port
    (IPL-4). ``equality_key`` excludes bindings, so a bound sink changes
    neither the config digest nor plan identity (IPL-7): two runs of the
    same agent still compile the same plan shape with different sinks.
    """
    validate_output_type(output_type)

    config = self._resolve_config()
    chat_model = self.model_provider.get_chat_model(config)
    response_model = chat_model
    if output_type is not None:
        # Some OpenAI-compatible reasoning models need a different request
        # mode for the terminal schema pass than for the conversational
        # turn.  Keep this an optional provider seam so existing adopter
        # providers continue to work unchanged; the ordinary model still
        # owns every tool/reasoning round.
        structured_model = getattr(
            self.model_provider, "get_structured_chat_model", None
        )
        if callable(structured_model):
            specialized = structured_model(config)
            if specialized is not None:
                response_model = specialized
        require_structured_output(response_model, output_type)

    bound = chat_model
    if self.tools:
        bound = chat_model.bind_tools(list(self.tools.tools))
    from symfonic.agent.backend.binding import palette_projector

    adapted = ModelAdapter(bound)
    projector = palette_projector(
        self.model_provider,
        config,
        chat_model,
        self.tools.tools if self.tools else (),
        adapted,
        ModelAdapter,
    )

    resolution = ModelResolution(
        # T3.1.1: the detected wire family, not the provider class name.
        # ``ModelResolution.provider_family`` is documented as a family and
        # is read as one downstream (cache dialect, content-block encoding,
        # diagnostics); stamping ``"OpenAIProvider"`` here made every one of
        # those reads miss without ever raising.
        provider_family=detect_provider_family(self.model_provider, config),
        model_name=getattr(config, "model_name", None),
        response_format=ResponseFormat(
            mode="structured" if output_type is not None else "text",
            schema_name=output_type.__name__ if output_type else None,
        ),
    )

    return compile_invocation_plan(
        CompileRequest(
            config_digest=self._digest(config, output_type),
            model=resolution,
            instructions=self.instructions,
            tools=tuple(
                ToolDescriptor(name=tool.name, description=tool.description or "")
                for tool in self.tools.tools
            ),
            stages=self.stages,
            capabilities=self.capability_names,
            bindings=ServiceBindings(
                conversation=ConversationAdapter(self.model_provider),
                model=adapted,
                palette=projector,
                tools=ToolAdapter(self.tools),
                response=ResponseAdapter(
                    response_model,
                    output_type,
                    repair_attempts=config.structured_output_repair_attempts,
                ),
                stage_handlers=dict(self.stage_handlers),
                tool_preconditions=tuple(self.tool_preconditions),
                # TA8.20 filled the seam this row reserved. The simple Agent
                # facade still has no callback input and still passes no
                # factory, so its plans bind nothing and pay nothing; the
                # cutover delegate passes
                # ``symfonic.agent.cutover.observability.RunObservability``,
                # which composes the run's ``ObservabilityBridge`` from the
                # resolution above.
                event_sink=(
                    None if sink_factory is None else sink_factory(resolution)
                ),
            ),
            # Nothing a capability declared reaches this union, and there
            # is no field on this factory through which it could: what a
            # capability asked for is a *narrowing* of what the facade
            # already granted, checked at fold time. An earlier version
            # unioned in a ``capability_grants`` field, which let a
            # capability widen the plan by declaring a grant; the field is
            # gone rather than merely unused, so the bypass has no seam to
            # come back through.
            effect_grants=frozenset(self.authorized_effects)
            | ({"tool_call"} if self.tools else frozenset()),
            limits=PlanLimits(max_model_rounds=self.max_model_rounds),
            event_program=EventProgram(
                emitted=_EMITTED,
                adapters=tuple(ADAPTER_POLICIES.values()),
            ),
        )
    )

merge_capability_tools

merge_capability_tools(adopter: Sequence[Any], contributed: Sequence[tuple[str, Any]]) -> NormalizedTools

Normalise the adopter's tools and the capabilities' into one set.

One set, because the tool path is read in four places — bind_tools, the G4 manifest, ToolAdapter, and the tool_call grant — and a second collection that only some of them consult is exactly how a tool becomes bindable and not callable.

contributed is (capability, tool) pairs, which is why this exists rather than a bare normalize_tools(list(a) + list(b)). Every refusal below names the capability. A collision reported as "tools[4] resolves to the name 'run_agent', which is already registered by an earlier tool" is true and tells the adopter nothing about which capability to configure — and unlike tools=[...], the adopter did not write the offending list and cannot see it.

The adopter's tools go first and keep the plain index in their messages, so an error in a hand-written list reads exactly as it did before capabilities could contribute anything.

Raises:

Type Description
ConfigurationError

for a non-tool object, or for any name collision — capability against adopter, or capability against capability.

Source code in src/symfonic/agent/backend/tools.py
def merge_capability_tools(
    adopter: Sequence[Any],
    contributed: Sequence[tuple[str, Any]],
) -> NormalizedTools:
    """Normalise the adopter's tools and the capabilities' into one set.

    One set, because the tool path is read in four places — ``bind_tools``, the
    G4 manifest, ``ToolAdapter``, and the ``tool_call`` grant — and a second
    collection that only some of them consult is exactly how a tool becomes
    bindable and not callable.

    ``contributed`` is ``(capability, tool)`` pairs, which is why this exists
    rather than a bare ``normalize_tools(list(a) + list(b))``. Every refusal
    below names the capability. A collision reported as "tools[4] resolves to
    the name 'run_agent', which is already registered by an earlier tool" is
    true and tells the adopter nothing about which capability to configure —
    and unlike ``tools=[...]``, the adopter did not write the offending list
    and cannot see it.

    The adopter's tools go first and keep the plain index in their messages, so
    an error in a hand-written list reads exactly as it did before capabilities
    could contribute anything.

    Raises:
        ConfigurationError: for a non-tool object, or for any name collision —
            capability against adopter, or capability against capability.
    """
    merged = normalize_tools(adopter)
    if not contributed:
        return merged

    # Local: ``agent.cutover`` imports this module's package, so a top-level
    # import closes a cycle. It is also the cheaper placement -- an agent with
    # no contributed tools never reaches this line.
    from symfonic.agent.cutover.delegation import bind_contributed_tool

    resolved = list(merged.tools)
    by_name = dict(merged.by_name or {})
    owner: dict[str, str] = {}

    for capability, candidate in contributed:
        try:
            # A capability contributes a *description* -- a name, a
            # description, a coroutine -- rather than a runtime tool object,
            # so that a capability package does not carry the tool library on
            # its import path. Wrapping it in the type the runtime binds is
            # the composition root's job, and for a kernel agent this is that
            # step. Anything already bound passes through untouched.
            tool = adapt_tool(bind_contributed_tool(candidate), len(resolved))
        except ConfigurationError as exc:
            # Not ``_adapt``'s message with a prefix. That message is written
            # for a hand-written list -- it says ``tools[4]``, an index into a
            # merged sequence the adopter never wrote and cannot inspect. What
            # the adopter can act on is the capability's name and the name the
            # thing claimed to have.
            offered = getattr(candidate, "name", None)
            raise ConfigurationError(
                f"capability {capability!r} contributed "
                + (f"{offered!r}, which is " if offered else "")
                + f"not a tool: a {type(candidate).__name__} is neither a "
                "@symfonic_tool object, a LangChain BaseTool, nor a plain "
                "annotated callable. A capability offering a tool has to offer "
                "something executable: a name and a description reach the "
                "manifest and the model, and then the executor has nothing to "
                "call."
            ) from exc
        if tool.name in by_name:
            held = owner.get(tool.name)
            raise ConfigurationError(
                f"capability {capability!r} contributed a tool named "
                f"{tool.name!r}, which is already registered by "
                + (f"capability {held!r}." if held else "a tool passed to Agent(tools=[...]).")
                + " Two tools cannot share a name: the manifest is keyed by it "
                "and the executor looks calls up by it, so one of them would be "
                "bound and never reachable."
            )
        by_name[tool.name] = tool
        owner[tool.name] = capability
        resolved.append(tool)

    return NormalizedTools(tools=tuple(resolved), by_name=by_name)

normalize_tools

normalize_tools(tools: Sequence[Any]) -> NormalizedTools

Accept the three documented input forms; reject everything else.

Accepted (FAC-6): an object produced by @symfonic_tool(...), any LangChain BaseTool, or a plain Python callable with annotated parameters and a docstring — adapted through symfonic_tool() with its documented defaults.

Raises:

Type Description
ConfigurationError

for a non-tool object (naming it and its index), or for two tools resolving to the same name. Last-one-wins would silently drop a tool the adopter believes is registered, so the collision is a hard error rather than a warning.

Source code in src/symfonic/agent/backend/tools.py
def normalize_tools(tools: Sequence[Any]) -> NormalizedTools:
    """Accept the three documented input forms; reject everything else.

    Accepted (FAC-6): an object produced by ``@symfonic_tool(...)``, any
    LangChain ``BaseTool``, or a plain Python callable with annotated
    parameters and a docstring — adapted through ``symfonic_tool()`` with its
    documented defaults.

    Raises:
        ConfigurationError: for a non-tool object (naming it and its index),
            or for two tools resolving to the same name. Last-one-wins would
            silently drop a tool the adopter believes is registered, so the
            collision is a hard error rather than a warning.
    """
    resolved: list[BaseTool] = []
    by_name: dict[str, BaseTool] = {}

    for index, candidate in enumerate(tools):
        tool = adapt_tool(candidate, index)
        if tool.name in by_name:
            raise ConfigurationError(
                f"tools[{index}] resolves to the name {tool.name!r}, which is "
                "already registered by an earlier tool. Two tools cannot share "
                "a name; rename one with @symfonic_tool(name=...)."
            )
        by_name[tool.name] = tool
        resolved.append(tool)

    return NormalizedTools(tools=tuple(resolved), by_name=by_name)