Skip to content

Run events

Purpose

Observe a run while it executes: every provider call attempt — retried attempts included —, every tool execution, and every decoder correction boundary, delivered as typed events in execution order.

When to use

Structured logging, tracing spans, progress reporting, or metering that needs to see a run as it happens rather than after it ends. Not when post-hoc inspection is enough — Result.Messages remains the canonical record, and text streaming already covers showing generation progress.

How it works

WithRunEvents registers one observer per agent. The run invokes it synchronously on the caller's goroutine, inline with execution: no goroutines, no buffers, no dropped events. The observer returns nothing, so observation can never fail a run; an observer that must stop the run cancels the run context. Run, RunWithHistory, and both streaming variants emit the same events.

Two kinds mark lifecycle boundaries rather than executions: EventDeferred replaces a deferred call's tool end when the run pauses on it, and EventCanceled follows the tool end of the call that ended the run with &tool.Canceled — the boundary after which nothing else executes, and calls the stop prevented never emit at all.

Run-scoped observers

WithRunObserver registers the same observation for a single run, as a run option — the routing a shared agent needs, where one agent serves many requests and each request's events belong to that request:

result, err := agent.Run(ctx, runCtx, prompt,
    golem.WithRunObserver(func(event golem.RunEvent) {
        logger.Trace(requestID, event.Kind, event.ToolName)
    }),
)

A run's observer composes with the agent's: the construction-scoped observer fires first, then the run's, per event — global metering and per-request tracing coexist. Accepted by Run and its history, streaming, and deferred-resume variants; a nil observer observes nothing.

Run and conversation identity

Every event carries the emitting run's RunID and the ConversationID it continues, constant across the run — the same pair the run's Result and, on failure, RunError.Partial report. That is what makes one construction-scoped observer enough for a shared agent: interleaved runs' events sort by event.RunID without per-run closures, and events from the runs of one conversation group by event.ConversationID. The minting and inheritance rules — run IDs are never inherited, conversation IDs travel through history and storage — are documented in Conversations and history and decided in ADR 0027.

Events arrive in deterministic execution order, and that order is a compatibility promise:

  • model_start / model_end bracket every provider call attempt, carrying the turn index and 1-based attempt number; model_end carries the attempt's usage and error. A retried attempt is individually observable.
  • tool_start / tool_end bracket every tool execution, carrying the call ID, tool name, raw arguments, and on end the result text or error. A correction rejection arrives as a tool_end whose error is a *model.ModelRetry.
  • output_rejected marks a decoder rejection starting a correction round; its Attempt numbers the round that follows, and turn indexes restart with it.

Parallel tool groups stay deterministic: starts are emitted in model emission order before the group runs, ends in the same order after it completes — matching the result-evidence order rule.

Example

Run examples/run-events (offline, against a scripted fake model):

go run ./examples/run-events
agent, err := golem.New[string, string](client,
    golem.DecodeFunc[string](func(_ context.Context, response model.Response) (string, error) {
        return response.Message.Content, nil
    }),
    golem.WithTools[string, string](getPlayerName),
    golem.WithRunEvents[string, string](func(event golem.RunEvent) {
        fmt.Printf("%-16s turn=%d attempt=%d\n", event.Kind, event.Turn, event.Attempt)
    }),
)

API surface

  • golem.WithRunEvents[Deps, Output](onEvent func(RunEvent)) Option[Deps, Output]
  • golem.WithRunObserver(onEvent func(RunEvent)) RunOption
  • golem.RunEvent{Kind, Turn, Attempt, CallID, ToolName, Args, Result, Err, Usage, RunID, ConversationID}
  • golem.EventKind — golem.EventModelStart, golem.EventModelEnd, golem.EventToolStart, golem.EventToolEnd, golem.EventOutputRejected, golem.EventDeferred, golem.EventCanceled
  • golem.WithRunID(id string) RunOption, golem.WithConversationID(id string) RunOption, golem.NewID() — see Conversations and history

Gotchas

  • The observer runs inline with model and tool execution: it must not block. Cancel the run context — don't stall the callback — to stop the run.
  • Events are advisory observation; usage bounds and the canonical record come from the run Result. A streaming run emits the same events alongside its fragments, with a model turn's events bracketing its fragments.
  • Which fields carry meaning depends on the kind; the rest are zero.
  • Decisions live in docs/adr/0014-run-event-stream.md.