agents-go

Design Spec

This is the behavioral specification for this SDK. Behavior questions are answered here, not by openai-agents-python.

The rule: when this document does not cover a case, decide, implement it, and add the invariant here in the same change.

Every invariant below is implemented and stable unless flagged:


1. Scope

1.1 What this is

A Go SDK for building agents on the OpenAI Responses API. It began as a port of openai-agents-python and shares its core concepts — agents, handoffs, guardrails, sessions — but evolves independently. See migration_from_python.md if you are arriving from the Python SDK, and upstream_watch.md for what we have reviewed from upstream.

1.2 Non-goals

Not doing Why
Chat Completions API Internal item types are Responses types (§5.5). A backend that speaks another protocol is supported by translating at the model boundary (§5.10) — never by making a second format canonical. Chat Completions specifically was declined again 2026-07-31 in favor of a native Anthropic adapter; revisit only with a concrete backend nothing else covers.
Provider-hosted tools (web_search, file_search, code_interpreter, computer_use, …) A tool is a *Tool struct, not an interface, so there is nothing a hosted tool could implement; every tool executes locally. Hosted tools bind a tool to one backend.
A neutral multi-provider abstraction No lowest-common-denominator message model. An adapter implements Model by translating to the canonical Responses format (§5.10); models/modelkit is shared plumbing for writing adapters, not an abstraction layer. The SDK guarantees depth of correctness for Responses semantics.
Model price or capability tables They change constantly and do not belong in an SDK. Usage exposes raw token counts; pricing is the caller’s concern.
Realtime and voice A different interaction model, out of scope.
Graph orchestration as the multi-agent primitive Handoffs already cover “switch agent at runtime”. Graph orchestration, if ever needed, layers on top — see §5.1.

2. Core invariants

2.0 Entry points

Run returns (RunStream, RunControl); RunSync returns (*RunResult, error). ResumeRun / ResumeRunSync are the same pair for a paused run.

2.0b Option grouping

RunOptions groups its fields by what they configure — Model, Conversation, Exec, Observe — rather than listing them flat. The zero value stays usable.

The grouping is not cosmetic. Conversation collects options that constrain each other: a local Session, UsePreviousResponseID and ConversationID are alternatives, not layers, and a run that combines a local session with server-managed state is rejected. A flat list hid that.

2.1 The run loop

A run consists of turns. One turn = one model call plus every side effect it triggers (tool execution, handoff).

for turn := 1; ; turn++ {
    check budget (turns; 🚧 tokens / deadline)
    check ctx cancellation
    resolve model / instructions / prompt / tools / handoffs / output schema
    build model input
    (first turn) run input guardrails
    call the model
    classify the response into message / tool call / handoff call / reasoning /
    unknown (kept verbatim, see §2.1b)
    execute side effects (§2.2)
    persist (§2.5)
    decide: continue / final output / interrupt
}

Termination conditions, highest precedence first:

  1. ctx cancelled → the run ends there, and ctx.Err() reaches the caller wrapped in a *RunError carrying the turns that did complete. A cancellation noticed inside the loop is a failure like any other; only failures from before the loop are returned bare.
  2. Budget exhausted → with ToolLoop.FinalTurnWithoutTools, call the model once more without tools so it can close out in prose. Otherwise return *MaxTurnsError.
  3. HITL interruption → return a RunResult carrying Interruptions and State.
  4. The model produced a final output → see §2.3.

A RunState round-trips whole. Everything a resume consumes is in the wire format — the pending injected input, the disclosed deferred tools, the server-conversation cursor and the off-chain-history flag included — pinned by a full-field round-trip test (RunStateSchemaVersion 1.5). The in-process resume passing the live pointer must never be the only path that works; the serialized surface IS the contract. The cursor in particular rides along so a resumed run keeps sending deltas: the resumed turn re-processes a response the restored cursor already accounts for and does not advance it — re-deriving the cursor there marked pre-pause sibling tool outputs as already served, and a server-managed conversation never received them.

And it round-trips the run’s full past, deliberately. The serialized state carries every raw response and generated item so far, so its size grows with the run. That is the cost of a contract, not an oversight: a resumed run’s RunResult must report the same RawResponses (and therefore the same UsageByRequest) as one that never paused, and the max-turns handler’s snapshot promises “every response so far”. Trimming the state to the interrupted response alone would make pausing observable in the result.

A RunState decodes across a version window, not on strict equality. RunStateFromJSON accepts the same schema major from runStateOldestDecodableMinor up to RunStateSchemaVersion; anything newer, any other major, and anything below the floor is a *UserError naming which way it missed. A minor may only ADD fields — a bump that replaces or reinterprets one must raise the floor to itself. See §5.18.

2.1b Items

RunItem is one struct with a Kind, not an interface. The kinds are a closed set the runner produces — message, tool call, tool output, handoff call/output, reasoning, injected input, unknown — and a caller cannot add one, which is the definition of a union, not of a polymorphic seam. As an interface it took seven near-identical implementations (five were {Agent, Raw} plus a tag) restating six methods each, and serialization still had to flatten them: a stored RunState holds {type, agent, input, source, display}, and reading it back required an eighth, unexported implementation whose only job was to carry those fields. The struct IS that shape, live and stored.

Consumers switch on Kind and must treat an unrecognized kind as opaque — render it via Display(), never fail — so the set can grow without breaking them.

Beyond its payload, every item reports two things:

Both survive RunState serialization, so a resumed run reports the same provenance and renders the same timeline as before the pause. A rebuilt item carries its replayed input form (RawInput) and stored display; Raw is nil, and so is Output — a tool’s Go-native return value does not round-trip, only its rendered input form does. A resume replays history from input items, which is all it needs.

An unknown output item is kept, never dropped. A model output type this SDK does not model becomes an ItemUnknown run item carrying the original bytes, and goes back on the wire byte for byte on the next turn. Dropping it is not “ignoring a feature” — the next turn resends a history the model does not recognize as its own.

The same rule reaches storage: UnmarshalInputItem accepts a typed item the union does not know and preserves its bytes, so a session written by a newer build stays readable. An item with no type is still rejected, so malformed JSON does not slip through as an opaque blob.

2.2 Ordering within a turn

This is the most important invariant in this document. The steps may not be reordered.

# Step Constraint
1 Publish RunContext.TurnInput Set once the turn’s input is final (before the model call), refreshed if CallModelInputFilter edits it. It is what was actually sent — under server-managed conversation state that is the new items only, not the whole history
2 On resume: drop already-completed sibling calls Prevents duplicated side effects and a second function_call_output for the same call id
3 Partition calls by approval: toRun / interruptions / rejected
4 If any call needs approval, pause the whole turn — no tool runs Pausing only the gated calls would leave RunState holding partial results
5 Run toRun concurrently, then merge with rejected in original call order Result order is deterministic and independent of completion order
6 A nested agent-as-tool interruption pauses the parent run too Completed siblings keep their outputs; the interrupted call’s output is withheld
7 Unknown tool → feed back Tool 'X' not found. Only under ToolNotFoundReturnToModel; otherwise it is a *ModelBehaviorError
8 Handoffs win: switch to the target agent, end the turn Tools in the same response have already executed; the final-output check is skipped
9 Decide the final output (§2.3)

Concurrency guarantees:

2.3 Deciding the final output

Once the turn has no remaining tool work:

no message, but there was tool activity (e.g. all calls rejected)
    → continue to the next turn (results must reach the model)

message contains a refusal
    → *ModelRefusalError
      (a refusal wins over any text or structured content in the same message)

the agent has an OutputType:
    text present → parse against the schema
                   parse failure → InvalidFinalOutput recovery handler,
                                   or *ModelBehaviorError if none
    no text      → recovery handler, or **continue to the next turn**
                   (never a hard failure)

otherwise (plain text)
    → the message text is the final output (possibly the empty string)

2.3a The save point

The save point is the turn boundary: the turn’s assistant message and every tool result are persisted, and the next model call has not happened yet.

It is one place in the code, and its step order is the contract:

  1. flush the turn to the session
  2. ask ShouldStopAfterTurn
  3. compact (§2.5f), rebuilding the context from the log
  4. call PrepareNextTurn

Persisting first is what makes the rest safe: a run that stops at step 2, or whose context is rewritten at step 3, has its history already written. Asking to stop before compacting means the decision is made against the turn that actually happened rather than a shortened view of it.

A handoff reaches only step 1 and 2. The next turn belongs to a different agent, so its snapshot is resolved fresh, and its context is about to be rewritten by the handoff input filter.

2.3b Turn snapshots

A turn is resolved into a TurnSnapshot — agent, model, settings, instructions, prompt, tools, handoffs, output schema, input — before the model is called, and the turn reads the snapshot from then on rather than the agent.

2.3c Stopping early

A turn that would otherwise continue can be ended from two places, and only two:

Level Mechanism Final output
tool ToolResult.Terminate the last tool’s output
run ExecOptions.ShouldStopAfterTurn the turn’s last message, else its last tool output

There is deliberately no agent-level early-stop configuration. Naming tools up front cannot express anything the turn predicate cannot, and the policy belongs to the run — the same agent gets reused across runs that stop at different points.

2.4 Handoffs

2.5 Session persistence boundaries

When What is written
Just before the first model call The new user input — deferred so a failure ahead of that leaves no orphan message
End of each turn The items produced by that turn
Final turn After output guardrails pass — a tripped final output is never persisted

Whether a tripped input guardrail leaves the user message behind is decided by Blocking, and by nothing else: a blocking guardrail finishes before the save and before the model is reached, so a tripwire leaves the session untouched and costs nothing; a racing one (the default) trips while the model call is in flight, so the input is persisted and the request was made. Both entry points answer identically.

A save that leaves nothing behind is announced on the stream as ItemsPersistedEvent. The implication is one-way: the event guarantees that every item the stream showed before it is in the store; its absence promises nothing (a run without a session never emits it, history restored on resume predates the stream, and a save that held items back — an interruption’s pending calls — stays silent, precisely because the stream has shown items the store does not yet hold). Consumers mirror persisted state from this event rather than inferring the SDK’s persist timing from raw response events.

Core invariant — safePersistBoundary: the stored conversation never contains a function call without its output. When a run pauses for approval, the pending function_call items are withheld and written together with their outputs after resume.

This guarantee does not survive an abnormal process exit; a RecoveryPolicy repairs dangling state when the session is reopened.

Entries are append-only. An entry’s display may need updating long after the turn that produced it has ended — a background task card, a late diagnostic. That is expressed as a new update entry naming its target, folded in at projection time; entries are never rewritten in place. Multiple updates to one target merge in sequence order. An update whose target does not exist is ignored, not an error — the target may have been folded away by compaction.

2.5b Session entries

A session stores entries, not bare Responses items. An entry carries the item plus what the run knew about it — provenance, display, the model call it belongs to — or something that is not a Responses item at all (an annotation, a compaction checkpoint, terminal output).

Entries are append-only. Nothing is rewritten in place; that is what lets a session be forked, shared and read concurrently without a writer invalidating a reader’s view. A display settled after its turn ended is expressed as an update entry naming its target, folded in at read time:

A server-managed conversation (openai.ConversationsSession) can hold only items; other kinds are dropped on write, because failing a run over a UI annotation that could not be stored server-side is worse than losing it.

2.5c Session layering

A session is three layers, split along what varies:

Reads page on sequence numbers, not offsets. Entries keep arriving, so an offset shifts under a concurrent append and a second page silently skips or repeats. A negative Cursor.Limit takes the most recent N.

Derived state is a fold, never a stored field. State and Stats recompute from the entries. A field maintained beside the log has to be updated on every write and can disagree with it after a crash, a concurrent writer or a fork; a fold cannot. State folds the ACTIVE BRANCH — the view recovery reads — not append order: a dangling call on an abandoned attempt is not pending, and folding every branch reported it forever as a stuck approval nothing could clear. Stats stays whole-log, because it counts what is stored.

ContextEntries is the active branch minus what compaction folded — the checkpoints themselves stay in the view (they carry the summary and stand-ins the projection renders), while the entries their exclusions name are left out: re-sending folded history would undo the compaction, and a cursor limit must count entries the model will actually see. ProjectEntries applies the same exclusions again wherever it is called, so a view built without the filter still cannot replay folded history.

A branch view is computed from the whole log, and the cursor only trims the answer. The tree is walked by following ParentID links back from the leaf, which no backend can express as a range scan, so ContextEntries reads every entry and pages the projection afterwards — a Cursor passed to it saves nothing on the way in. A run reads once per turn, so the cost grows with the conversation and compaction does not bring it down: compaction shrinks what the model sees, not what the store holds. That is a known ceiling, accepted for now because the alternative is pushing the walk into every backend. The way out, if one is needed, is an optional capability that resolves an ancestor chain server-side (a recursive CTE in SQL) rather than a second canonical view.

Capabilities a store may or may not have are optional interfaces, not required methods: AtomicReplacer, GuardedReplacer, CompactionAware. A wrapper that claims a capability delivers its contract or refuses: delegating AtomicReplacer to a wrapped store without it must return an error before touching anything, never degrade to a non-atomic Clear+Append — a caller type-asserted the interface precisely to rule that failure mode out. GuardedReplacer is delegated the same way: a wrapper over a store that cannot compare the log back errors rather than answering replaced=false, which would assert the log had moved.

2.5d Sessions are trees

An entry names its parent, so a session is a walk rather than a pile.

