Skip to content

symfonic.agent.backend.provider_history

provider_history

What one completed round leaves on the wire for the provider's next call.

turn.payload is the round as the provider produced it, and reply receipts bind it by identity (Transcript._record_successful_reply). So nothing here mutates or replaces it: the wire gets its own message, and only when the provider's object is not already the one legacy would replay.

Two things differ from the payload, both toward what the legacy body sends:

  • A streamed round aggregates to an AIMessageChunk. Legacy replays the AIMessage LangChain's generate_from_stream builds from that same aggregate, through the same :func:message_chunk_to_message.
  • A tool observation carries name=<tool>, as LangGraph's ToolNode writes it. Some serializers read it (Gemini names its FunctionResponse from ToolMessage.name first). The name is correlated from the assistant reply's tool call with the same id, never stored on Message.

names_by_call_id

names_by_call_id(calls: Iterable[Any]) -> dict[str, str]

Tool name per call id, from LangChain call dicts or facade ToolCalls.

Source code in src/symfonic/agent/backend/provider_history.py
def names_by_call_id(calls: Iterable[Any]) -> dict[str, str]:
    """Tool name per call id, from LangChain call dicts or facade ``ToolCall``s."""
    names: dict[str, str] = {}
    for call in calls:
        if isinstance(call, dict):
            call_id, name = call.get("id"), call.get("name")
        else:
            call_id, name = getattr(call, "id", None), getattr(call, "name", None)
        if call_id and name:
            names[str(call_id)] = str(name)
    return names

replayed_reply

replayed_reply(payload: Any, call_ids: Sequence[str] | None = None) -> Any

The assistant reply as the provider history carries it.

call_ids remaps tool call ids onto the facade's; None is a round that requested no tool. A payload that is not a chunk is returned as is when its ids already match, so an invoked reply stays the same object.

Source code in src/symfonic/agent/backend/provider_history.py
def replayed_reply(payload: Any, call_ids: Sequence[str] | None = None) -> Any:
    """The assistant reply as the provider history carries it.

    ``call_ids`` remaps tool call ids onto the facade's; ``None`` is a round
    that requested no tool. A payload that is not a chunk is returned as is
    when its ids already match, so an invoked reply stays the same object.
    """
    reply = message_chunk_to_message(payload) if payload is not None else None
    if call_ids is None or reply is None:
        return reply
    return remap_tool_call_ids(reply, call_ids)

tool_observations

tool_observations(reply: Any, outcomes: Sequence[Any]) -> list[ToolMessage]

Each tool outcome as the ToolMessage the provider is handed back.

name comes from reply's call with the outcome's id; an outcome no call names keeps None, which is what it carried before. Failed or refused calls remain errors on the provider wire.

Source code in src/symfonic/agent/backend/provider_history.py
def tool_observations(reply: Any, outcomes: Sequence[Any]) -> list[ToolMessage]:
    """Each tool outcome as the ``ToolMessage`` the provider is handed back.

    ``name`` comes from ``reply``'s call with the outcome's id; an outcome no
    call names keeps ``None``, which is what it carried before. Failed or
    refused calls remain errors on the provider wire.
    """
    names = names_by_call_id(getattr(reply, "tool_calls", None) or ())
    return [
        ToolMessage(
            content=(outcome.result if outcome.error is None else outcome.error) or "",
            tool_call_id=outcome.id,
            name=names.get(outcome.id),
            status="error" if outcome.error is not None else "success",
        )
        for outcome in outcomes
    ]