Skip to content

symfonic.agent.cutover.turn_request

turn_request

The caller's half of one invocation, built once for all three projections.

Its own module rather than a third function in messages: that module is at its line budget, and "what this turn asks" is a different responsibility from "how a LangChain message becomes a kernel message" -- it uses that conversion rather than being part of it.

turn_request

turn_request(query: str, scope: Any, history: Sequence[Any] | None, attachments: Sequence[Any] | None, *, cap: int, run_id: str = '', root_run_id: str = '', parent_run_id: str | None = None) -> TurnRequest

The caller's half of one invocation -- for every projection alike.

One function for the blocking, streaming and typed projections on purpose: all three travel to the same PromptAssembly and are consumed by the same build_request call, so a divergence between them would be a difference in what the model is asked, invisible in the answer a scripted model returns.

history is converted; attachments are not, and the asymmetry is a fact about the types rather than an omission. The facade takes history as list[BaseMessage] and the kernel carries typed :class:~symfonic.agent.facade_types.Message values, so a converter has to exist and :func:as_kernel_history is it -- it also owns the trim, because the trim operates on the LangChain messages. The facade takes attachments as Sequence[ContentPart] and ContentPart is exactly what symfonic.agent._content_blocks._build_human_content accepts at the far end of the kernel's chain, which is the same function the legacy body calls. Converting them would mean re-encoding a value into the shape it already has, and every such round trip is a place a provider-specific block can lose a field.

Moved here from delegate when that module went over its line budget, for the reason as_kernel_history already lives here: it is message translation.

run_id is the caller's, and leaving it empty is not a neutral default. runner.py builds RequestContext(plan, run_id=request.run_id or None), so an empty field makes the kernel mint an id of its own -- and the turn then has two: the one AgentResponse.run_id echoes back to the caller, and an internal one that nothing else reports but that the delegation lineage handed to children is built from. A child correlated to an id its parent does not answer to is not correlated (issue #131).

Lineage is inherited from the ambient run when the caller names none (issue #131, K2). A SymfonicAgent used as a sub-agent -- the composition every example writes, SubAgent(agent=SymfonicAgent(...)) -- cannot be told its ancestry: SymfonicAgent.run ends in a **state_overrides catch-all, which accepts_run_lineage correctly refuses to send lineage to because that catch-all raises RetiredArgumentError on unknown keys. So the roster withholds it and the child arrives with nothing, a root run correlated by no mechanism at all.

Reading it from the context the child is already running inside closes that without widening a signature the TA8.18 retirement is narrowing. The cost is named rather than hidden: this is context inheritance, so an agent an adopter invokes from their own tool mid-turn also inherits the enclosing run's lineage. A turn with no enclosing run stays a root -- identity is None and both fields stay empty, which is what RunObservation's request.root_run_id or context.run_id already expects.

An explicit argument always wins over the ambient one, so a caller that knows better is never overridden by the context it happens to run in.

Source code in src/symfonic/agent/cutover/turn_request.py
def turn_request(
    query: str,
    scope: Any,
    history: Sequence[Any] | None,
    attachments: Sequence[Any] | None,
    *,
    cap: int,
    run_id: str = "",
    root_run_id: str = "",
    parent_run_id: str | None = None,
) -> TurnRequest:
    """The caller's half of one invocation -- for every projection alike.

    One function for the blocking, streaming and typed projections on purpose:
    all three travel to the same ``PromptAssembly`` and are consumed by the same
    ``build_request`` call, so a divergence between them would be a difference
    in what the model is asked, invisible in the answer a scripted model
    returns.

    **``history`` is converted; ``attachments`` are not, and the asymmetry is a
    fact about the types rather than an omission.** The facade takes ``history``
    as ``list[BaseMessage]`` and the kernel carries typed
    :class:`~symfonic.agent.facade_types.Message` values, so a converter has to
    exist and :func:`as_kernel_history` is it -- it also owns the trim, because
    the trim operates on the LangChain messages. The facade takes
    ``attachments`` as ``Sequence[ContentPart]`` and ``ContentPart`` is exactly
    what ``symfonic.agent._content_blocks._build_human_content`` accepts at the
    far end of the kernel's chain, which is the *same function* the legacy body
    calls. Converting them would mean re-encoding a value into the shape it
    already has, and every such round trip is a place a provider-specific block
    can lose a field.

    Moved here from ``delegate`` when that module went over its line budget, for
    the reason ``as_kernel_history`` already lives here: it is message
    translation.

    **``run_id`` is the caller's, and leaving it empty is not a neutral
    default.** ``runner.py`` builds ``RequestContext(plan, run_id=request.run_id
    or None)``, so an empty field makes the kernel mint an id of its own -- and
    the turn then has two: the one ``AgentResponse.run_id`` echoes back to the
    caller, and an internal one that nothing else reports but that the
    delegation lineage handed to children is built from. A child correlated to
    an id its parent does not answer to is not correlated (issue #131).

    **Lineage is inherited from the ambient run when the caller names none**
    (issue #131, K2). A ``SymfonicAgent`` used as a sub-agent -- the composition
    every example writes, ``SubAgent(agent=SymfonicAgent(...))`` -- cannot be
    *told* its ancestry: ``SymfonicAgent.run`` ends in a ``**state_overrides``
    catch-all, which ``accepts_run_lineage`` correctly refuses to send lineage
    to because that catch-all raises ``RetiredArgumentError`` on unknown keys.
    So the roster withholds it and the child arrives with nothing, a root run
    correlated by no mechanism at all.

    Reading it from the context the child is already running inside closes that
    without widening a signature the TA8.18 retirement is narrowing. The cost is
    named rather than hidden: this is context inheritance, so an agent an
    adopter invokes from their own tool mid-turn also inherits the enclosing
    run's lineage. A turn with no enclosing run stays a root -- ``identity`` is
    ``None`` and both fields stay empty, which is what ``RunObservation``'s
    ``request.root_run_id or context.run_id`` already expects.

    An explicit argument always wins over the ambient one, so a caller that
    knows better is never overridden by the context it happens to run in.
    """
    # Deferred for the import-footprint gate, for the reason
    # ``DelegationTools.delegate`` gives: a delegation needs this, resolving
    # the facade does not.
    from symfonic.kernel.contracts.run_identity import current_run_identity

    identity = current_run_identity()
    return TurnRequest(
        prompt=query,
        scope=scope,
        history=as_kernel_history(history, cap=cap),
        attachments=tuple(attachments or ()),
        run_id=run_id,
        root_run_id=root_run_id or (identity.root_run_id if identity else ""),
        parent_run_id=parent_run_id or (identity.run_id if identity else None),
    )