Fork extracts a branch; branch moves within one session. A fork carries entry ids across unchanged, so an update entry naming one still finds its target. The destination is written through session.ReplaceEntries, so a storage that can swap atomically (AtomicReplacer) never shows a cleared-but-unfilled fork target when a failure lands mid-write.

2.5e Session lifecycles

A SessionRepo owns which sessions exist, separately from their contents.

2.5e2 The entry lifecycle contract

Everything above describes what a session is. This describes what happens to an entry over its life — minted, addressed, walked, removed — and it exists as one section because the alternative was tried: these rules were decided one at a time, in whichever backend a defect was reported against, and four implementations drifted apart on every one of them.

The rule this section is really about: none of it is a backend’s decision. Each item below names who implements it. Where that is “shared”, a backend that answers the question itself is a bug even if its answer is right, because the next backend will answer differently.

Identity

Entry identity

Sequence numbers

Seq is a cursor position, and that is the whole of its meaning.

The tree

What must be one step

Absence

2.5f Compaction

Compaction is a run-level concern. Deciding what to drop needs the model, the usage numbers and the context window; all three belong to the run, so the configuration does too (RunOptions.Compaction).

A checkpoint is appended, never a rewrite — and it copies nothing. CompactAfterRun records the pass as an EntryKindCompaction entry whose payload names the entries it folded (ExcludedIDs) and carries only what exists nowhere else: the summary text, and a CompactionFold per folded group whose stand-in renders in the group’s place (anchored Before the first surviving entry after it). The entries the pass kept are read from the session itself — never from a copy inside the checkpoint, which would fall out of step with any later change to the entry it duplicates. The folded entries stay in the session untouched, so a reader can offer to expand them and a fork from before the checkpoint still finds its full history. ContextEntries leaves folded entries out and ProjectEntries renders each live checkpoint’s summary up front, so the next run reads the shorter context without recomputing the pass.

Writing a checkpoint is an optional capability (CompactionCheckpointer): a compactor that only reshapes the context in memory is useful and has nothing durable to say.

A checkpoint is bound to its pass. Checkpoint(seen) names the entries the caller’s own Compact saw, and a compactor whose state no longer describes them — one shared across concurrent runs, re-aimed at another session between the pass and the checkpoint — reports nothing rather than recording the other conversation’s exclusions (and content) here. A lost checkpoint costs one recomputed pass; a stolen one is a cross-session leak.

The one path that still rewrites is openai.CompactionSession, because the server’s compact API returns a replacement rather than a decision.

A rewrite built from the response chain never deletes what that chain never saw. The last response holds everything that stood in front of the model when it answered — its own output, and every tool output, handoff acknowledgement and steer before it. Those are on the chain, and a summary that folds them away read them first. A log outgrows that chain in four ways, and the runner reports all four through the single flag CompactionArgs.OffChainItems:

The last three are facts about the run’s past that nothing later undoes, so they ride across an interrupt/resume on RunState.OffChainHistory. A resumed run re-reads no history and re-runs no filter; answering from its own options instead would be silently false whenever the caller did not repeat Conversation.Settings, which is the one direction this flag must never fail in. Position is the opposite case — it clears between runs — so it is recomputed every time and never carried.

openai.CompactionSession answers the flag by compacting from the stored items instead of previous_response_id: the same conversation, minus the deletion. A caller who PINNED CompactionModePreviousResponseID gets the pass skipped and abandoned: off_chain_items on the span instead, because the mode is the one thing they configured. For position that skip is transient — the next run starts clean. Past a truncating window it is NOT: a window does not clear, so the pass is abandoned every run while the log grows. Pinning the chain mode and configuring a read window is a conflict only the caller can resolve, by dropping one of the two — which is why the window half is measured rather than assumed, so a log that never reached its window is never mistaken for that conflict. The runner does not decide this by skipping the pass. It used to, and that took the decision away from a storage with no chain to be wrong about: an agent that always finishes through a terminating tool never compacted at all.

That rewrite is guarded by the sequence number it read. Reading the history and writing the replacement are separated by a network round trip, and an entry appended inside that window is in neither — an unconditional swap deletes it silently, with no copy left anywhere. So the swap goes through GuardedReplacer: the store compares its highest sequence number back and writes only while it still matches, comparison and write in ONE step, taken under whatever already serializes that store’s appends. The number compared is the highest the store HOLDS, not the highest it ever issued — a session emptied by a pop would otherwise refuse every replace forever — and zero for a log read empty. A pass that loses the comparison is abandoned, not retried and not merged: nothing is written, the reason is recorded on the compaction span as abandoned, and the next pass starts from the history as it then stands, since compaction is housekeeping and one skipped pass costs size alone. A store without the capability keeps the unguarded swap: refusing to compact for it would take the feature away from every third-party store rather than from the race.

The rewrite keeps the ids of the entries it carries over. An update entry names its target by id, so re-minting on the way through leaves it pointing at an entry no longer there, and a fold that finds no target is dropped in silence — the late display it carried (a background task’s card) lost for good. Those entries are numbered afresh regardless (§2.5e2).

2.5g Context overflow

Compaction predicts; overflow recovery reacts. A prediction is an estimate — a token count the SDK guessed, against a window the provider never states exactly — so it will sometimes be wrong, and the failure it misses is one the run cannot otherwise survive.

2.5h Crash recovery

session.Recover repairs a session a killed process left inconsistent.

2.6 Guardrails

One Guardrail type covers every stage. Placement decides scope: guardrails in RunOptions or on an Agent apply to the whole run — their tool stages cover every tool that agent exposes — while guardrails on a Tool apply to that tool only.

Stage When Decision space
input First turn, before the model call (Blocking) or concurrently with it (default) Allow / Replace / Trip
output After the final output is produced, before persistence Allow / Replace / Trip
tool_input After arguments are parsed, before tool lifecycle callbacks, before execution Allow / Replace / Trip
tool_output After the tool runs, before the result is fed back Allow / Replace / Trip

Ordering, concurrency and cancellation:

Replace semantics: the decision’s Message replaces the inspected content.

Streaming and blocking share one run loop, so they share one guardrail behavior: concurrent with the model call, with cancellation.

2.7 Tools

Return values

A tool returns a ToolResult (§2.7b); plain values are wrapped. What the model sees, given the result’s Content:

The tool returns The model sees
string verbatim
nil ""
ToolOutputContent (text / image / file) native multimodal content items
anything else JSON encoded
a value that cannot be JSON encoded fmt.Sprintf("%v") — degraded, never dropped

An empty result with no error is a success with no output, not a failure.

Errors

Approval

2.7b Tool results

A tool returns a ToolResult, not a bare value. The distinction it makes is that some of what a tool knows is not for the model:

A tool that returns a plain value (string, struct, ToolOutputContent) is wrapped automatically, so the ordinary tool is unchanged.

2.7c Tool capabilities are fields

*Tool is the only tool type, and everything a tool can do beyond being called is a field on it: OnInvoke, Description, ParamsJSONSchema, Strict, NeedsApproval / NeedsApprovalFunc, Guardrails, Timeout, Sequential, IsEnabled, FailureErrorFunction, Deferred, RetrySafe.

“Errors abort the run” is the absence of a failure handler: FailureErrorFunction = nil. It is expressible because it is a field — an absence a wrapper could not have represented.

Why fields and not an interface with optional side interfaces: that was the previous design, and it had exactly one concrete implementation (*Tool) plus eight wrapper shells whose only job was to set what were already fields on it. The wrappers required a ToolAs[T] unwrap walker, and a bare type assertion through a wrapper silently reported that a tool needing approval needed none — a trap the design created and then had to specify around. A field cannot hide behind a wrapper.

2.7d Tool-loop safety valves

The loop’s own failure modes — not the model’s ordinary mistakes, but the ones where an agent keeps going and gets nowhere:

2.7e Truncated responses

A response the provider marks status="incomplete" with reason max_output_tokens was cut off at the output-token limit.

2.7f Usage attribution

2.7h Schema validation

Tool arguments, handoff input and structured outputs are validated against the whole JSON Schema, not a root-level required check.

2.7i Progressive tool disclosure

A tool marked Deferred: true is withheld from the model until some ToolResult.AddedTools names it.

2.7g Tool progress

ToolContext.Emit pushes a partial result to a streamed run’s consumer as a ToolProgressEvent.

2.7j Sandbox command policy

CodeToolConfig.Policy filters commands before the approval gate.

2.7k Persistent shells

exec_command optionally reuses a named shell, so cd, exported variables and an activated environment survive between calls.

2.7l Sandbox tool argument decoding

exec_command decodes its own arguments — it is a hand-built tool, not a NewTool wrapper, so nothing upstream of OnInvoke catches a malformed call. Three rules keep one from costing more than the call:

2.8 Nested agent-as-tool attribution

Aspect Attribution
Usage Folded into the parent run’s Usage
Trace Nested spans join the parent trace, parented by the function span that triggered them
Logging The parent’s LogConfig is inherited, like the tracer — the nested run must not be the silent part of the workflow. Records carry the agent name, so parent and nested lines stay tellable apart
Session Not shared with the parent; give the nested run state of its own (or the parent’s Session, explicitly) via AgentToolConfig.ModifyRunOptions
Interruptions Propagate upward as the parent’s own; nested RunState is cached on the parent RunState keyed by call id
Guardrail results The nested run runs its own guardrails; results stay on the nested result and are not merged into the parent RunResult

An agent used as both a handoff target and an AsTool target follows whichever path invoked it — handoff shares the run (and its session), agent-as-tool starts a nested run (with its own session unless configured otherwise). The two paths do not interact.

2.9 Budgets 🚧

🚧 Only the turn dimension ships today; tokens and deadline are not implemented. The three dimensions are OR-ed: whichever trips first stops the run.

Dimension How it is counted
MaxTurns Model calls. Not reset by handoffs. A HITL resume continues accumulating.
MaxTokens Cumulative Usage.TotalTokens. Nested agent-as-tool usage counts, because it folds into the parent Usage.
Deadline A time.Duration measured from the start of the run.

🚧 LLM calls made by compaction itself count toward MaxTokens but not toward MaxTurns.

When a budget trips mid-turn, the current tool batch is allowed to finish before the run stops. Stopping mid-batch would leave dangling calls, which §2.5 forbids.

2.10 Errors and recovery

How the safety valves compose

ExecOptions stacks several independent protections — MaxTurns, ToolLoop, Overflow, ErrorHandlers, ShouldStopAfterTurn / PrepareNextTurn — and their interactions are pinned, not emergent:


2.11 Event fan-out

One producer’s events reach many independent consumers through Fanout[T].

Rejected alternatives, both worse: dropping silently (corrupts the consumer’s view undetectably) and disconnecting the slow subscriber (turns a recoverable hiccup into a visible failure).

Fan-out is a requirement, not an optimization, and that was measured rather than assumed. A slow consumer couples to the producer under iter.Seq2 (13.1× the ideal wall clock) — but it also couples under a buffered channel, just later: with chan(64) the producer still finished at 992 ms against a 100 ms ideal, once the buffer filled. Neither stream shape isolates a slow consumer on its own, so per-subscriber buffering is needed either way.


2.11b Run control

Run returns a RunControl alongside the stream. It is safe to use from another goroutine, including before ranging begins.

RunControl is stop + injection + pending, nothing more. An introspection trio (Phase/CurrentAgent/CurrentTurn) shipped here for a while and was removed with zero consumers: every real host renders progress from the stream’s own events, which carry strictly more information. Beyond StopAfterTurn, it has three injection methods feeding one arrival-ordered queue; the two consumption points filter by kind, and only two kinds may extend a run that was ending:

  Consumed at Extends a finishing run
Steer the save point, or the final output yes — it is “change course”
NextTurn the save point only no — it rides along with a turn the run was taking anyway
FollowUp the final output yes — the exchange lands, then the next one starts

2.11e Span coverage

2.11d Diagnostics

A Diagnostic records trouble a run went through and survived.

2.11c Logging

2.12 Middleware

RunOptions.Middlewares wraps a run, outermost first — the order they are read in is the order they see the run.

A middleware wraps a whole run: it may edit the input and options, call next zero or more times, and replace or suppress events. That is what it is good at — retrying, re-running with feedback, resuming from an interruption — and it is also what bounds it.

What is not middleware, and why:

  Why it stays in the loop
Handoffs Change which agent the state machine is in
Guardrails Race the model call and can cancel it
Session persistence Has a boundary only the loop knows (§2.5)
Tracing Spans nest with the loop’s own structure
ExecOptions.ErrorHandlers Needs the run’s in-flight items to build RunErrorData, and the loop’s completion path to persist what it recovers. A middleware sees a terminal error and can reconstruct neither
ModelOptions.InputFilter Per turn, not per run

Expressing any of them as middleware would turn an invariant into an implicit protocol between wrappers.

A middleware must not swallow the stream. One that re-enters the run forwards each attempt’s events and holds back only RunCompletedEvent, which is the one event whose meaning it owns — “this attempt finished” versus “the run finished”. A middleware that buffered everything until it was satisfied would make a long retry look like a hang, which is the opposite of what streaming is for.

That norm is a three-clause contract, and an author owes all three (stated on RunMiddleware’s godoc, where an author starts):

  1. Every event other than RunCompletedEvent flows through as it happens.
  2. RunCompletedEvent appears exactly once, last, on a run that ends without error — and zero times on one that errors. A re-entering middleware therefore holds back each attempt’s completion event and emits a single one for the attempt it accepts.
  3. Once the consumer stops ranging — yield returned false — nothing more is yielded, not even an error: there is nobody to receive it.

The shipped middleware that re-enter or terminate a run keep the contract through the package’s internal collect/finish helpers (a pure pass-through that only observes keeps it by construction); a third-party author implements the same three clauses directly.

Order is behavior. A middleware that resolves something about one attempt (answering an approval pause) must sit inside one that decides whether to make another attempt (an evaluator loop, a retry). Reversed, the outer one judges a result the inner one had not finished producing.

A stop the caller asked for is visible on the result (RunResult. StoppedEarly), wherever the run ends — at the turn boundary that saw the request, and equally on a run that reached its final output on that same turn. The flag answers “did the caller stop this”, not “where did it stop”: the stop lives on the control for the whole run and is never cleared, so a middleware that re-runs (Loop) cannot tell “the agent finished” from “the human stopped it” without it, and started every remaining attempt — including for single-turn agents, which never reach a turn boundary at all.

A middleware that resumes strips Middlewares first. The chain is already unwound at that point; resuming with the run’s own options would re-enter that middleware and every one outside it.

The public ResumeRun applies opts.Middlewares exactly as Run does. A caller resuming with the options it ran with gets the wrapping it ran with — logging still logs, Retry still retries, Approval still resolves further pauses. (The rule above is what keeps the two from compounding: an in-chain resume passes stripped options.) The paused state’s agent and input are already decided; a middleware’s edits to those fields do not apply on resume.

Workflow middlewares (Plan, Todo) rewrite the ENTRY agent only — handoff targets keep their own toolset, the same scoping as every instruction-injecting middleware. Their invariants:


2.13 Background tasks

A task is a sub-agent that outlives the turn that started it. The invariants below are behavior, not implementation detail — see tasks.md.


3. Capabilities deliberately not provided

Beyond the non-goals in §1.2:

Not provided Why
A built-in default model The SDK does not guess which model you want. With none configured, Model returns a *UserError.
Implicit model-parameter injection (e.g. reasoning defaults for a model family) Explicit beats implicit. Set ModelSettings yourself.
A free-form request passthrough dict ExtraBody / ExtraHeaders / ExtraQuery cover it, and they are typed.
Redis / encrypted session backends Implement the session storage interface. The SDK ships in-memory, JSONL and SQL.
A pop/undo storage primitive Removed after shipping with zero callers: a run never pops (entries are append-only, §2.5b), and every host that wanted “undo” had its own deletion primitive against its own store. Seven implementations of EntryPopper/ItemPopper existed for no consumer.
A REPL and graph visualization Not an SDK concern.
A graph / fan-out orchestrator on top of tasks (map over N inputs, join, branch on model choice) A task’s work may span several runs (Config.Continue, §2.13): a fixed sequence, a loop until a check passes — one job, one session, one transcript, which is what keeps it cheap and legible. Fanning out into N parallel children with a join is a different thing: N sessions, N transcripts, a merge nobody has designed the semantics of yet, and a step toward the general workflow engine handoffs and tasks were chosen over (§5.1). Parallel work is what spawn_task is for; a host that needs a join writes it against the task API.

4. Reference behavior you can rely on

Defaults that callers may depend on:

Setting Default Note
MaxTurns 10 MaxTurnsUnlimited (-1) disables it
Strict schemas on Chaining NonStrict() relaxes both the advertised schema and local validation, atomically — but only on a tool that got built; an argument type strict mode cannot express at all needs NewToolNonStrict (§5.11)
Handoff input schemas strict Handoff.NonStrictSchema: true opts out; the zero value is the strict default
Tool errors fed back to the model DefaultToolErrorFunction; set the field to nil to make them fatal
Tool concurrency unlimited Bound with MaxToolConcurrency
Input guardrails concurrent with the model call Blocking: true makes one a gate
Session persistence after each turn Final turn is written after output guardrails pass
RunResult.Usage / RunState.Usage detached snapshot Never the live accumulator; read without synchronization. Mid-run, RunContext.Usage is live — read it via Snapshot()

5. Recorded design decisions

These have been discussed and settled. Read the rationale before reopening.

A decision is only as good as the reason recorded under it. Entries whose stated reason is a citation of another codebase rather than a property of this one get marked 🔁 reason under review: the decision stands, but it may not be closed by citation — re-deciding one means replacing the citation with a reason that stands on its own, or changing the decision, and dropping the mark in the same change. Every entry below currently carries its own reason; the mark is the mechanism for the next time one does not.

5.1 Handoffs stay; graph orchestration does not replace them

A handoff is “switch agent at runtime”; a graph is “declare the topology up front”. They solve different problems. Our handoffs carry an InputFilter and history folding; the equivalent in a graph model takes a lot of glue. Graph orchestration, if it ever arrives, belongs above handoffs — serving task orchestration, not replacing agent switching.

5.2 Names describe the thing, and renames are batched

A name earns a rename only when it misdescribes or violates a Go rule — never to “look less like Python”. RunItem, RunResult, RunContext and friends read fine as Go and stay. What did not, and was renamed in the pre-v0.2 breaking batch:

The rule that survives for the future: a rename is a breaking change and is batched into a window users absorb once — this batch rode the v0.2 window alongside the structural collapses; the next one is the openai-go v4 bump (§5.5b).

5.3 Instructions and Prompt both stay; both are func types

Prompt (a server-stored prompt template with a version and variables) is a Responses API capability, not a porting artifact. The two compose: a stored prompt provides the base, instructions append to it.

Their shape: Instructions and PromptProvider are func types, not interfaces. As single-method interfaces their only implementations were unexported types in this package behind adapter constructors (InstructionsFunc, PromptFunc) — a plug point nothing ever plugged into. A func type is the same capability assigned directly; StaticInstructions / StaticPrompt cover the fixed case and WrapInstructions composes. The Agent.GetSystemPrompt / Agent.GetPrompt forwarders became unexported resolution points — resolution (nil handling, prompt-ID validation) is the runner’s job, not API surface.

The same rule collapsed tasks.AgentResolver, tasks.Launcher, tasks.Stopper and tasks.WakeGuard: each was a single-method interface with a ...Func adapter nobody used in production — hosts assigned method values anyway, and a method value satisfies a func type just as directly. A single-method injection point is a func type unless a second method is already in sight; tasks.Store (multi-method) keeps being an interface.

5.4 A tool is a struct, not an interface

*Tool is the tool type. There is no Tool interface, which is how the “no hosted tools” decision (§1.2) is enforced: a provider-hosted tool has nowhere to be introduced, because there is nothing to implement.

This replaced a sealed interface with an unexported marker method. The seal was doing the same job, but it also invited a wrapper hierarchy to carry optional behavior, and that hierarchy needed a lookup protocol (§2.7c) to be usable. A struct closes the kind and carries the behavior in one move; behavior stays open because the fields are exported and a variant is a copy.

