agents-go

Running agents

Run agents with one of three entry points:

input is either a string (treated as a user message) or a []agents.InputItem — the OpenAI Responses API item list.

res, err := agents.RunSync(ctx, agent, "Write a haiku about recursion.", agents.RunOptions{
	Model: agents.ModelOptions{Provider: provider},
})

The agent loop

Run executes this loop:

  1. Call the model for the current agent with the conversation so far.
  2. If the model produced a final output (a message with no pending tool calls, matching the agent’s output type), the loop ends.
  3. If the model requested a handoff, switch the current agent and loop.
  4. Otherwise execute the tool calls (concurrently), append their results, and loop.

If the number of turns exceeds the budget, the run fails with *agents.MaxTurnsError — unless a MaxTurns error handler recovers it with a fallback final output.

Run options

RunOptions is grouped by what each field configures — the groups are not cosmetic; Conversation in particular collects options that constrain each other:

type RunOptions struct {
	Model        ModelOptions        // Provider / Override / Settings / InputFilter
	Conversation ConversationOptions // Session, server-managed state, projectors
	Exec         ExecOptions         // MaxTurns, tool policies, error handlers, injection points
	Compaction   CompactionOptions   // shrink context as the conversation grows (docs: Sessions)
	Guardrails   []Guardrail         // run-level guardrails, before each agent's own
	Middlewares  []RunMiddleware     // wrap the run, outermost first
	Observe      ObserveOptions      // opt-in tracing (docs: Tracing)
	Log          LogConfig           // the SDK's own structured logging (docs: Logging)
	Context      any                 // your app data, threaded through tools/guardrails/hooks
}

The commonly reached-for knobs, by group:

Conversations / chat threads

Each Run is one logical turn of a conversation. To carry history across runs you can:

  1. Use a Session — history is loaded before the run and saved incrementally as it proceeds (each turn as it completes):

    sess := session.NewInMemorySession()
    agents.Run(ctx, agent, "What city is the Golden Gate Bridge in?", agents.RunOptions{Conversation: agents.ConversationOptions{Session: sess}, Model: agents.ModelOptions{Provider: p}})
    agents.Run(ctx, agent, "What state is it in?", agents.RunOptions{Conversation: agents.ConversationOptions{Session: sess}, Model: agents.ModelOptions{Provider: p}})
    
  2. Thread items manually — build the next input from the previous result:

    res1, _ := agents.RunSync(ctx, agent, "What city is the Golden Gate Bridge in?", opts)
    input := append(res1.Input, mustInputItems(res1.NewItems)...) // via item.ToInputItem()
    input = append(input, agents.InputItemsFromText("What state is it in?")...)
    res2, _ := agents.RunSync(ctx, agent, input, opts)
    
  3. Let the server keep state — two server-managed options, each sending only new items each turn instead of resending history. Neither may be combined with a local Session (the run errors if you try):

    • Set UsePreviousResponseID: true to chain calls through the Responses API’s previous_response_id. Requires stored responses (the default; do not set ModelSettings.Store to false).
    • Set ConversationID: "conv_..." to attach the run to a server-side OpenAI conversation (the Responses conversation parameter). Create one with openai.NewConversationsSession().ConversationID(ctx), or use openai.ConversationsSession directly as the Session for the same effect with local item access.

Cancellation and deadlines

The context.Context you pass governs the whole run: cancel it to abort between turns, mid-stream, and inside tool calls (tools receive the same context).

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
res, err := agents.RunSync(ctx, agent, input, opts)

Per-tool timeouts are a tool-level setting.

Tool-loop safety valves

ExecOptions.ToolLoop bounds the ways a run can keep going without getting anywhere:

opts.Exec.ToolLoop = agents.ToolLoopPolicy{
	MaxConsecutiveErrorTurns: 3,    // default; -1 disables
	FinalTurnWithoutTools:    true, // off by default
}

MaxConsecutiveErrorTurns aborts with a *ToolLoopError after N turns in which every tool call failed. Any success clears the counter, and a turn with no tool calls is neither counted nor cleared. Without it, a model calling a broken tool spends the whole turn budget rediscovering that it is broken.

