elelemstream

package
v0.7.5 Latest Latest
Warning

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

Go to latest
Published: Sep 9, 2026 License: MIT Imports: 5 Imported by: 0

README

elelemstream — elelem's callbacks, as content blocks

elelem streams a turn as callbacks: deltas arrive, then tool calls, then their results. It has no notion of a content block index, and no reason to — that is a wire concern.

This package is the seam. Adapter registers the callbacks, tracks where the turn is, and emits correctly-indexed blocks through an essessey.Publisher.

pub := essessey.NewPublisher(ctx, sink)
adapter := elelemstream.New(pub)

resp, err := adapter.Bind(
    elelem.NewRequest(client).WithModel(model).WithPrompt(prompt),
).Run(ctx)

Adding your own callbacks

An app usually has per-round concerns of its own — a heartbeat, a log line, a metric. Register them as well; elelem's On* setters append to a chain, so both the Adapter's callback and yours run:

adapter.Bind(req).
    OnRoundStart(func(context.Context, *elelem.RoundEvent) error {
        heartbeat.Touch()

        return nil
    })

This needs elelem v0.3.0 or later. Before that the setters replaced rather than appended, and registering your own hook silently unregistered the Adapter's — the stream just stopped emitting blocks, with no error to catch.

When elelem is not streaming

elelem.WithStreaming(false) exists for backends that cannot serve a streaming call — an async job queue in front of the model, typically. It changes the transport, not the callbacks: elelem feeds the finished response through the same On* hooks the Adapter is bound to.

So this package keeps working, and the block protocol on the wire is the same shape. The only difference a subscriber sees is timing — the blocks for a turn all arrive at once, at the end, instead of filling in as the model writes.

Every callback is also exported (OnRoundStart, OnDelta, OnAssistantMessage, OnRoundEnd, OnToolCallStart, OnToolResult), for when you want your hook at an exact point relative to the Adapter's, or want to wire only some of them:

req.OnRoundStart(func(ctx context.Context, ev *elelem.RoundEvent) error {
    heartbeat.Touch()

    return adapter.OnRoundStart(ctx, ev)   // yours first, then the Adapter's
})

Why this is a package and not ten lines in your app

Because the index arithmetic is fiddly and silently wrong when you get it slightly off. A tool result landing on the wrong index does not error — it renders into the wrong card, and you find out from a screenshot.

The arithmetic

A round emits its thinking/text blocks first. Wherever those stop becomes toolBase, and every tool block in the round is placed relative to it:

toolBase + i                    tool_use for call i
toolBase + toolCallCount + i    tool_result for call i
toolBase + 2*toolCallCount      where the NEXT round starts

All tool_use blocks come before any tool_result block — which is why the result offset carries + toolCallCount rather than pairing each result directly after its call. With two parallel calls the round lays out as:

index block
toolBase + 0 tool_use, call 0
toolBase + 1 tool_use, call 1
toolBase + 2 tool_result, call 0
toolBase + 3 tool_result, call 1

Naive implementations interleave use/result pairs and look correct for a single tool call. Parallel calls are where they break.

Invariants

  • Blocks open lazily. A round with no reasoning must not emit an empty thinking block, and must not consume an index for one.
  • Thinking closes before text opens. They are separate blocks; overlapping them makes the client render reasoning into the answer.
  • The index advances only on a block that actually opened. Close on an unopened streamer is a no-op — otherwise a silent round shifts everything after it.
  • The round advances once, on the LAST tool result (i == toolCallCount-1), not per result.

Enforcement lives in adapter_test.go, which asserts the emitted indices for the single-call, parallel-call, non-zero-toolBase and second-round cases. Those tests were mutation-checked: breaking either the result offset or the round advance fails them.

What it deliberately does not do

Heartbeats, persistence, chat identifiers, logging of application state — those belong to the app that owns the turn, not to the translation layer. This package converts callbacks into blocks and nothing else.

Errors

ErrRoundStreamNotInitialized and ErrToolResultMissing name the two callback-wiring faults, wrapped with call-site context, so errors.Is works through the wrap. Both mean the request was built without Bind, or elelem handed over a result-less tool event.

Documentation

Overview

Package elelemstream bridges elelem's streaming callbacks to essessey's content-block protocol.

An elelem round streams deltas, then (optionally) tool calls and their results, with no notion of "content block index" of its own. Adapter is the one place that arithmetic lives: get it wrong and a tool result renders into the wrong card client-side. Every caller wiring elelem to essessey binds one Adapter instead of reimplementing this.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrRoundStreamNotInitialized means a delta or assistant message arrived
	// before OnRoundStart opened a round. It signals a callback wiring bug —
	// Bind registers OnRoundStart, so reaching this means the request was
	// built without it.
	ErrRoundStreamNotInitialized = errors.New("round stream is not initialized")

	// ErrToolResultMissing means OnToolResult fired with no result attached.
	// The block cannot be emitted without one, and inventing an empty result
	// would render an empty card as though the tool had genuinely returned
	// nothing.
	ErrToolResultMissing = errors.New("tool result is missing")
)

Functions

func MapStopReason

func MapStopReason(
	finishReason elelem.FinishReason,
	hasToolCalls bool,
) essessey.StopReason

MapStopReason maps elelem's finish reason and whether the round produced tool calls onto essessey's StopReason.

A truncated finish always wins over tool calls: the client must not treat a cut-off answer as a clean tool-use round just because calls happened to be present in it.

Types

type Adapter

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

Adapter binds elelem's streaming callbacks to an essessey Publisher, translating each round's deltas, tool calls, and tool results into correctly-indexed content blocks.

Build one Adapter per in-flight elelem.Request — its fields track the current round's state and are not safe for concurrent Bind targets.

func New

func New(pub *essessey.Publisher) *Adapter

New builds an Adapter that emits content blocks to pub.

func (*Adapter) Bind

func (a *Adapter) Bind(req *elelem.Request) *elelem.Request

Bind registers the Adapter's callbacks on req and returns req, so callers can chain it into the rest of the elelem.Request build.

An app with per-round concerns of its own just registers them as well — since elelem v0.3.0 the On* setters append to a chain rather than replace, so Bind and the app's own OnRoundStart both run, in registration order:

adapter.Bind(req).
    OnRoundStart(func(context.Context, *elelem.RoundEvent) error {
        heartbeat.Touch()

        return nil
    })

Every callback below is exported anyway, for the case where the app wants its hook to run at an exact point relative to the Adapter's, or wants to wire only some of them — without reimplementing the block arithmetic.

func (*Adapter) OnAssistantMessage added in v0.2.0

func (a *Adapter) OnAssistantMessage(
	ctx context.Context,
	message elelem.Message,
) error

OnAssistantMessage closes out the round's thinking/text blocks and derives the tool block base: toolBase is wherever the round's content left off, and every tool_use/tool_result index for this round is computed relative to it.

func (*Adapter) OnDelta added in v0.2.0

func (a *Adapter) OnDelta(ctx context.Context, delta elelem.Delta) error

OnDelta forwards a streamed chunk to the round's thinking/text streamers.

func (*Adapter) OnRoundEnd added in v0.2.0

func (a *Adapter) OnRoundEnd(
	_ context.Context,
	event *elelem.RoundEvent,
) error

OnRoundEnd tracks the completed round count.

func (*Adapter) OnRoundStart added in v0.2.0

func (a *Adapter) OnRoundStart(
	_ context.Context,
	_ *elelem.RoundEvent,
) error

OnRoundStart opens a fresh roundStream at the block index the previous round (or the run's start) left off at. Nothing is emitted here — blocks open lazily, on first content.

func (*Adapter) OnToolCallStart added in v0.2.0

func (a *Adapter) OnToolCallStart(
	_ context.Context,
	event elelem.ToolCallEvent,
) error

OnToolCallStart emits the tool_use block for one call. Parallel calls in the same round each get their own slot: toolBase + the call's index.

func (*Adapter) OnToolResult added in v0.2.0

func (a *Adapter) OnToolResult(
	_ context.Context,
	event elelem.ToolCallEvent,
) error

OnToolResult emits the tool_result block for one call, after every tool_use block in the round — hence the +toolCallCount offset — then, once the last result of the round has landed, advances blockIndex past all of them (2 blocks per tool: one use, one result) so the next round starts clean.

func (*Adapter) Rounds

func (a *Adapter) Rounds() int

Rounds reports how many rounds have completed so far.

Jump to

Keyboard shortcuts

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