5.5 Internal item types are Responses wire types

Zero conversion, zero information loss — reasoning ids, encrypted_content and strict schemas all survive round-trips. The cost is that non-LLM entries need a session.Entry wrapper to have somewhere to live.

5.5b The wire types couple our compatibility to openai-go’s

§5.5’s zero-conversion choice has a price with a name: InputItem and friends are type aliases of openai-go/v3 union types, and they appear in nearly every exported signature. A major-version bump of openai-go (v3→v4) is therefore a breaking change of this SDK’s entire API surface, whatever else it contains.

This is accepted, not overlooked:

5.6 Background work runs in-process, not in isolated processes

Background sub-agents (“tasks”) run as nested runs inside the same process, each with its own hidden session, reporting back by injecting a notification message into the parent session.

The alternative — supervising one OS process per session and talking to it over a line protocol — was considered and rejected. It buys crash isolation and independent working directories at the cost of IPC, serialization, and a second lifecycle to manage. Nested runs already give us independent sessions and configuration; the isolation is not worth the machinery at this scale.

5.6b Tracing stays vendor-neutral; OpenTelemetry is a separate module

The core tracing package has no dependencies: a span is a flat record with string ids and a Data map. tracing/otel translates that into OTel spans and carries the OTel SDK, per §5.7.

The reconstruction is not free — our spans are exported after they finish, often children first, while OTel builds trees from live spans through a context. It works by pinning a custom IDGenerator to the ids the span already has. Two invariants fall out and must hold:

A trace has one root span per agent, not one per trace. A handoff finishes the current agent span and opens the next one under the same (empty) top-level parent, so an N-handoff run contributes N+1 parentless spans, arriving in separate export batches. tracing/otel therefore keeps a trace’s workflow metadata after stamping a root span and reclaims it by bounded eviction: releasing it at the first root would leave every agent after a handoff without a workflow name.

The alternative — making the core emit OTel spans directly — was rejected: it puts a heavy, fast-moving dependency in every consumer’s build for a feature most do not use.

5.7 A submodule exists only to keep a heavy dependency out of the core

The repository is a Go workspace with a root module (the SDK) plus submodules. The only reason to split something into its own module is that it would otherwise pull a heavy dependency into the core. Test helpers, small utilities and anything dependency-free stay in the root module regardless of how self-contained they are.

mcp is a module for that reason and no other: modelcontextprotocol/go-sdk brought seven of the root module’s eleven indirect requirements with it (uritemplate, x/oauth2, x/time, x/tools, x/sys and the segmentio pair), taxing every build that never speaks MCP. The core does not import it — agents.MCPServer is the inversion that lets an Agent hold servers without the dependency — so the split cost one go.mod and moved no import path.

5.8 Public API compatibility begins at v1.0.0

A minor release before v1.0.0 may break exported identifiers. Each one is recorded in the release notes with the old spelling beside the new, and they are batched into as few releases as the work allows, so a user absorbs one migration rather than a drip.

This section used to promise a deprecation cycle from v0.2.0 onward, and the promise was not kept: the eleven breaking commits after v0.2.1 — the tool and item collapses, the naming batch, the agents/session split — each renamed or removed outright. Keeping a rule nobody follows is worse than not having it, because it teaches the next reader that this document describes intentions rather than behavior. The API is still finding its shape; the deprecation cycle begins when it stops, at v1.0.0.

5.9 A parent-linked checkpoint chain for execution state is declined

Microsoft’s agent-framework-go checkpoints every workflow superstep into a parent-linked store (CreateCheckpoint(..., parent) / RetrieveIndex(withParent)), so a run can resume from any historical point and the checkpoints form a browsable tree — time-travel debugging included. It needs that structure because its Session is a key-value bag: the checkpoint tree is its only history.

Declined here, because this SDK already carries the stronger halves of that design:

The net capability a chain would add — deterministic replay, and a byte-exact “resume turn N with the execution state it had then” — does not justify a second history structure beside the tree, with its own consistency rules against it.

Revisit only with a concrete replay/debugger need, and then on three terms: a checkpoint is a session entry kind (payload: a trimmed RunState, projected to nothing), so the tree stays the only history structure; a deterministic execution mode comes first, because replaying a nondeterministic run replays into different behavior; and the payload must be trimmed — RunState carries every raw response, and a per-turn copy of that grows quadratically.

5.10 Non-Responses backends adapt at the model boundary

The canonical item and event format stays the Responses wire format (§5.5) even when the backend speaks something else. An adapter translates in both directions inside its own packagemodels/anthropic for the Messages API — so the runner, sessions, run state and the server never learn a second format. models/modelkit (root module) holds the shared halves: the input walker, item/event synthesizers that stamp round-trippable raw JSON, and the feature-rejection helper.

The runner’s consumption contract, which every agents.Model implementation in this repository must satisfy (enforced by modelkit/conformancetest; both in-repo providers run it):

Anthropic-specific mappings recorded with the adapter: mid-history system/developer messages travel as mid_conv_system blocks in system turns (the Messages API has no plain system role for input text; top-of-run instructions use the top-level system parameter); thinkingreasoning, with the blob in encrypted_content carrying an adapter prefix (thinking_signature: / redacted_thinking:) — a blob without a recognized prefix is another provider’s reasoning and is dropped on replay rather than sent as a bogus signature; stop_reason: max_tokensincomplete/max_output_tokens; stop_reason: refusal → ONE canonical refusal message and nothing else (the response’s text, else stop_details.explanation, else a fixed line — never empty): the Messages API reports refusal out-of-band, and a refused response’s partially generated tool_use blocks must not survive into items the runner would execute before it ever looks for the refusal — so ModelRefusalError and model_refusal handlers fire exactly as on any backend (a streamed refusal’s mid-stream item.done events may still show text/tool items; the terminal rebuild is what the runner reads); model_context_window_exceeded → an error carrying that marker (§2.5g); Reasoning.Effort → thinking budgets (minimal 1024 / low 4096 / medium 16384 / high 32768) with MaxTokens defaulting to 8192 (grown to budget + 8192 when the budget would not fit under it), and thinking rejects Temperature/TopP/forced tool choice up front; prompt caching is the request-level cache_control marker, on by default (Provider.WithPromptCaching(false) opts out). models/anthropic is a submodule per §5.7 — it carries the anthropic-sdk-go dependency; modelkit adds none, so it stays in root.

5.11 Construction errors split by data provenance

A constructor whose failure can only be a programmer error panics; a constructor whose input is runtime data returns an error.

NewTool and AgentAsTool derive their schema from a Go type: for a given type the outcome is deterministic, so a failure (non-struct args, a field no schema can express) is a bug that any test constructing the agent surfaces immediately — the regexp.MustCompile precedent. They panic, which also keeps constructors chainable inside Agent{Tools: []Tool{...}} literals. NewRawTool takes a schema that is data (loaded from a database or config), so a bad schema is an expected input, not a bug: it returns (*Tool, error).

One of those failures is a shape rather than a bug: strict mode cannot express an any/interface{} field or a map with arbitrary keys at all. Tool.NonStrict does not rescue it — it relaxes a tool that already exists, while the strict schema is generated during construction — so NewTool has a non-strict twin, NewToolNonStrict, mirroring the OutputType / OutputTypeNonStrict pair. AgentAsTool has no such twin: its schema is hard-wired strict. That is a recorded gap, not a decision — no caller has needed an unconstrained field in a nested run’s arguments yet, and until one does the way out is building the Tool value directly. The normalization errors phrase their advice accordingly: they say to turn strict off where the schema was built, and name the constructors only as the Go-type example, because the same message is reached from NewRawTool and NewDynamicOutputSchema, where the switch is elsewhere.

The earlier design — returning a tool that errors on every invocation, surfaced by the runner before the first model call — deferred a deterministic bug to runtime and cost a field (constructionErr) plus a runner check. Rejected.

5.12 One user-context entry point

RunOptions.Context is the only way user data enters a run; every run wraps it in a fresh RunContext. There is no field to inject a pre-built RunContext: nothing in the SDK needed it (nested runs share the parent’s Context value and fresh accumulators), and cross-run usage totals are sums over each RunResult.Usage. Two fields expressing one concept was the cost; a run owning its RunContext outright is what the guarantee “a run’s accumulators start empty” rests on.

5.13 AgentToolConfig configures the tool, ModifyRunOptions the run

AgentToolConfig holds only what has no RunOptions counterpart: the tool’s name, description, visibility, approval gate, error rendering, output extraction, streaming callback and input rendering. Everything about the nested run itself — session, turn budget, conversation, model, guardrails — goes through the single ModifyRunOptions channel. Mirror fields (MaxTurns, Session, ConversationID) were removed: each was a second spelling of a RunOptions field, and the escape hatch’s existence proved the dedicated-field approach could never be complete. A ConversationID set via ModifyRunOptions is still cleared when a paused nested run resumes (the serialized state already carries the conversation).

5.14 Sandbox file tools share exec’s path view

The sandbox file operations (ReadFile, WriteFile, CreateExclusive, ListDir, RemoveFile, Rename) resolve paths with shell semantics, identical to exec_command: a relative path resolves under the working directory, an absolute path is used as-is. The isolation boundary is the sandbox itself, not the working directory — for local, ssh and docker-persistent, exec already reaches everything on that filesystem, so pinning the file tools inside WorkDir adds no protection; it only creates a second path universe. The model learns real absolute paths from exec output (pwd, ls, git status) and echoes them into the file tools, so the two surfaces sharing one view is what makes those calls work. (An earlier workdir-rooted “virtual chroot” design was dropped for exactly that failure: absolute paths got re-joined under WorkDir and read as “not found”.)

The one exception is docker bind-mount mode, where file operations run on the host side of the mount while exec runs inside the container — the container’s isolation does not cover them. There they are confined to WorkDir via os.Root (which also polices .. and symlink escapes); absolute paths must lie under the in-container mount point (/workspace, the only view the model ever sees) and are translated to their host-side names, and anything else fails with sandbox.ErrOutsideWorkDir — an explicit “outside the working directory” to the model, never a silent re-rooting.

Docker’s working directory may be narrowed to a subtree of the mount (Options.ContainerWorkDir, /workspace by default, validated by New to be /workspace or below it): the mount point never moves, but commands run — and relative paths in the file tools resolve — in that subdirectory, exec and file tools moving together per this section’s rule. Absolute /workspace/... paths keep addressing the whole mount, exactly like a shell cd‘d into the subtree still can.

5.15 Streaming-only backends adapt with a Model decorator

Some backends accept only streaming requests — the ChatGPT Codex backend (chatgpt.com/backend-api/codex) rejects a non-streaming POST with 400. The adaptation is NewStreamOnlyModel / NewStreamOnlyProvider: a provider-agnostic decorator whose Respond runs the request as an internal StreamResponse and assembles the final ModelResponse from the terminal event; StreamResponse passes through untouched.

It is a Model decorator, not an HTTP middleware, because forcing "stream": true at the transport layer would hand an SSE body to a caller that parses a JSON response — the request shape and the response parser must switch together, which only the model boundary sees. Assembly is shared with the runner’s own streaming path (one responseAssembler), so the two paths cannot drift; like that path, the assembled response carries no RequestID and treats a length-truncated response.incomplete as an arrived (not failed) response. Compose it innermost, directly on the backend it adapts: decorators above it (retry, fallback, routing) then see a severed stream as an ordinary Respond error and handle it normally.

5.16 A severed stream retries only before output, with the preamble held back

Three rules govern a stream that dies before its terminal event:


5.17 The session layer is its own package

agents/session owns stored history: entries, storage, the semantics struct, projection, the tree, forking, recovery, and the wire codec (MarshalInputItem / UnmarshalInputItem — their consumers are exactly the storage implementations). The runner imports session; session never imports the runner. Its one upward need — building an entry from a live RunItem — stays in agents as EntryFromRunItem.

Names inside dropped their Session prefix (session.Entry, session.Storage, session.Repo, session.Recover, session.Fork, session.ErrNotFound); session.Session keeps the stutter the way context.Context does, because the concept IS the package.

The value types shared by both layers — Source, ItemDisplay, RequestUsage, Diagnostic, ErrorCode — live in session (entries persist them) and are aliased in agents (agents.Source = session.Source), because they are equally part of the runner’s surface: every RunItem carries a Source, every result reports RequestUsage. An alias is transparent — one type, two import paths — so neither layer’s API is second-class. Each alias keeps the name it aliases. A renamed alias stops being transparent the moment anything spells the type out: the compile error, the godoc and the reflected name all say the session name while the code says the agents one. (agents.ItemDisplay = session.Display was the one that drifted; session’s type was renamed to ItemDisplay to close it, which is also the more accurate name there — what it projects is an item.) ErrorCode specifically: the vocabulary sits in session because entries and diagnostics persist codes; the derivation (CodeOf, Classify) stays in agents with the error types it reads.

Session-only names are deliberately NOT aliased: code that works with stored history imports the package that owns it. This was the §6.4 split, taken after the structural collapses so the code moved once.


5.18 A RunState decodes across a version window, and the window is earned

RunStateFromJSON accepts the same schema major from runStateOldestDecodableMinor up to RunStateSchemaVersion, rather than demanding strict equality. The reason is what a pause IS: an approval waits on a human, the process may be redeployed while they decide, and refusing the state afterwards strands the run for a reason the user had no part in. The field-by-field fallbacks the decoder already carries — a zero MaxTurns meaning DefaultMaxTurns, UsagePending *bool separating absent from false, an absent cursor meaning zero — are what make an older minor readable; under strict equality they were cost with no payer.

The window is not free and it is not retroactive. A minor may only ADD fields. A bump that REPLACES or reinterprets one must raise runStateOldestDecodableMinor to itself, because such a state decodes successfully with its old fields silently dropped — strictly worse than a refusal, since the caller is told the resume is faithful.

The floor sits at 4 and does not reach back further: "1.3" was stamped by released builds both before and after the four guardrail-result keys collapsed into a single guardrail_results, so two incompatible payloads share that one string, and accepting it would drop every recorded guardrail result from the older shape — resume is the only path that carries first-turn input-guardrail results forward at all. The bumps since have been purely additive (1.5 the off-chain-history flag, 1.6 the host extra map), so the window is now real: a 1.4 state decodes under a 1.6 SDK.

A consumer triaging stored states must apply the same window, via RunStateVersionSupported, never string equality against RunStateSchemaVersion — an equality gate destroys states an additive bump resumes fine (agents-server’s approval pre-flight did exactly that until it switched).

RunState.Extra (1.6) is host-owned state riding the pause: the SDK marshals and unmarshals it verbatim and never reads a key. It exists because a build-time agent transform (middleware.Plan.Apply) returns fresh state on rebuild, so what the transform knew — a plan phase’s unlock — must travel with the pause or the host invents a side channel. It covers pause→resume only: a fact that must survive a crash mid-run needs the host’s own durable write at the moment it happens (PlanPhase.OnUnlock), and the two records answer different questions.

5.19 A named container is adopted only against a configuration fingerprint

A persistent docker sandbox with a fixed ContainerName can find the name already taken — by a container a previous process run left behind, or by something else entirely. Adoption (taking the existing container over instead of erroring) is allowed only when the container proves to be ours from the same configuration: creation stamps a label carrying a hash of every security-relevant option (image, runtime, user, network, bind source, resource limits), and adoptNamed requires an exact match.

Matching on image + mount alone — the original rule — was a hole: a container created under a laxer policy (network on, root user, no limits) passed both checks and silently served a config that no longer allows any of it. The fingerprint hashes effective values (the resolved user, the applied PIDs default), so equivalent spellings of one configuration still adopt. ContainerWorkDir is excluded on purpose — persistent mode passes the working directory per exec, so it does not change what the container is. A container without the label (foreign, or created before the label existed) is a hard error naming the remedy: remove or rename it. That cost lands once per legacy container and buys the guarantee that adoption can never widen a sandbox’s blast radius.

5.20 A shared connection is not a caller’s to cancel

An MCP session is shared by everyone configured with that server — several runs, their background tasks, other conversations — while a run’s context belongs to one of them. A request on a shared connection therefore rides the connection’s context, not the caller’s, and the caller’s cancellation is honored by returning from the wait rather than by cancelling the request. The answer that arrives afterwards is dropped.

The alternative was tried and cost a great deal: the streamable HTTP transport issues each request on the context it is handed, and one cancelled mid-flight makes the go-sdk fail the whole CONNECTION — a sync.Once closing its failure gate. Every later call by anyone then answers “client is closing” until something reconnects, which nothing does. One person stopping one run was observed failing five background tasks across two conversations inside seven seconds, each blamed on its own agent’s MCP server rather than on the stop that actually did it.

The price is one in-flight request outliving its caller, bounded by the connection’s own lifetime (Close ends it). That is the right trade against a connection outage for every other user of the server. The rule generalizes: a resource shared between runs may not be handed a single run’s cancellation — a per-run deadline on a per-process resource is a way for one run to break another.

5.21 A dead shared connection repairs itself, and a tool call is not repeated

Isolation keeps one caller from killing the connection; it does not make connections immortal. A server restarts, a proxy drops an idle socket. Nothing in the go-sdk reconnects, so the connection owns its own recovery: given a way to rebuild its transport (mcp.Options.Redial), a session found dead is replaced in place. In place matters — every holder of that server recovers, not only the runs that start afterwards, which is the difference between one task failing and every task failing.

Three bounds make it safe. A death is noticed as it happens, by watching the connection rather than waiting for a caller to trip over it, because the callers who would pay are whoever is mid-run. Healing is throttled, so a server that accepts a connection and drops it again cannot become a dial loop. And only idempotent work is repeated: tools/list is re-issued on the fresh session, while a failed tool CALL is reported to the model rather than retried — a dead connection cannot say whether the server ran that tool before the line dropped, and running a write twice is worse than reporting it once.

Recovery is opt-in because only the owner of the configuration can rebuild a transport: an *exec.Cmd is spent once, an endpoint needs its headers, proxy and OAuth handler. Without Redial the old behavior stands — the failure is reported, not repaired.

5.22 Retry policy lives in one layer

Both official clients retry transient failures on their own (2 attempts by default), and agents.NewRetryModel wraps the whole model call from above; the two layers compose multiplicatively, and neither can see the other. So openai.NewProvider and anthropic.NewProvider build their clients with WithMaxRetries(0): the SDK’s one retry layer is NewRetryModel, which is provider-agnostic, classifiable (RetryIf), and observable (a span per attempt). A provider used without it performs no retries. The transport layer is re-enabled, not forbidden — the caller’s own option.WithMaxRetries is appended after the default and overrides it.

A server-suggested Retry-After longer than the configured MaxDelay ends the retries — returning that attempt’s wrapped error — rather than clamping to the cap and trying again: a wait the caller capped below what the server asked for is a signal to stop, not to retry sooner.


6. Open questions

6.1 What a v1.0.0 promise means while openai-go’s major can move

§5.5 makes the Responses wire types the canonical format, and §5.5b accepts that they therefore appear in nearly every exported signature by way of InputItem and friends. Both decisions stand. What neither records is the consequence for §5.8: an openai-go v3 → v4 bump is transitively breaking for every downstream package, not just for this one. Their function signatures name those aliased types, so their code stops compiling on the day this module’s go.mod moves — a break this SDK causes but does not author.

That is survivable before v1.0.0, where §5.8 already allows breaking minors. It is the question after: a v1 that promises compatibility is implicitly promising openai-go/v3, since honoring the promise and taking the bump cannot both happen. Three answers, none chosen yet:

Whichever is taken, it belongs in §5 before v1.0.0 is tagged, not after.

When a new case comes up that this document does not answer, add it here with the options under consideration. Implementing it means moving it out of this section and into §2 in the same change.


7. Change rules

  1. Any PR that changes behavior described here must update this document in the same change.
  2. A new §6 entry does not need an immediate answer, but the PR that implements it must move it out of §6 first.
  3. Upstream changes are tracked in upstream_watch.md with no obligation to match.
  4. Users migrating from the Python SDK: see migration_from_python.md.