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=[],
)
|