symfonic.agent.cutover.typed_projection¶
typed_projection ¶
Compatibility imports for the native kernel-to-typed event projection.
The implementation belongs to symfonic.agent.typed_projection so native
recovered continuations do not load cutover policy or the legacy delegate.
These aliases preserve the existing cutover consumers and object identities.
as_typed_events ¶
Project one kernel event onto zero or more typed events, in order.
Order within the returned tuple is part of the contract, not an accident: a tool call announces itself before its arguments, and a tool result arrives before the timing that describes it. A caller that yields the tuple in order preserves the kernel's total order, because the kernel assigns a dense monotonic index and nothing here reorders or renumbers.
Terminal kinds (done, error, cancelled) return (): what a
terminal means is the caller's decision -- an error propagates, a
cancellation emits no normal terminal at all -- and burying either in a
projection would put a failure on the success path.
ask_user and interrupt are the exception, and they are terminal too
(TA8.34). They return their structured event, because a pause is the one
terminal that carries something the consumer has to act on rather than
something the caller has to decide about: the questions, and the token they
are answered with. Leaving that to the caller would mean a paused run
reaching a human-in-the-loop consumer as a stream that simply ended, which
is ST1's typed_interrupt_translation row failing on the new route the
same way it never worked on it before.
Source code in src/symfonic/agent/typed_projection.py
interrupt_events ¶
Project one paused kernel event onto the typed pause vocabulary.
Returns () for anything that is not a pause, so the caller can hand
every event through without branching on the kind twice.
Source code in src/symfonic/agent/typed_interrupts.py
response_complete ¶
The typed terminal, built from what the turn actually produced.
typed_stream
async
¶
Drain one kernel event stream into typed events, in the kernel's order.
The whole typed route in one loop, and everything it does not do is as load-bearing as what it does.
- It never renumbers or reorders. The kernel assigns a dense monotonic index; a projection that sorted or batched would break the ordering guarantee every consumer joins on.
- On an
errorterminal it emits nothing and lets the run's own exception propagate on the next step. Synthesising a typed error event would put a failure on the success path of every existing consumer, and wrapping the exception would hide the class an adopter already catches. - On a
cancelledterminal it emits nothing -- specifically, noResponseCompleteEvent. An incomplete turn may not emit the terminal a completed turn emits, and inventing a final response for a run that was walked away from is fabrication, not cleanup. - On an
ask_userorinterruptterminal this loop emits the pause and noResponseCompleteEvent, for the same reason: a paused run produced no final answer. The pause itself is emitted rather than swallowed because it is the whole point of the terminal -- the loop above yields whateveras_typed_eventsreturns, so the structured event lands in the kernel's own order, ahead of nothing and behind every delta the run produced before it stopped.
That is this function's behaviour, not the public surface's: one layer
up, SymfonicAgent._stream_typed_kernel_impl yields a
ResponseCompleteEvent on every non-GeneratorExit exit, a pause
included, because the legacy body does and the pause path's parity is the
acceptance criterion. So a consumer of stream_typed does see a
terminal after a pause. The difference is deliberate and recorded in
.agent/team/framework-refactor/evidence/RET-PREP/hk1-pause-surface.md
§6 ("One difference is stated rather than discovered").
* The source is closed on every exit path, including the one where the
consumer walks away mid-stream. An abandoned async generator otherwise
finalises whenever the garbage collector reaches it, in nobody's task.
Source code in src/symfonic/agent/typed_projection.py
usage_event ¶
The run's token usage, or None when the provider reported none.
None is the load-bearing case. TokenUsage defaults every count to
0 and documents 0 as unreported, so emitting a UsageEvent for
an all-zero report would publish "this run cost nothing" with the same
confidence as a real measurement. Cost and usage are attributed only where
real evidence exists.
The guard reads input_tokens and output_tokens only, and
deliberately not total_tokens: UsageEvent carries just those two
fields, so a provider reporting TokenUsage(total_tokens=1200) with no
split would produce UsageEvent(input_tokens=0, output_tokens=0) -- two
zeros that look measured, published because real evidence existed
somewhere this event cannot carry. An unsplit total is dropped here rather
than rendered as zeros; carrying it would need a channel that can say
"total, unsplit", which this event has no field for.