Skip to content

feat: allow streaming for sampling results #403

Description

@jakelorocco

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 —

  1. Generate a streaming MOT.
  2. Chunk + quick-check each chunk during streaming (per-chunk requirements).
  3. Quick-check failure → cancel, retry immediately (regenerate, without strategy.repair()).
  4. Full-output validation once chunks pass (full-output requirements).
  5. Full-validation failurestrategy.repair() produces an updated instruction/context, retry.
  6. 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

  1. 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?
  2. 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.
  3. 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?
  4. 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.
  5. 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).
  6. 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.

Notes

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area/samplingSamplingStrategy, SamplingResult, ModelOption, generation optionsarea/streamingStreaming chunks, events, per-chunk validationenhancementNew feature or requestp2Medium/low: minor bugs, niche features, polish, docs, tests, cleanup. Scoped, lower urgency.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions