Deliver stream rendering via a StreamRendering capability - #835
Open
mpfaffenberger wants to merge 3 commits into
Open
Deliver stream rendering via a StreamRendering capability#835mpfaffenberger wants to merge 3 commits into
mpfaffenberger wants to merge 3 commits into
Conversation
Promote the streaming render pipeline from the per-run event_stream_handler= kwarg to a first-class pydantic-ai capability on the wrap_run_event_stream seam (eighth in the capability series). * New code_puppy/agents/_stream_rendering.py: StreamRendering resolves a context-local StreamObservation once per run (for_run). No observation or a disabled one resolves inert, so gated-off runs stay non-streamed; an enabled one delivers the detector-wrapped handler through the stock ProcessEventStream observer (tee semantics, back-pressure preserved). * _runtime._do_run installs one observation across the initial run and every steer/hook follow-up, so streamed_text accumulates exactly as the old shared StreamingTextDetector did. The DBOS carve-out (plugins that render their own output used to stream regardless of the gate via their wrapper's constructor-level handler) is preserved explicitly. * subagent_invocation installs its own observation (silencer or inline renderer) around the create_task'd run; the task's context snapshot carries it. * on_wrap_pydantic_agent now passes event_stream_handler=None for the main build too (sub-agent site always did) - wrapped agents get rendering from the capability, so constructor-level fallback handlers are no longer needed. Hook signature unchanged. 19 contract tests in tests/agents/test_stream_rendering_capability.py.
The sub-agent inline check pinned the old wiring (StreamingTextDetector wrap in subagent_invocation). Detection now lives in the StreamObservation installed around the run and the handler reaches pydantic-ai via the StreamRendering capability - pin that instead.
Four additions from the PR #835 review pass: - delta ordering through the observed view - early-returning handler does not stall the run - raising handler propagates to the run (parity with the kwarg) - full nested sub-agent topology: shadow, create_task snapshot, restore, and per-run event isolation
This was referenced Aug 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Eighth in the capability series (#828
SteerInjection, #829HistoryCompaction, #830PluginMessageTransform, #831PerModelSettings, #832AssembledInstructions, #833ResolvedModel, #834McpToolsets): the streaming render pipeline no longer reaches pydantic-ai as a per-runevent_stream_handler=kwarg. It is now a first-class capability,StreamRendering, on thewrap_run_event_streamseam.The problem this seam poses
Unlike the previous seven, this seam is static-vs-per-run: overriding
wrap_run_event_streammakes pydantic-ai force streaming mode for every run, while code_puppy's streaming state is inherently per-run — the gate (get_enable_streaming()), the observabilitygroup_id, and theStreamingTextDetectorwhosestreamed_textflag the runtime reads after the run to decide on the one-shot fallback render.The two meet through a context-local
StreamObservation(code_puppy/agents/_stream_rendering.py):_do_run/_invoke_agent_impl) installs an observation around its run(s): handler,group_id,enabled.StreamRendering.for_runresolves it once per run. No observation, or a disabled one → an inert capability that does not override the seam, so pydantic-ai keeps the run non-streamed (has_wrap_run_event_streamis checked on the resolved run capability — verified against installed 2.31.0 source). The static class never trips the streaming check (pinned by test).ProcessEventStreamobserver delivers the detector-wrapped handler: tee semantics, synchronous delivery, so the pause gate's back-pressure and handler-exception propagation match the old direct-consumption behaviour.streamed_textaccumulates across the sequence exactly as the old shared detector did.asyncio.create_tasksnapshots the context, so the sub-agent run task sees the observation installed by its invoker (pinned by test).Feature-parity checklist
FunctionModelproves no streaming is forced)capture_agent_context(group_id)), same text detectionSubAgentResponseMessagedouble-render guard unchangedDBOSAgentruns get rendering from the capability (durable base explicitly applieswrap_run_event_streamworkflow-side — same replayed-model-events semantics as the per-run kwarg it replaces)run_with_mcp: observation installed inside_do_run, nothing to changeTwo honest divergences, both deliberate
on_wrap_pydantic_agentnow receivesevent_stream_handler=Noneforkind="main"(the sub-agent site always passedNone). The hook signature is unchanged; the docstring documents it. Sole known reader is the DBOS plugin, which used the handler as a constructor-level fallback onDBOSAgent— redundant now that the capability fires for wrapped agents' runs (and previously it could double-render if kept).self.event_stream_handler. That behaviour is preserved on purpose:_do_runenables the observation whenuse_streaming or skip_fallback_render(a plugin that skips the fallback render is declaring the stream its output path). Without this, DBOS + gate-off would render nothing.get_serialization_name() → None(resolution depends on ambient context-local state; series precedent for non-spec-constructible capabilities).Tests
23 contract tests in
tests/agents/test_stream_rendering_capability.py: context install/restore (incl. error paths and post-block reads),for_runresolution (inert/no-observation, inert/disabled,ProcessEventStreambound to the observation's detector), non-streamed-run preservation, handler delivery +RunContext, observability capture (and its absence without agroup_id),streamed_textaccumulation across sequential runs, create_task context propagation, non-text event detection, both construction sites, and the hook-contract change.One existing test updated: the high-mode audit checklist pinned the old
StreamingTextDetectorwiring insubagent_invocation— now pins the capability delivery.Full suite: 7597 passed; the single red (
test_render_version_check_current) fails identically with this branch's changes stashed — pre-existing on main.Heads-up
Eighth PR touching the shared
capabilities=[...]blocks in_builder.py+subagent_invocation.py— whichever of #828–#835 lands last inherits trivial rebases.Do not merge yet — awaiting review.