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.

The shapes themselves belong to :mod:symfonic.agent.response_projection, the native owner the compatibility HTTP routes call (CORE-414). This facade adds only what the delegate measures and those routes do not: memory_entries_used and extracted_ops from its turn observation (CORE-227, :mod:~.turn_effects), and the presented final_response of a run. A caller that passes no observation gets exactly the native projection.

Translation is not interpretation. Where the legacy shape has a column the kernel path cannot fill โ€” activation_log, system_prompt_tokens โ€” 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, effects: TurnObservation | None = None) -> StreamChunk | None

Project one kernel event onto the legacy chunk shape.

The native projection decides every shape, the raised error, and the declared ask_user/interrupt gap; see :func:symfonic.agent.response_projection.as_chunk. With effects, the done chunk reports the measured memory count and operations in place of the native empty values; streamed text is never rewritten.

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

    The native projection decides every shape, the raised ``error``, and the
    declared ``ask_user``/``interrupt`` gap; see
    :func:`symfonic.agent.response_projection.as_chunk`. With ``effects``, the
    ``done`` chunk reports the measured memory count and operations in place
    of the native empty values; streamed text is never rewritten.
    """
    chunk = response_projection.as_chunk(event, run_id=run_id)
    if effects is None or chunk is None or chunk.event_type != "done":
        return chunk
    text = event.result.text if event.result else ""
    return chunk.model_copy(
        update={
            "data": {
                **chunk.data,
                "memory_entries_used": memory_entries_used(effects),
                "extracted_ops": stream_extracted_ops(text, effects),
            }
        }
    )

as_response

as_response(result: AgentResult[Any], *, run_id: str, session_id: str, effects: TurnObservation | None = None) -> AgentResponse

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

The native projection runs first, so an exhausted round budget still raises :class:~symfonic.agent.cutover.errors.RecursionExhaustedError (a GraphRecursionError) before any observation is read; see :func:symfonic.agent.response_projection.as_response. With effects, the measured memory count, the operations, and the presented text replace the native empty values. The presented text keeps the legacy tri-state: an answerless turn is None, not "".

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

    The native projection runs first, so an exhausted round budget still raises
    :class:`~symfonic.agent.cutover.errors.RecursionExhaustedError` (a
    ``GraphRecursionError``) before any observation is read; see
    :func:`symfonic.agent.response_projection.as_response`. With ``effects``,
    the measured memory count, the operations, and the presented text replace
    the native empty values. The presented text keeps the legacy tri-state:
    an answerless turn is ``None``, not ``""``.
    """
    response = response_projection.as_response(result, run_id=run_id, session_id=session_id)
    if effects is None:
        return response
    text, extracted_ops = presented_run_text(result.text, effects)
    return response.model_copy(
        update={
            "final_response": text or None,
            "memory_entries_used": memory_entries_used(effects),
            "extracted_ops": extracted_ops,
        }
    )