Skip to content

Deliver stream rendering via a StreamRendering capability - #835

Open
mpfaffenberger wants to merge 3 commits into
mainfrom
feature/stream-rendering-capability
Open

Deliver stream rendering via a StreamRendering capability#835
mpfaffenberger wants to merge 3 commits into
mainfrom
feature/stream-rendering-capability

Conversation

@mpfaffenberger

@mpfaffenberger mpfaffenberger commented Aug 21, 2026

Copy link
Copy Markdown
Owner

What

Eighth in the capability series (#828 SteerInjection, #829 HistoryCompaction, #830 PluginMessageTransform, #831 PerModelSettings, #832 AssembledInstructions, #833 ResolvedModel, #834 McpToolsets): the streaming render pipeline no longer reaches pydantic-ai as a per-run event_stream_handler= kwarg. It is now a first-class capability, StreamRendering, on the wrap_run_event_stream seam.

The problem this seam poses

Unlike the previous seven, this seam is static-vs-per-run: overriding wrap_run_event_stream makes pydantic-ai force streaming mode for every run, while code_puppy's streaming state is inherently per-run — the gate (get_enable_streaming()), the observability group_id, and the StreamingTextDetector whose streamed_text flag 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):

  • The caller (_do_run / _invoke_agent_impl) installs an observation around its run(s): handler, group_id, enabled.
  • StreamRendering.for_run resolves 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_stream is checked on the resolved run capability — verified against installed 2.31.0 source). The static class never trips the streaming check (pinned by test).
  • Enabled → the stock ProcessEventStream observer 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.
  • One observation spans the initial run and every steer/hook follow-up, so streamed_text accumulates across the sequence exactly as the old shared detector did.
  • asyncio.create_task snapshots the context, so the sub-agent run task sees the observation installed by its invoker (pinned by test).

Feature-parity checklist

  • Streaming gate off → non-streamed model requests + one-shot fallback render (a request-only FunctionModel proves no streaming is forced)
  • Gate on → same handler, same per-node invocation, same observability capture (capture_agent_context(group_id)), same text detection
  • Sub-agent path: silencer (low/medium) vs inline main renderer (high), fallback render + SubAgentResponseMessage double-render guard unchanged
  • DBOS: DBOSAgent runs get rendering from the capability (durable base explicitly applies wrap_run_event_stream workflow-side — same replayed-model-events semantics as the per-run kwarg it replaces)
  • ACP / plugins driving run_with_mcp: observation installed inside _do_run, nothing to change

Two honest divergences, both deliberate

  1. on_wrap_pydantic_agent now receives event_stream_handler=None for kind="main" (the sub-agent site always passed None). 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 on DBOSAgent — redundant now that the capability fires for wrapped agents' runs (and previously it could double-render if kept).
  2. The DBOS gate carve-out is now explicit. Today, with streaming off under DBOS, runs still streamed — an emergent effect of the constructor-level fallback handler winning via self.event_stream_handler. That behaviour is preserved on purpose: _do_run enables the observation when use_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_run resolution (inert/no-observation, inert/disabled, ProcessEventStream bound to the observation's detector), non-streamed-run preservation, handler delivery + RunContext, observability capture (and its absence without a group_id), streamed_text accumulation 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 StreamingTextDetector wiring in subagent_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.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant