Skip to content

symfonic.agent.response_projection

response_projection

Projecting one native outcome onto the compatibility AgentResponse/StreamChunk.

The native compatibility HTTP routes (/chat and /stream, standalone and on the selected root) own no turn observation, so they read a finished kernel value and write the shape a compatibility client already holds. This module compiles nothing, calls no kernel, and holds no state.

:mod:symfonic.agent.cutover.projection keeps the effects-aware facade the engine delegate calls: it delegates the shapes here and overwrites only the fields its turn observation measured (CORE-227). Here those fields carry their empty values, because nothing on these routes measured them.

Translation is not interpretation. Where the compatibility shape has a column the kernel path cannot fill — activation_log, system_prompt_tokens, memory_entries_used, extracted_ops — 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 compatibility chunk shape.

error raises rather than yielding: the chunk 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. The chunk surface has no pause shape, so a paused run ends without a done chunk. Giving it one is a versioned addition to a public wire contract. Recorded in .agent/team/framework-refactor/evidence/RET-PREP/hk1-pause-surface.md §8.

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

    ``error`` raises rather than yielding: the chunk 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.** The chunk surface has no pause shape, so a
    paused run ends without a ``done`` chunk. Giving it one is a versioned
    addition to a public wire contract. 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 compatibility 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.

It raises :class:~symfonic.agent.recursion_exhausted.RecursionExhaustedError, which is a GraphRecursionError, so an existing except GraphRecursionError keeps catching this turn.

result.text or None reproduces the legacy tri-state rather than narrowing it: the legacy body builds final_response=final_response or None, so an answerless turn surfaces as None, not "".

Source code in src/symfonic/agent/response_projection.py
def as_response(result: AgentResult[Any], *, run_id: str, session_id: str) -> AgentResponse:
    """Project the kernel's result onto the compatibility 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.

    It raises :class:`~symfonic.agent.recursion_exhausted.RecursionExhaustedError`,
    which *is* a ``GraphRecursionError``, so an existing ``except
    GraphRecursionError`` keeps catching this turn.

    ``result.text or None`` reproduces the legacy tri-state rather than
    narrowing it: the legacy body builds ``final_response=final_response or
    None``, so an answerless turn surfaces as ``None``, not ``""``.
    """
    if result.stop_reason == "tool_limit":
        from symfonic.agent.recursion_exhausted 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,
        memory_entries_used=0,
        extracted_ops=[],
    )