streamemit

package
v0.260806.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 6, 2026 License: MPL-2.0 Imports: 8 Imported by: 0

README

streamemit

Decoupled emission layer on top of the Anthropic stream assemblers in internal/protocol/assembler.

Why this package exists

The provider-side stream from Anthropic interleaves several kinds of content blocks — text, thinking, tool_use, and various tool-result variants — inside one event sequence. Today the passthrough handler in internal/protocol/stream/anthropic_passthrough.go forwards each event to the SSE consumer the moment it arrives:

provider ──► ProcessStream ──► SSEvent ──► consumer

That is fine when the consumer is happy to render partial content as it arrives. It breaks down for two real use cases:

  1. Independent visibility into tool vs. message assembly. Recording, auditing, and routing code want to ask "have I finished the tool call yet?" or "what text has the model produced so far?" without reimplementing the per-block state machine each time.
  2. Atomic tool-call delivery. Some downstream consumers cannot act on a half-formed tool call — they need the whole tool_use block (id, name, fully-parsed input) before any of it reaches them, while text and thinking continue to stream live in parallel.

The buffer-then-decide pattern that solves (2) already lives in internal/guardrails/mutate/anthropic_stream.go, but it is wired specifically into the guardrails rewrite path. streamemit generalizes that pattern into a reusable layer so non-guardrails callers can compose the same shape, and so guardrails itself can eventually collapse onto it.

Design

One emitter, two responsibilities

A StreamEmitter does two things every time Feed is called:

  1. Always feed the inner *assembler.AnthropicStreamAssembler so MessageAssembler() and Finish() reflect every event the emitter has seen, regardless of any buffering decisions.
  2. Decide what to release right now. Per-kind EmissionPolicy selects between EmitImmediate (1:1 with arrival) and EmitOnComplete (buffer the entire content block, flush as one ordered slice on content_block_stop).

Both responsibilities are independent: the assembled *anthropic.Message returned by Finish is correct whether or not anything was buffered.

Routing
                  ┌─────────────────────────────────────────────┐
                  │                StreamEmitter                │
                  │                                             │
 evt ─► Feed ─►   │   ┌─ inner *AnthropicStreamAssembler        │
                  │   │     (RecordV1Event / RecordV1BetaEvent) │
                  │   │                                         │
                  │   ├─ kinds[index]    ── learned at          │
                  │   │                     content_block_start │
                  │   │                                         │
                  │   ├─ toolBufs[index] ── only when policy    │
                  │   │                     for that kind is    │
                  │   │                     EmitOnComplete      │
                  │   │                                         │
                  │   └─ OnToolBlockComplete hook ── invoked at │
                  │                                  cb_stop    │
                  │                                  on flush   │
                  └─────────────────────────────────────────────┘
                              │
                              ▼
                    []BufferedEvent  (zero, one, or many — caller
                                      forwards each to the consumer)

message_start, message_delta, message_stop are always immediate. content_block_start learns the block kind, opens a buffer if needed, and either holds or releases the start event. content_block_delta routes by the previously-recorded kind. content_block_stop either flushes a buffered block (running the optional decision hook first) or passes through.

Emission timeline
events arriving:   ms_start  cb_start(text)  txt_d  txt_d  cb_stop  cb_start(tool)  json_d  json_d  cb_stop  ms_delta  ms_stop
                       │           │           │      │       │            │           │       │       │         │         │
emitter.Feed   ───►   1ev         1ev         1ev    1ev     1ev          0ev         0ev     0ev    ┌─4ev─┐    1ev      1ev
                       ▼           ▼           ▼      ▼       ▼            ▼           ▼       ▼     ▼     ▼     ▼         ▼
 SSE wire:           ms_start    cb_start(t)  d      d     cb_stop      ░░░░░░░░░  ░░░░░░  ░░░░░░  cb_start(tool)  ms_delta  ms_stop
                                                                                                   json_d
                                                                                                   json_d
                                                                                                   cb_stop
                                                                  ▲
                                                                  │
                                                          held in toolBufs[1] until
                                                          cb_stop, then drained as
                                                          one ordered slice

Text and thinking flow live; the tool block goes silent on the wire and re-emerges as a contiguous burst at content_block_stop.

BufferedEvent is a type alias
type BufferedEvent = protocol.GuardrailsBufferedEvent

The alias (not a named type) means the output of streamemit and the output of the guardrails rewriter are interchangeable. A handler can chain them, a slice from one can be appended to a slice from the other, and the existing sendAnthropicStreamEvent consumes both without translation.

Hook composition

