Skip to content

symfonic.agent.cutover.projection

projection

Projecting one kernel outcome onto the legacy AgentResponse/StreamChunk.

The outward half of the delegate's translation, split out of :mod:~symfonic.agent.cutover.delegate when TA8.10 gave that module the inbound half to carry as well. The seam is the direction of travel: this module only ever reads a finished kernel value and writes the shape a legacy caller already holds. It compiles nothing, calls no kernel, and holds no state.

Translation is not interpretation. Where the legacy shape has a column the kernel path cannot fill — memory_entries_used, activation_log — the empty value is written rather than an estimate, because the empty value is true and an estimate is a fabrication that survives into a metric.

as_chunk

as_chunk(event: AgentEvent, *, run_id: str) -> StreamChunk | None

Project one kernel event onto the legacy chunk shape.

error raises rather than yielding: the legacy stream contract reports failure by raising out of the generator, and inventing a chunk kind for it would put a failure on the success path of every existing consumer.

ask_user and interrupt fall through to None, and that is a declared gap, not an oversight. TA8.34 made a kernel run able to pause, so this route can now end without a done chunk where before every kernel chunk stream ended on one. It is not a parity regression -- the legacy chunk body has no interrupt chunk shape either, and no event_type="interrupt" or "ask_user" exists anywhere in src/ -- but it means a chunk consumer cannot tell a pause from a completed turn whose terminal went missing. Giving the chunk surface a pause shape is a versioned addition to a public wire contract and belongs with resume (TA8.35), not with the typed projection this task scoped. Recorded in .agent/team/framework-refactor/evidence/RET-PREP/hk1-pause-surface.md §8.

Source code in src/symfonic/agent/cutover/projection.py
def as_chunk(event: AgentEvent, *, run_id: str) -> StreamChunk | None:
    """Project one kernel event onto the legacy chunk shape.

    ``error`` raises rather than yielding: the legacy stream contract reports
    failure by raising out of the generator, and inventing a chunk kind for it
    would put a failure on the success path of every existing consumer.

    **``ask_user`` and ``interrupt`` fall through to ``None``, and that is a
    declared gap, not an oversight.** TA8.34 made a kernel run able to pause, so
    this route can now end without a ``done`` chunk where before every kernel
    chunk stream ended on one. It is not a parity regression -- the legacy chunk
    body has no interrupt chunk shape either, and no ``event_type="interrupt"``
    or ``"ask_user"`` exists anywhere in ``src/`` -- but it means a chunk
    consumer cannot tell a pause from a completed turn whose terminal went
    missing. Giving the chunk surface a pause shape is a versioned addition to a
    public wire contract and belongs with resume (TA8.35), not with the typed
    projection this task scoped. Recorded in
    ``.agent/team/framework-refactor/evidence/RET-PREP/hk1-pause-surface.md`` §8.
    """
    if event.kind == "error":
        from symfonic.agent.types import SymfonicAgentError

        raise SymfonicAgentError(event.error or "the invocation failed")
    if event.kind == "cancelled":
        return None
    if event.kind in {"thinking", "text_delta"}:
        return StreamChunk(event_type=event.kind, data=event.text, run_id=run_id)
    if event.kind in {"tool_call", "tool_result"} and event.tool_call is not None:
        return StreamChunk(
            event_type=event.kind,
            data=_tool_payload(event),
            run_id=run_id,
        )
    if event.kind == "done":
        result = event.result
        return StreamChunk(
            event_type="done",
            data={
                "memory_entries_used": 0,
                "system_prompt_tokens": 0,
                "duration_ms": round(result.duration_ms, 1) if result else 0.0,
                "extracted_ops": [],
                "activation_log": {},
                "final_response": result.text if result else "",
            },
            run_id=run_id,
        )
    return None

as_response

as_response(result: AgentResult[Any], *, run_id: str, session_id: str) -> AgentResponse

Project the kernel's result onto the legacy response shape.

An exhausted round budget is a failure, not an answer. The legacy body raises GraphRecursionError out of the graph runtime when the loop runs past config.agent.recursion_limit; projecting the kernel's stop_reason="tool_limit" onto final_response="" would hand the adopter an empty answer with nothing raised and no fallback recorded, so cutover.fallbacks("invocation.run") would still report a clean migration while the answer was wrong.

It raises :class:~symfonic.agent.cutover.errors.RecursionExhaustedError, which is a GraphRecursionError, so the adopter's existing except GraphRecursionError keeps catching this turn after the flip — failing loudly with a different class would still have broken every handler written against the documented legacy behaviour.

result.text or None reproduces the legacy tri-state rather than narrowing it. The legacy body builds final_response=final_response or None (symfonic.agent.engine), so an answerless turn surfaces as None; writing "" here would be a type change visible to every adopter whose code reads if response.final_response is None — silent, since both are falsy, and shipped by default the day the run switch flips.

Source code in src/symfonic/agent/cutover/projection.py
def as_response(
    result: AgentResult[Any], *, run_id: str, session_id: str
) -> AgentResponse:
    """Project the kernel's result onto the legacy response shape.

    An exhausted round budget is a failure, not an answer. The legacy body
    raises ``GraphRecursionError`` out of the graph runtime when the loop runs
    past ``config.agent.recursion_limit``; projecting the kernel's
    ``stop_reason="tool_limit"`` onto ``final_response=""`` would hand the
    adopter an empty answer with nothing raised and no fallback recorded, so
    ``cutover.fallbacks("invocation.run")`` would still report a clean
    migration while the answer was wrong.

    It raises :class:`~symfonic.agent.cutover.errors.RecursionExhaustedError`,
    which *is* a ``GraphRecursionError``, so the adopter's existing
    ``except GraphRecursionError`` keeps catching this turn after the flip —
    failing loudly with a different class would still have broken every
    handler written against the documented legacy behaviour.

    ``result.text or None`` reproduces the legacy tri-state rather than
    narrowing it. The legacy body builds ``final_response=final_response or
    None`` (``symfonic.agent.engine``), so an answerless turn surfaces as
    ``None``; writing ``""`` here would be a *type* change visible to every
    adopter whose code reads ``if response.final_response is None`` — silent,
    since both are falsy, and shipped by default the day the run switch flips.
    """
    if result.stop_reason == "tool_limit":
        from symfonic.agent.cutover.errors import RecursionExhaustedError

        raise RecursionExhaustedError(
            "recursion_exhausted: the invocation used every model round its "
            "budget allows (config.agent.recursion_limit) without producing a "
            "final answer"
        )
    return AgentResponse(
        final_response=result.text or None,
        messages=as_legacy_messages(result.messages),
        run_id=run_id,
        session_id=session_id,
        duration_ms=result.duration_ms,
        structured=result.output,
    )