symfonic.agent.stream_contract¶
stream_contract ¶
Task #25 option C — the 11.0 untyped-stream chunk contract.
.claude/docs/2026-08-20-task-25-stream-chunk-payload-decision.md Addendum 2
chose C: the target shape is option B's — StreamChunk.data on
event_type="text_delta" is the answer delta as str, at the grain
stream_typed already emits, with the kernel's kind set — and the schedule
is the whole difference. C ships that shape at the first release that already
rejects the old one, and leaves 9.x / 10.x yielding the LangGraph node
mappings they have always yielded.
This module is that decision, implemented. Two things live here and nothing else:
- The release-line predicate. Which line publishes the contract, answered from the installed distribution version rather than from a private flag. The 9.x/10.x behaviour is not "old code left behind" — it is the published contract of those lines, so the branch is a first-class part of the design and is exercised by tests on both sides.
- The projection itself (:class:
TextDeltaProjection), so the11.0body ofSymfonicAgent._stream_implstays a short delegation rather than a second copy of_stream_typed_implinlined into an already-oversized module.
On the duplicated version marker. 11.0 is not decided here. The
authority is
symfonic.services.switching.release.FIRST_REJECTING_RETIREMENT_VERSION
(itself the normative name from T1.2.6, and the value RET-8 published in
retirement-record.toml). That module is a runtime-service internal and this
one is facade-compiler, whose only licensed edge into runtime-service is a
declared port (dependency-matrix.md); importing it here is the layering
violation test_the_facade_reaches_runtime_services_only_through_a_port
exists to catch. So the marker is mirrored, and the mirror is
machine-checked: tests/agent/test_stream_contract.py fails if the two
strings ever disagree. release.py's own warning — "three copies of 10.4 in
three files is how a version boundary quietly becomes two boundaries" — is
answered by the test, not by the comment.
TextDeltaProjection ¶
Project the runtime's typed events onto 11.0 untyped chunks.
Three properties are the acceptance criteria of the decision, and each is a consequence of how this projects rather than of a check bolted on afterwards:
- Concatenated deltas reconstruct
final_responseexactly. Every delta is appended to :attr:textas it is yielded, and the terminaldonepayload is built from that same accumulation (:attr:final_response) — so the two cannot drift. The alternative, deriving the terminal text separately from the runtime's last node update, is precisely how a filter change would leave the deltas and the final answer disagreeing with nothing to notice. - The deltas are JSON-safe. They are
str. The 9.x payload was a mapping carrying liveAIMessageobjects, whichjson.dumpsrefuses without adefault. - The kind set matches the kernel's.
thinkingis emitted only when the model actually produced thinking text, carrying that text — which is whatcutover.projection.as_chunkdoes — instead of the unconditionaldata=Nonechunk the 9.x body yields before any I/O. On a turn with no thinking deltas both routes therefore emit{text_delta, done}, and on a reasoning turn both emitthinkingwith astr. Convergence, not suppression: blunt-droppingthinkingwould match the measured scenario and diverge again the first time a reasoning model was used.
spreading_activation has no such convergent form — the kernel's
EventKind taxonomy excludes it by construction — so 11.0 drops it
from the untyped stream. It remains on stream_typed as
ActivationEvent, which is where the decision put it.
consolidating is deliberately not touched. Addendum 2 §1 leaves
exactly that one kind unsigned, because admit_invocation refuses
auto_consolidate=True and no turn in the parity harness can emit it.
Dropping it here would sign a kind set that was never observed.
Source code in src/symfonic/agent/stream_contract.py
extracted_ops
property
¶
Memory-extraction ops the filter parsed out of the deltas.
chunks
async
¶
Yield 11.0 chunks for one runtime event stream.
Does not close events; the caller owns that, because the caller is
the one that has to schedule consolidation on GeneratorExit.
Source code in src/symfonic/agent/stream_contract.py
final_response ¶
The terminal final_response.
Identical to :attr:text whenever anything streamed, which is what
makes reconstruction exact. fallback covers a provider that emitted
no TextDeltaEvent at all: on such a turn there is nothing to
reconstruct from, and answering with the graph's own final text is
strictly better than answering with "". That turn is the one shape
where concatenation does not reproduce the terminal payload, and it is
the same shape on which stream_typed and stream_text already
yield nothing — this contract does not make it worse, and inventing a
synthetic whole-answer delta to paper over it would break the grain
half of the target.
The fallback is passed in rather than derived here so that this module
stays free of an import back into engine, whose
_find_final_response is the function that knows how to walk a
LangGraph state dict.
Source code in src/symfonic/agent/stream_contract.py
flush ¶
Drain both filters into any deltas they were still withholding.
Order matters and is the same as _stream_typed_impl's: the
extraction filter may release real prose, that prose still has to pass
the citation filter, and only then does the citation filter drain. A
flush that skipped the middle step would emit an unscrubbed citation
tag on exactly the turns where a tag landed on a chunk boundary.
Source code in src/symfonic/agent/stream_contract.py
installed_release_line ¶
The installed distribution version, or a conservative fallback.
Source code in src/symfonic/agent/stream_contract.py
numeric_release_line ¶
The leading numeric release segment of version, dotted, or "0".
Tolerant on purpose: a local build may carry 11.0.0.dev3+local or
11.0rc1, and refusing to parse those would silently drop such a build
onto the pre-11.0 branch — the same wrong answer as the fallback, arrived
at without anyone noticing. Only the digits before the first non-numeric
component decide the line.
Public since TA8.54, which needed the string rather than the tuple:
services.switching.release.parse_version is strict (int(part), so
9.13.0.dev0 raises ConfigurationError), and every agent build now
hands it a version. Normalising through this one function is what keeps
"which line is this build on?" a single answer — a second tolerant parser
would be free to disagree with this one about a dev build, and the
disagreement would only show up on a release candidate.
Source code in src/symfonic/agent/stream_contract.py
publishes_text_deltas ¶
Does package_version ship the text-delta chunk contract?
Pure, and separately testable from whatever the checkout happens to be
installed as. >= rather than == so 11.1 and 12.0 keep the
contract they inherited.
Source code in src/symfonic/agent/stream_contract.py
text_delta_contract_is_published ¶
Does this installation publish it? The seam _stream_impl reads.