You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Give the streaming primitive the instruct-validate-repair retry behavior that SamplingStrategy provides for the non-streaming path, so a streamed generation can retry/repair on validation failure rather than just early-exit.
Two possible interfaces are on the table. One is a dedicated stream_instruct-style entry point, as implemented in Mellea-partials. The other is an optional sampling parameter on stream(), mirroring how validation is already an opt-in parameter; this second option was suggested but not yet evaluated. Both are open; this issue works out both the interface and the mechanism.
Background
This issue began as "allow streaming for functions that return sampling results" (filed 2026-02, referencing the since-closed PR #240). That predates the validated-streaming work and Mellea-partials, arriving independently from the sampling side — it is the origin of the output-shape question below. The body here supersedes that original one-line description with the full scope.
Framing: the streaming counterpart of the existing sampling modes
The non-streaming path already has two sampling-related return modes on act()/instruct(). This issue is their streaming equivalents:
Non-streaming (today)
Streaming (this issue)
act(..., strategy=...) retries/repairs internally, returns the final output
stream(...) gains retry/repair — the control-flow half
act(..., return_sampling_results=True) returns a SamplingResult with every attempt (passed and failed)
streaming surfaces those attempts as they happen — the output-shape half
So this issue has two dimensions: how the retry loop drives a caller's async for (control flow), and what a caller sees when they want the full multi-attempt view rather than just the final stream (output shape). The two map to strategy= and return_sampling_results=True respectively.
Intent (from Mellea-partials)
Mellea-partials provides a stream_instruct: a streaming counterpart to instruct() that runs the whole loop —
Generate a streaming MOT.
Chunk + quick-check each chunk during streaming (per-chunk requirements).
Full-output validation once chunks pass (full-output requirements).
Full-validation failure → strategy.repair() produces an updated instruction/context, retry.
Loop up to strategy.loop_budget; emit events throughout.
Mellea-partials predates the current stream() and was built against the original stream_with_chunking, so its structure reflects that older design. Use it as the reference for what streaming sampling needs to do, not as a design to port — the goal is for this to fall out of the new stream() cleanly.
How the current codebase does sampling (non-streaming)
BaseSamplingStrategy.sample(action, context, backend, requirements, ...) owns the entire generate/validate/repair loop and returns a SamplingResult. It calls generation, awaits the full result, validates, repairs, loops up to loop_budget.
act()/aact() take a strategy= param (default RejectionSamplingStrategy(loop_budget=2)) and delegate the loop to sample().
The core tension to resolve
sample() is built to own the generate-and-validate cycle: it drives generation, awaits the full output, validates, repairs, loops. stream()'s whole point is that the caller drives consumption via async for. These are opposed control-flow models:
sample() wants to be in control of the loop.
stream() hands the loop to the caller.
This tension holds regardless of the interface chosen (param on stream() vs. a wrapper): either way, the open work is how a caller-driven async for interoperates with a loop-owning sampling strategy — specifically, what happens to iteration when a streamed attempt fails validation and the strategy wants to repair and retry. The interface question (param vs. wrapper) is the smaller, later decision; this control-flow mechanism is the substance.
Open questions
Who drives the retry loop? Does stream() internally call strategy.sample() (letting it own the loop, with streaming as an implementation detail inside), or is the sampling loop re-expressed for the streaming/caller-driven model?
What does the caller see across a retry? When attempt 1 fails and attempt 2 begins, does the single async for transparently continue into attempt 2's chunks? Restart? Surface a retry event and re-yield from the top? Mellea-partials emitted RetryEvent and continued; the new model must define this explicitly.
Two retry triggers or one?Mellea-partials distinguished quick-check failure (regenerate, no repair) from full-validation failure (repair then retry). Does the new model keep both, or unify?
Does this reuse BaseSamplingStrategy or bypass it? Phase 1's primitive deliberately bypassed BaseSamplingStrategy (callers own the retry loop, per epic feat: streaming validation — per-chunk requirement checking with early exit #891). Reintroducing sampling here means deciding whether to re-integrate with sample() or keep a parallel streaming-specific loop.
Relationship to full-output validation.stream() already runs full-output validate() on natural completion (refactor: replace stream_with_chunking with single-task stream() #1440). Sampling adds the repair-and-retry layer on top of that — confirm the layering (validation is the check; sampling is what acts on a failed check).
Output shape — what does streaming a multi-attempt run yield? The non-streaming return_sampling_results=True returns a SamplingResult holding every attempt (passed and failed) after completion. Its streaming equivalent has to define what a caller sees live across attempts: a flat chunk stream of only the final/winning attempt, or a richer stream that surfaces each attempt's chunks, validation, and retry as they happen. This is distinct from Q2 (Q2 is "does the async for continue"; this is "what typed view of the whole run is available"). May need a streaming counterpart to SamplingResult, or Streamer fields exposing attempts.
Interface is undecided: a param on stream() vs. a stream_instruct-style wrapper (the Mellea-partials form). Both face the same control-flow mechanism question, which is the real design work.
Give the streaming primitive the instruct-validate-repair retry behavior that
SamplingStrategyprovides for the non-streaming path, so a streamed generation can retry/repair on validation failure rather than just early-exit.Two possible interfaces are on the table. One is a dedicated
stream_instruct-style entry point, as implemented inMellea-partials. The other is an optional sampling parameter onstream(), mirroring how validation is already an opt-in parameter; this second option was suggested but not yet evaluated. Both are open; this issue works out both the interface and the mechanism.Background
This issue began as "allow streaming for functions that return sampling results" (filed 2026-02, referencing the since-closed PR #240). That predates the validated-streaming work and
Mellea-partials, arriving independently from the sampling side — it is the origin of the output-shape question below. The body here supersedes that original one-line description with the full scope.Framing: the streaming counterpart of the existing sampling modes
The non-streaming path already has two sampling-related return modes on
act()/instruct(). This issue is their streaming equivalents:act(..., strategy=...)retries/repairs internally, returns the final outputstream(...)gains retry/repair — the control-flow halfact(..., return_sampling_results=True)returns aSamplingResultwith every attempt (passed and failed)So this issue has two dimensions: how the retry loop drives a caller's
async for(control flow), and what a caller sees when they want the full multi-attempt view rather than just the final stream (output shape). The two map tostrategy=andreturn_sampling_results=Truerespectively.Intent (from
Mellea-partials)Mellea-partialsprovides astream_instruct: a streaming counterpart toinstruct()that runs the whole loop —strategy.repair()).strategy.repair()produces an updated instruction/context, retry.strategy.loop_budget; emit events throughout.Mellea-partialspredates the currentstream()and was built against the originalstream_with_chunking, so its structure reflects that older design. Use it as the reference for what streaming sampling needs to do, not as a design to port — the goal is for this to fall out of the newstream()cleanly.How the current codebase does sampling (non-streaming)
BaseSamplingStrategy.sample(action, context, backend, requirements, ...)owns the entire generate/validate/repair loop and returns aSamplingResult. It calls generation, awaits the full result, validates, repairs, loops up toloop_budget.act()/aact()take astrategy=param (defaultRejectionSamplingStrategy(loop_budget=2)) and delegate the loop tosample().The core tension to resolve
sample()is built to own the generate-and-validate cycle: it drives generation, awaits the full output, validates, repairs, loops.stream()'s whole point is that the caller drives consumption viaasync for. These are opposed control-flow models:sample()wants to be in control of the loop.stream()hands the loop to the caller.This tension holds regardless of the interface chosen (param on
stream()vs. a wrapper): either way, the open work is how a caller-drivenasync forinteroperates with a loop-owning sampling strategy — specifically, what happens to iteration when a streamed attempt fails validation and the strategy wants to repair and retry. The interface question (param vs. wrapper) is the smaller, later decision; this control-flow mechanism is the substance.Open questions
stream()internally callstrategy.sample()(letting it own the loop, with streaming as an implementation detail inside), or is the sampling loop re-expressed for the streaming/caller-driven model?async fortransparently continue into attempt 2's chunks? Restart? Surface a retry event and re-yield from the top?Mellea-partialsemittedRetryEventand continued; the new model must define this explicitly.Mellea-partialsdistinguished quick-check failure (regenerate, no repair) from full-validation failure (repair then retry). Does the new model keep both, or unify?BaseSamplingStrategyor bypass it? Phase 1's primitive deliberately bypassedBaseSamplingStrategy(callers own the retry loop, per epic feat: streaming validation — per-chunk requirement checking with early exit #891). Reintroducing sampling here means deciding whether to re-integrate withsample()or keep a parallel streaming-specific loop.stream()already runs full-outputvalidate()on natural completion (refactor: replace stream_with_chunking with single-task stream() #1440). Sampling adds the repair-and-retry layer on top of that — confirm the layering (validation is the check; sampling is what acts on a failed check).return_sampling_results=Truereturns aSamplingResultholding every attempt (passed and failed) after completion. Its streaming equivalent has to define what a caller sees live across attempts: a flat chunk stream of only the final/winning attempt, or a richer stream that surfaces each attempt's chunks, validation, and retry as they happen. This is distinct from Q2 (Q2 is "does theasync forcontinue"; this is "what typed view of the whole run is available"). May need a streaming counterpart toSamplingResult, orStreamerfields exposing attempts.Notes
stream()primitive) only; independent of per-validator chunking (feat: per-requirement chunking strategies #1441) andstream_parsed_repr(feat(core): stream_parsed_repr — streaming parsed representation on the MOT #1442).stream()vs. astream_instruct-style wrapper (theMellea-partialsform). Both face the same control-flow mechanism question, which is the real design work.