FinalTurnWithoutTools gives an exhausted turn budget one more model call with no tools and no handoffs, so the model closes out in prose rather than the run failing with *MaxTurnsError. Tool-free is the point — offered a tool it would call one. It is opt-in because a turn budget is sometimes a cost ceiling, and this spends a call it said not to spend.

A tool with Sequential: true makes its whole batch run one call at a time; see Tools.

Truncated responses

A model response cut off at the output-token limit (status="incomplete", reason max_output_tokens) has none of its tool calls executed. Each is answered with an explanation so the model resends.

This is correctness, not policy: a truncated response looks ordinary — items present, no error — but its tail may be half-formed, and a tool call’s arguments are exactly the kind of tail that gets cut. Truncation is fed back to the model rather than failing the run; every other incomplete reason still fails.

Steering a run in flight

Run returns a RunControl next to the stream. Besides StopAfterTurn and the progress accessors, it has three ways to put input into a run that is already going:

stream, ctrl := agents.Run(ctx, agent, "research this", opts)

ctrl.Steer("actually, focus on the pricing")   // change course NOW
ctrl.NextTurn("mention the source when you cite it")  // ride along with the next turn
ctrl.FollowUp("now summarize it for a customer")      // and then do this
  When it lands Extends a run that was finishing
Steer the next model call, whatever the run is doing yes
NextTurn the next turn boundary, if there is one no
FollowUp after the final output, in the same run yes

FollowUp continues the same run rather than starting a new one, so the trace, the usage total and the session stay one thing.

Injections reach the model in the order they were made, across all three methods, and delivery is transactional: input consumed by an attempt that then fails (a middleware retry, a failed resume) is returned to the queue and delivered by the next attempt — nothing lost, nothing doubled.

Injected input is recorded as the user’s, so a reopened session shows what was actually said rather than an answer to a question nobody asked. Whatever a run did not consume — a NextTurn that arrived as the run was ending — is reported by ctrl.Pending() instead of vanishing.

Input queued before a run pauses for approval rides along in RunState.PendingInput — across RunState serialization too — and is delivered on resume.

Turn hooks

A turn is resolved into a TurnSnapshot — agent, model, settings, instructions, prompt, tools, handoffs, output schema, input — before the model is called. Two ExecOptions hooks see it at the save point: the moment the turn’s message and every tool result are persisted, and the next model call has not happened.

opts.Exec.ShouldStopAfterTurn = func(ctx context.Context, tr *agents.TurnResult) (bool, error) {
	return slices.Contains(tr.ToolCallNames(), "save_report"), nil
}

opts.Exec.PrepareNextTurn = func(ctx context.Context, tr *agents.TurnResult) (*agents.TurnSnapshot, error) {
	next := *tr.Snapshot
	next.Tools = nil                    // withdraw the tools now that they have run
	next.Model = cheapModel             // finish on something cheaper
	return &next, nil
}

PrepareNextTurn applies to one turn; the turn after resolves afresh. It changes the run without mutating the Agent, which a concurrent run may be reading.

The *TurnResult a hook receives is a read-only view of the finished turn. Writing to its fields changes nothing — not the run’s final output, not what the other hook sees. To shape the next turn, return a TurnSnapshot.

The runner owns Snapshot.Input and replaces whatever a returned snapshot carries. A prepared snapshot is nearly always a copy of the previous turn’s, so honoring its input would replay that turn with the tool call and its output missing. To edit what a call sends, use ModelOptions.InputFilter, which runs per turn on the input the loop built.

Middleware

RunOptions.Middlewares wraps a run, outermost first. It is where optional policy lives — logging, retrying, recovering, rewriting input — so the loop does not grow a field and a branch for each one.

mw := agents.RunMiddlewareFunc(func(ctx context.Context, next agents.RunFunc, in agents.RunInput) agents.RunStream {
	in.Opts.Exec.MaxTurns = 5          // adjust the run
	return next(ctx, in)               // ...or do not call next at all
})

agents.RunSync(ctx, agent, "hi", agents.RunOptions{
	Middlewares: []agents.RunMiddleware{middleware.Retry{MaxAttempts: 2}, mw},
})

A middleware that only observes calls next and re-yields the stream unchanged; one that intervenes can inspect events, replace them, run the loop twice, or refuse before the model is ever called.

Built-in middleware

agents/middleware ships the run-level policies that come up most:

  What it does
middleware.Loop Re-runs the agent until an Evaluator accepts the answer, feeding each rejected attempt back with the reason
middleware.Approval Answers approval interruptions from a standing ApprovalPolicy and resumes, so the caller only sees the pauses the policy declined
middleware.Retry Re-runs a failed run
middleware.Plan Plan mode: read-only exploration, a plan submitted through submit_plan pauses for approval, and approval unlocks the toolset in the same run
middleware.Todo Has the agent keep a working todo list through todo_write; the host observes it via OnUpdate
import "github.com/zzir/agents-go/agents/middleware"

opts.Middlewares = []agents.RunMiddleware{
	middleware.Retry{MaxAttempts: 3},
	middleware.Approval{Policy: middleware.AllowTools("read_file", "list_files")},
	middleware.Loop{Evaluate: func(ctx context.Context, res *agents.RunResult) (middleware.Evaluation, error) {
		if looksRight(res.FinalOutputString()) {
			return middleware.Stop(), nil
		}
		return middleware.Continue("the answer must cite a source"), nil
	}},
}

Order is behavior, not style. Approval must sit inside Loop: outside it, the loop’s first attempt comes back paused with no answer, and the evaluator judges an empty string. The rule of thumb is that a middleware which resolves something about one attempt goes inside one that decides whether to make another attempt.

Loop is the shape middleware exists for: the run loop knows when a model has finished talking and nothing more, while “good enough” is the caller’s question — a critic agent, a schema check, a compiler.

Plan mode (middleware.Plan) splits a run into two phases with one pause between them. While planning, a tool that is not read-only stays in the toolset but REFUSES when called, answering with a refusal that names submit_plan — hiding it instead produced “tool not found”, which a model cannot tell from a tool the session never had, so it kept guessing. Handoffs are the exception and stay hidden (a target’s full toolset would be a side door out of plan mode, and the model has no priors about your handoff names). A DIRECT tool counts as read-only when it says so (Tool.ReadOnly, which sandbox.ReadFileTool/ListFilesTool set) or when ReadOnlyTools (DefaultReadOnlyTools when nil) names it. An MCP tool is admitted ONLY by name: its ReadOnly came from the server’s own readOnlyHint, an outside claim plan mode’s guarantee cannot rest on. A gated call also raises no approval while planning — not the tool’s own NeedsApproval and not the agent’s ApproveTools listing (which Apply translates into per-tool predicates so the phase can suppress it); pausing a human over a call the phase refuses anyway would waste the interruption. submit_plan is always approval-gated, and that pause IS the plan review: an interruption whose tool is middleware.PlanToolName carries the plan in its arguments; Approve unlocks the full toolset and the same run continues into execution, Reject’s message sends the model back to planning with the write tools still refusing.

Todo mode (middleware.Todo) adds a todo_write tool and a preamble telling the model to keep a working list. Every call replaces the whole list — the model always sends every item, which is simpler to prompt for and impossible to desynchronize. The host renders it from OnUpdate (or reads the calls off the stream); a malformed list is refused whole, so an observer never sees a half-applied update. Both middlewares rewrite the entry agent only; handoff targets keep their own toolset. See examples/planmode for both together.

middleware.Retry and agents.NewRetryModel are different and usually both right. The model decorator retries one call (a 429, a dropped connection) and the run never notices; the middleware retries the whole run, which is what a failure the loop could not absorb needs. A failed run is retried from the start, not resumed — resuming means guessing which side effects already happened, and the SDK cannot know.

What is deliberately not middleware: handoffs, guardrails, session persistence, tracing, and ExecOptions.ErrorHandlers. Those are not policy layered over the loop, they are the loop — a handoff changes which agent the state machine is in, guardrails race the model call and cancel it, persistence has a boundary only the loop knows, and an error handler 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. Expressing them as middleware would turn invariants into implicit protocols between wrappers.

For callbacks tied to a specific agent rather than the whole run, see Agent.OnStart / Agent.OnEnd.

Errors

All failures come back as Go errors. The SDK’s typed errors carry their data as plain fields and are matched with errors.As:

Error Meaning
*MaxTurnsError Turn budget exhausted
*ModelBehaviorError The model did something invalid (unknown tool, malformed structured output, truncated stream)
*ModelRefusalError The model refused to respond; carries the refusal text
*UserError You used the SDK incorrectly (e.g. no model provider, invalid output schema)
*ToolTimeoutError A tool exceeded its Tool.Timeout
*GuardrailTripwireError A guardrail tripped; Stage() says where

A run that fails after its loop started returns a *RunError wrapping the cause; its Result field is the partial progress (input, items generated so far, raw responses, last agent, usage) in the same *RunResult shape a finished run reports — see Results.

Error codes

For anything that has to travel — an HTTP response, a WebSocket frame, a log line — match on the code rather than the type. CodeOf unwraps %w chains, so it works on whatever Run returned regardless of how the run loop wrapped it:

switch agents.CodeOf(err) {
case agents.CodeMaxTurns:          // "max_turns_exceeded"
case agents.CodeGuardrailTripwire: // "guardrail_tripwire"
case agents.CodeToolTimeout:       // "tool_timeout"
case agents.CodeUnknown:           // not an SDK error, or unclassified
}
Code Produced by
max_turns_exceeded *MaxTurnsError
model_behavior *ModelBehaviorError
model_refusal *ModelRefusalError
user_error *UserError
tool_timeout *ToolTimeoutError
tool_panic A tool panic, whether it aborted the run or was recovered
tool_loop *ToolLoopError — every tool failed on N consecutive turns
guardrail_tripwire *GuardrailTripwireError
sandbox_exec A sandbox command that failed to run
mcp An MCP server connection or tool call
context_overflow Reported as a diagnostic when a run compacted and retried after the context did not fit
unknown Anything else, including a plain error from your own code

The set is open. Handle an unrecognized code generically — the SDK adds codes without a breaking change, and a consumer that treats an unknown code as impossible breaks on upgrade.

To contribute a code from your own tool, use Classify. It tags the error without hiding it, so errors.Is and errors.As still reach the original:

return nil, agents.Classify(agents.CodeSandboxExec, fmt.Errorf("build: %w", err))

An error that already carries a code is returned unchanged — the innermost classification wins, because it knows the most about the failure.

Error handlers

RunOptions.Exec.ErrorHandlers turns selected failures into a normal completion with a fallback final output instead of an error:

A handler receives the error and a RunErrorData snapshot (input, items so far, their input-item form, raw responses, last agent) and returns the fallback:

res, err := agents.RunSync(ctx, agent, "Analyze this long transcript", agents.RunOptions{
	Model: agents.ModelOptions{Provider: provider},
	Exec: agents.ExecOptions{
		MaxTurns: 3,
		ErrorHandlers: agents.RunErrorHandlers{
			MaxTurns: func(ctx context.Context, in agents.RunErrorHandlerInput) (*agents.RunErrorHandlerResult, error) {
				return &agents.RunErrorHandlerResult{
					FinalOutput:        "I couldn't finish within the turn limit. Please narrow the request.",
					ExcludeFromHistory: true,
				}, nil
			},
		},
	},
})

The run then completes normally: output guardrails and OnAgentEnd hooks run on the fallback, and res.FinalOutput carries it. Unless ExcludeFromHistory is set, an assistant message with the fallback is appended to res.NewItems and the session. For an agent with an output type, FinalOutput must marshal to JSON that validates against the output schema — anything else fails the run with a *UserError.

Return (nil, nil) to decline recovery and keep the original error. A declined (or missing) InvalidFinalOutput handler keeps the empty-output default: when the model returns no final text for a structured output type, the runner runs the model again rather than failing.

ErrorHandlers: agents.RunErrorHandlers{
	ModelRefusal: func(ctx context.Context, in agents.RunErrorHandlerInput) (*agents.RunErrorHandlerResult, error) {
		var refusal *agents.ModelRefusalError
		errors.As(in.Error, &refusal)
		return &agents.RunErrorHandlerResult{
			FinalOutput: Recipe{Ingredients: nil, RefusalReason: refusal.Refusal},
		}, nil
	},
},