Config.OnToolBlockComplete is the integration point for tool-level post-processing — credential masking, allow/deny verdicts, content rewriting:

            ┌──────────────────────────┐
            │ OnToolBlockComplete      │
            │   ├─ allow  → nil        │   (flush buffered as-is)
            │   ├─ rewrite→ Replace[]  │   (emit replacement instead)
            │   └─ drop   → Drop:true  │   (emit nothing)
            └──────────────────────────┘

This is the same decision shape as the guardrails AnthropicToolUseDecision; an adapter is one screen of code.

Scope
  • In: Anthropic v1 (MessageStreamEventUnion) and v1beta (BetaRawMessageStreamEventUnion).
  • Out: OpenAI Chat, OpenAI Responses, Google streams. Those have their own assemblers; analogous emitters can be added later.
  • One version per emitter. Mixing v1 and v1beta events on a single emitter returns ErrMixedVersions. Use one emitter per request.

Usage

Pass-through (default — same behavior as today's handler)
e := streamemit.New(streamemit.Config{}) // zero value: emit everything immediately

for streamResp.Next() {
    evt := streamResp.Current()
    out, err := e.FeedV1(&evt)
    if err != nil {
        return err
    }
    for _, ev := range out {
        sendAnthropicStreamEvent(c, ev.EventType, ev.Payload, c.Writer)
    }
}

_, msg := e.Finish(model, inputTokens, outputTokens)
// `msg` is *anthropic.Message reflecting the full stream.
Hold tool calls until complete
e := streamemit.New(streamemit.Config{
    ToolPolicy: streamemit.EmitOnComplete,
})

for streamResp.Next() {
    evt := streamResp.Current()
    out, _ := e.FeedV1(&evt)
    for _, ev := range out {
        sendAnthropicStreamEvent(c, ev.EventType, ev.Payload, c.Writer)
    }
}

// On error or client disconnect, salvage anything still buffered:
//   pending := e.Drain()
//   for _, ev := range pending { ... }

Text and thinking events still reach the consumer the moment they arrive. Each tool block is silent on the wire from content_block_start through every input_json_delta, then bursts out as a single ordered slice at content_block_stop.

Compose with a verdict hook
e := streamemit.New(streamemit.Config{
    ToolPolicy: streamemit.EmitOnComplete,
    OnToolBlockComplete: func(toolID string, idx int, buffered []streamemit.BufferedEvent) (*streamemit.ToolDecision, error) {
        verdict, err := guardrails.Inspect(toolID, buffered)
        if err != nil {
            return nil, err
        }
        switch verdict.Kind {
        case guardrails.Allow:
            return nil, nil                              // flush as-is
        case guardrails.Block:
            return &streamemit.ToolDecision{Replace: verdict.ErrorEvents}, nil
        case guardrails.Drop:
            return &streamemit.ToolDecision{Drop: true}, nil
        }
        return nil, nil
    },
})
Inspect each side independently
// Read-only access to the inner state machine; safe to call at any time.
asm := e.MessageAssembler()
_ = asm // e.g. inspect blocks accumulated so far

// Snapshot the events buffered for a specific block index.
if buf, ok := e.ToolBuffer(1); ok {
    fmt.Println("tool block 1 has", len(buf), "events buffered so far")
}

Public API surface

// Construction
func New(cfg Config) *StreamEmitter

// Feeding
func (e *StreamEmitter) Feed(event interface{}) ([]BufferedEvent, error)
func (e *StreamEmitter) FeedV1(*anthropic.MessageStreamEventUnion) ([]BufferedEvent, error)
func (e *StreamEmitter) FeedV1Beta(*anthropic.BetaRawMessageStreamEventUnion) ([]BufferedEvent, error)

// Inspection
func (e *StreamEmitter) MessageAssembler() *assembler.AnthropicStreamAssembler
func (e *StreamEmitter) ToolBuffer(index int) ([]BufferedEvent, bool)

// Termination
func (e *StreamEmitter) Drain() []BufferedEvent
func (e *StreamEmitter) Finish(model string, inputTokens, outputTokens int) ([]BufferedEvent, *anthropic.Message)

// Sentinels
var ErrMixedVersions = errors.New(...)

// Config
type Config struct {
    TextPolicy          EmissionPolicy
    ThinkingPolicy      EmissionPolicy
    ToolPolicy          EmissionPolicy
    OnToolBlockComplete func(toolID string, index int, buffered []BufferedEvent) (*ToolDecision, error)
}

type EmissionPolicy int
const (
    EmitImmediate EmissionPolicy = iota
    EmitOnComplete
)

type ToolDecision struct {
    Replace []BufferedEvent
    Drop    bool
}

type BufferedEvent = protocol.GuardrailsBufferedEvent

Design notes and caveats

  • The emitter owns the inner assembler. Callers that today drive their own AnthropicStreamAssembler in parallel (e.g. internal/server/scenario_recording.go) should pick one or the other to avoid double-feeding. MessageAssembler() is the bridge for callers that want the assembled message without a second instance.
  • Drain() does not call OnToolBlockComplete. It is the salvage path for error/cancel handling, not a completion signal. If the hook must run for partial tool blocks too, the caller should invoke it explicitly before calling Drain.
  • MessageAssembler() is read-oriented. Feeding events into the returned assembler directly will desync it from the emitter's routing state. Use Feed* only.
  • Drop and Replace are independent. If both are set, Drop wins. A nil *ToolDecision flushes the buffered events unchanged.
  • Thinking blocks under EmitOnComplete. Mechanically supported (the same buffer machinery is reused) but no caller exercises this yet. Treat as latent capability.

Migration plan

This package landed library-only. The handler-side migration happens in follow-ups:

  1. Swap anthropic_passthrough.go to drive a StreamEmitter with Config{} (byte-identical default behavior).
  2. Move the guardrails rewriter behind OnToolBlockComplete; delete GuardrailsStreamState.AnthropicToolEvents / AnthropicToolIDs.
  3. Expose a per-request opt-in (header or HandleContext flag) that flips ToolPolicy to EmitOnComplete for consumers that prefer atomic tool delivery.
  4. Optionally retire the inline state machine in internal/protocol/stream/openai_to_anthropic.go::streamState by having the conversion handler emit Anthropic events into a StreamEmitter.

Tests

go test ./internal/protocol/assembler/streamemit/... covers:

  • text-only streams (v1 + v1beta) with EmitImmediate
  • tool-only streams (v1 + v1beta) with EmitOnComplete
  • mixed text + tool with text live, tool buffered
  • ordering invariant: a tool flush returns only the tool's events, even when text deltas on a different block were already emitted
  • Drain() flushing an unclosed tool buffer
  • Finish() returning pending events plus an assembled message
  • OnToolBlockComplete Replace / Drop / error propagation
  • ErrMixedVersions when v1 and v1beta are fed to the same emitter
  • byte-compatibility with protocol.GuardrailsBufferedEvent

Documentation

Overview

Package streamemit provides a decoupled emission layer on top of the Anthropic stream assemblers in internal/protocol/assembler.

A StreamEmitter routes incoming Anthropic stream events to per-kind sub-buffers and applies a configurable EmissionPolicy per kind. The supported policies are EmitImmediate (the default — events flow out 1:1 as they arrive) and EmitOnComplete (events for a content block are buffered from content_block_start through content_block_stop and flushed as a single ordered slice on stop).

The primary use case is "hold tool_use blocks until the assembler has the complete tool call" while still streaming text and thinking to the consumer live. This mirrors the buffer/decision pattern used by internal/guardrails/mutate for credential masking, but generalizes it so non-guardrails callers can compose the same shape.

The emitter owns its inner *assembler.AnthropicStreamAssembler. It always feeds every event to the inner assembler, regardless of any buffering, so MessageAssembler() and Finish() always reflect the full stream so far. Callers that today drive their own assembler in parallel (e.g. internal/server/scenario_recording.go) should pick one or the other to avoid double-feeding.

Scope: this package supports Anthropic v1 (MessageStreamEventUnion) and v1beta (BetaRawMessageStreamEventUnion). OpenAI Chat and OpenAI Responses streams are out of scope.

Index

Constants

This section is empty.

Variables

View Source
var ErrMixedVersions = errors.New("streamemit: cannot mix v1 and v1beta events on a single emitter")

ErrMixedVersions is returned by Feed when an emitter that has already processed v1 events receives a v1beta event (or vice versa).

Functions

This section is empty.

Types

type BlockKind

type BlockKind uint8

BlockKind is the assembler-level classification of a content block. We collapse the SDK's many tool-result variants into a single ToolUse kind because the routing decision is the same for all of them: hold until complete, or pass through.

const (
	BlockKindUnknown BlockKind = iota
	BlockKindText
	BlockKindThinking
	BlockKindToolUse
)

type BufferedEvent

type BufferedEvent = protocol.GuardrailsBufferedEvent

BufferedEvent is one Anthropic SSE event ready to be sent to the consumer.

It is a type alias of protocol.GuardrailsBufferedEvent so the output of this package is byte-compatible with the guardrails rewrite layer's buffered events: callers can append slices from both sources and feed them into the same emitter without conversion.

type Config

type Config struct {
	// TextPolicy governs "text" content blocks.
	TextPolicy EmissionPolicy

	// ThinkingPolicy governs "thinking" and "redacted_thinking" content blocks.
	ThinkingPolicy EmissionPolicy

	// ToolPolicy governs "tool_use" content blocks. Setting this to
	// EmitOnComplete is the primary use case for this package: tool events
	// are held until the tool's content_block_stop arrives.
	ToolPolicy EmissionPolicy

	// OnToolBlockComplete is invoked when a tool_use block has finished
	// buffering under EmitOnComplete and is about to be released. The hook
	// receives the tool_use id, the content block index, and the buffered
	// events in arrival order. It may return a *ToolDecision to replace or
	// drop the buffered events; returning nil flushes them as-is. An error
	// short-circuits Feed and is returned to the caller.
	OnToolBlockComplete func(toolID string, index int, buffered []BufferedEvent) (*ToolDecision, error)
}

Config configures a StreamEmitter. The zero value emits everything immediately and installs no hooks.

type EmissionPolicy

type EmissionPolicy int

EmissionPolicy controls when events for a given block kind are released from the emitter to the caller.

const (
	// EmitImmediate releases each event as soon as it is fed to the emitter.
	// This is the zero value and matches today's passthrough behavior.
	EmitImmediate EmissionPolicy = iota

	// EmitOnComplete buffers all events for a single content block from
	// content_block_start through content_block_stop, then releases them
	// as one ordered slice when the stop event arrives.
	EmitOnComplete
)

type StreamEmitter

type StreamEmitter struct {
	// contains filtered or unexported fields
}

StreamEmitter routes Anthropic stream events through a configurable emission policy. See package doc for usage.

func New

func New(cfg Config) *StreamEmitter

New constructs a StreamEmitter with the given configuration.

func (*StreamEmitter) Drain

func (e *StreamEmitter) Drain() []BufferedEvent

Drain flushes every still-open tool buffer, returning the buffered events in ascending block-index order, and clears the buffers. Useful at end of stream or on error when the caller wants whatever has been accumulated.

Drain does NOT call OnToolBlockComplete — it is a salvage path, not a completion signal.

func (*StreamEmitter) Feed

func (e *StreamEmitter) Feed(event interface{}) ([]BufferedEvent, error)

Feed accepts either a *anthropic.MessageStreamEventUnion (v1) or a *anthropic.BetaRawMessageStreamEventUnion (v1beta) and returns the events the caller should send to the consumer right now.

An emitter is pinned to its first version; mixing versions returns ErrMixedVersions.

func (*StreamEmitter) FeedV1

FeedV1 routes a v1 Anthropic stream event through the emitter and returns the (possibly empty) events to send to the consumer right now.

func (*StreamEmitter) FeedV1Beta

FeedV1Beta routes a v1beta Anthropic stream event through the emitter and returns the events to send to the consumer right now.

func (*StreamEmitter) Finish

func (e *StreamEmitter) Finish(model string, inputTokens, outputTokens int) ([]BufferedEvent, *anthropic.Message)

Finish drains any remaining tool buffers and returns the assembled *anthropic.Message produced by the inner assembler.

model, inputTokens, outputTokens are forwarded to AnthropicStreamAssembler.Finish.

func (*StreamEmitter) MessageAssembler

func (e *StreamEmitter) MessageAssembler() *assembler.AnthropicStreamAssembler

MessageAssembler returns the inner *AnthropicStreamAssembler so callers can inspect message-side accumulation independently of tool buffering. Callers should treat it as read-only — feeding events into it directly will desync it from the emitter's routing state.

func (*StreamEmitter) ToolBuffer

func (e *StreamEmitter) ToolBuffer(index int) ([]BufferedEvent, bool)

ToolBuffer returns a copy of the events currently buffered for the given content block index. The bool is false when no buffer exists for that index (either it was never tool_use, it was emitted immediately, or it has already been flushed).

type ToolDecision

type ToolDecision struct {
	Replace []BufferedEvent
	Drop    bool
}

ToolDecision is the return value of Config.OnToolBlockComplete.

If Drop is true the buffered events are suppressed entirely. Otherwise, if Replace is non-nil those events are emitted instead of the buffered ones. A nil ToolDecision (or a zero-value one) means "flush the buffered events unchanged".

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL