Documentation
¶
Overview ¶
Package nacelle is the agent SDK for the Facile Suite: the model loop, the tool registry and the MCP wiring that every Facile agent needs and none of them should be re-writing.
An agent is a loop around a model with tools attached. That loop is about two hundred lines, and it gets rewritten in every project that needs one, slightly differently, with a slightly different bug in the tool-result handling. This is the single version.
Four properties are what make it embeddable, and none of them is negotiable. The loop returns events and never prints, so a backend streaming SSE, a terminal UI and a test are all consumers of one stream. Usage is reported on every turn, because comparing runs on cost is a reason this package exists. Backends declare what they support and an agent that asks for more is refused at construction rather than quietly running with less. And the core knows nothing about any product: no documents, no repositories, no citations. A consumer that needs its own vocabulary in here has found a bug in the abstraction, not a missing feature.
Index ¶
- Constants
- Variables
- func Attempt(err error) int
- func Retryable(err error) bool
- func RunTool(ctx context.Context, tool Tool, call Invocation, input json.RawMessage, ...) (string, error)
- func ToolsByName(tools []Tool) map[string]Tool
- func Transient(err error) error
- type Agent
- type Approve
- type Backend
- type Capabilities
- type Config
- type Effort
- type Event
- type Finish
- type Hook
- type HookEvent
- type HookPoint
- type HookResult
- type Invocation
- type Kind
- type Message
- type Part
- type Reasoning
- type Request
- type RetryOptions
- type Role
- type Stop
- type SubAgentOptions
- type Text
- type Thinking
- type Tool
- type ToolCall
- type ToolEvent
- type ToolResult
- type ToolSink
- type Unsupported
- type Usage
Constants ¶
const ( DefaultRetryAttempts = 3 DefaultRetryBase = 500 * time.Millisecond DefaultRetryMax = 8 * time.Second )
Retry defaults, applied to any RetryOptions field left at zero.
const DefaultMaxTokens = 32000
DefaultMaxTokens is the per-turn output ceiling.
Generous on purpose. Every request this package makes is streamed, so a large ceiling costs nothing in latency or timeouts, while a small one truncates an answer mid-sentence and buys a retry.
const MaxInject = 10000
MaxInject caps one hook's Inject, in bytes, before it reaches the model.
Injected text rides in the context window for the rest of the conversation, so an unbounded print is an unbounded bill. Claude Code caps additionalContext at the same size; the number has survived contact with real sessions.
const SubAgentToolName = "subagent"
SubAgentToolName is the name the sub-agent tool registers under, and the name stripped from the tools a nested run inherits. Stripping by this name is the recursion guard: a sub-agent cannot ask for another sub-agent, so delegation is exactly one level deep unless a caller builds that on purpose.
Variables ¶
var ( // ErrNoBackend is returned by New when Config.Backend is nil. ErrNoBackend = errors.New("nacelle: a backend is required") // ErrNoSystemPrompt is returned by New when Config.System is empty. // // It is an error rather than a default because an agent with no system // prompt is a general-purpose assistant wearing a product's name, and // that is never what the caller meant. ErrNoSystemPrompt = errors.New("nacelle: a system prompt is required") )
var ErrRetryBudget = errors.New("nacelle: the retry budget ran out")
ErrRetryBudget is what a run ends with when RetryOptions.Budget ran out.
It exists so the two ways a run can stop short stay tellable apart. Both arrive as a dead context and both read as context.DeadlineExceeded, but "the provider was down for longer than we were willing to wait" is this policy firing and "the caller stopped us" is theirs. A retry layer above this one, a message shown to a user and a metric all want to say something different about the two, and a bare deadline lets them say only one thing.
Functions ¶
func Attempt ¶
Attempt reports which attempt an error was recorded on, or zero for an error nothing retried.
A backend cannot fill this in, because it does not know how many times its stream has been started — only Retry does, and it stamps the number on the failure it finally surfaces. It is exposed so a consumer can say "gave up after three tries" instead of reporting a single anonymous failure, which is the difference between a run that limped and one that sailed through.
func Retryable ¶
Retryable reports whether err is worth starting a run again for.
It is true for anything marked by Transient, for any error implementing Retryable() bool, and for a truncated response — a stream that stops mid-body is the shape a dropped connection takes once the status line has already been read and the request counted as a success.
func RunTool ¶
func RunTool(ctx context.Context, tool Tool, call Invocation, input json.RawMessage, sink *ToolSink) (string, error)
RunTool executes a tool and reports the outcome to sink.
Backends call this instead of calling Run directly, so that a tool result reaches the event stream the same way whichever backend executed it, and so that timing is measured in one place. It is also the one place that checks sink.Approve, so a refusal looks the same — same event shape, same error returned to the caller — regardless of which backend asked.
A refusal is reported as a failed call, not skipped in silence: the pairing contract (a call started must be closed) is the same one Discarded exists for, and the model is better placed than this package to decide whether the task can still be finished without it. Refused is what tells a consumer this was a policy decision, not the tool breaking.
func ToolsByName ¶
ToolsByName indexes tools for a backend dispatching a call by name.
Types ¶
type Agent ¶
type Agent struct {
// contains filtered or unexported fields
}
Agent runs a conversation to completion, streaming what happens.
It is safe to reuse across conversations and safe to share between goroutines: it holds configuration, not state. A single run is not — a sequence returned by Stream must be ranged from one goroutine.
func New ¶
New builds an agent. It fails rather than degrading: a half-configured agent that answers plausibly is worse than one that refuses to start.
func (*Agent) Backend ¶
Backend returns the backend this agent runs on, so a caller can report which model answered without having kept the value it passed in.
func (*Agent) CountTokens ¶
CountTokens reports how many tokens this conversation would use if sent as the next turn, without sending it.
It counts the same request Stream would: the system prompt, the tools, the MCP servers, and the conversation together — not the bare messages alone. All of those are billed, and a count of the messages only would be an answer to a narrower question than the one a caller asking "will this fit" actually has.
func (*Agent) Stream ¶
Stream runs the conversation and yields what happens as it happens.
The sequence ends after a KindDone event, or early with a non-nil error. A consumer that stops ranging cancels the run: the underlying request is torn down with the context, so abandoning the loop is a supported way to stop an agent rather than a leak.
Tool failures are not stream errors. A tool that returns an error is reported as a KindToolResult carrying it and handed back to the model, which is better placed than the caller to decide whether the task can still be finished. An error out of this sequence means the run itself failed.
type Approve ¶
Approve decides whether a tool call may run, asked once per call before RunTool ever calls Run.
Nil is the default and means every call runs unasked — the same behaviour this package has always had. Most consumers (a server, a CI job, an unattended run) have nobody to ask, and a package that refused by default would make every one of them write a rubber-stamp callback just to get back to how every tool already worked. A consumer that wants a human in the loop sets this; nothing else about Tool or RunTool changes for one that does not.
It is asked with the same context RunTool receives, so cancelling a run (a caller abandoning the stream) unblocks anyone waiting on an answer that is never coming, the same way it already unblocks a tool mid-Run.
It may be asked from several goroutines at once, for the reason Tool.Run documents, and a callback that puts a question to a person has to do something about that rather than assume it. tui/ answers it by serialising the prompts: two questions racing for one terminal is one question nobody can read, and neither answer belongs to the call it lands on.
type Backend ¶
type Backend interface {
// Name identifies the backend in errors and logs.
Name() string
// Capabilities reports what this backend can actually do, so an agent
// asking for something it lacks fails at construction rather than
// quietly running with less.
Capabilities() Capabilities
// Stream runs the conversation to completion, yielding events.
//
// Implementations must end with a KindDone carrying the run's total
// usage, or with an error. They must report tool results through
// RunTool and a ToolSink so that every backend's stream looks the same
// to a consumer.
Stream(ctx context.Context, request Request) iter.Seq2[Event, error]
// CountTokens reports how many tokens this request would use if sent as
// it is, without sending it. A backend that cannot support it — see
// Capabilities.TokenCounting — returns an *Unsupported error rather than
// a guess: an estimate from a tokenizer this package does not own is not
// a number anyone should budget against.
CountTokens(ctx context.Context, request Request) (int64, error)
}
Backend is a model this package can run an agent on.
The seam is at the whole loop rather than at a single request, because the loop is exactly what differs. Anthropic ships one in its SDK and executes remote MCP servers on its own side of the request; an OpenAI-schema backend has neither and must drive the conversation itself. An interface at the request level would have forced the Anthropic path to give up the tested loop it gets for free, to look symmetrical with one that cannot have it.
func Retry ¶
func Retry(backend Backend, options RetryOptions) Backend
Retry wraps a backend so a run that fails before producing anything is started again.
This is deliberately not a backoff engine, because the backends already sit on one. Both SDKs retry at the HTTP level — connection failures, 408, 409, 429 and 5xx — honouring Retry-After-Ms and Retry-After, and that already covers establishing a stream. Re-implementing it here would be a second, worse copy.
What no HTTP-level retry can see is a provider that answers 200 and puts the failure in the body. OpenRouter reports a rate limit as an error object inside the SSE, and an Anthropic overloaded_error can arrive mid-stream on a response whose status was committed long before. Both reach a caller as a dead stream carrying a transient failure, and retrying those is what this adds.
It retries only while nothing has been yielded. Once a consumer has seen a text delta it has already printed it, and no wrapper can un-print it, so a failure after the first event ends the run and is reported as it is.
type Capabilities ¶
type Capabilities struct {
// MCP reports whether the backend can reach remote MCP servers.
//
// On the Anthropic API this is a request parameter and the servers are
// called from Anthropic's side. A backend without it would need a full
// MCP client, which is a different piece of software.
MCP bool
// Thinking reports whether the backend can stream the model's
// reasoning as KindThinking events.
Thinking bool
// Effort reports whether the backend accepts a reasoning depth, which
// covers both spellings of it: an effort level and a token budget. No
// backend here takes one without the other, so splitting this in two
// would add a field that can only ever agree with its neighbour.
Effort bool
// MinBudget is the smallest Thinking.Budget this backend's API will
// take, or zero when it has no floor to report.
//
// A number rather than a bool because the refusal is only useful if it
// says what to change to. Anthropic documents 1024 and rejects less;
// the OpenRouter backend leaves this at zero and means it, because it
// fronts hundreds of models whose floors are their own and a figure
// invented here would refuse requests the gateway would have accepted.
MinBudget int64
// Cost reports whether Usage carries money rather than only tokens.
// A backend that prices requests itself can fill it; one that does not
// leaves Usage.Cost at zero and the caller prices the tokens.
Cost bool
// TokenCounting reports whether CountTokens is real rather than an
// unconditional refusal. It takes a real request to a provider to know
// exactly how many tokens a tokenizer nobody outside that provider owns
// will produce, so a backend without an endpoint for it has nothing
// honest to estimate with.
TokenCounting bool
}
Capabilities is what a backend supports.
Every field is a feature a caller can ask for and be refused. The list is deliberately not a set of vague tiers: a consumer that needs MCP needs to know that specific thing is missing, not that the backend is "limited".
type Config ¶
type Config struct {
// Backend is the model this agent runs on. There is no default: a
// package that picks one for you is a package that hides the most
// consequential decision in the configuration.
Backend Backend
// System is the system prompt.
System string
// Thinking is how hard the model thinks and whether the reasoning
// reaches the consumer. The zero value asks for the backend's own
// depth, shown to nobody.
Thinking Thinking
// MaxTokens defaults to DefaultMaxTokens.
MaxTokens int64
// MaxIterations caps how many times the model is asked, so a value of
// N permits N requests and the tool rounds between them. Zero means no
// cap, which is only safe when every tool is read-only and cheap.
//
// Reaching it is unfinished work rather than a failure: the run ends
// with a KindDone carrying everything it cost and a Stop of
// StopIterations. The last turn asked for tools that were never run, so
// there is no answer built on them — check Stop before presenting one.
MaxIterations int
// Tools the model may call in this process.
Tools []Tool
// MCP servers the model may call tools on. These run on the backend's
// side of the request, not ours, and only some backends can reach them.
MCP []mcp.Server
// Approve, if set, is asked before every local tool call runs. See
// Approve's own doc comment for why nil — every call runs unasked — is
// the default rather than the safe-looking choice.
Approve Approve
// Hooks run at fixed points of every local tool call. See HookPoint
// for what exists and what a hook may decide. Nil means none.
Hooks map[HookPoint][]Hook
// Logger receives the few things worth recording that are not events.
// Defaults to slog.Default().
Logger *slog.Logger
}
Config describes an agent. Backend and System are required; everything else has a working default.
type Effort ¶
type Effort string
Effort tunes how hard the model works, trading cost against quality.
It replaces the fixed thinking budget older models took: a token budget is rejected outright by current Anthropic models, and this is what took its place. A backend that does not support it at all says so in its Capabilities.
Nothing here checks a level against the model that will receive it, and that is deliberate. Measured against OpenRouter on 2026-08-23: a level a model does not advertise is clamped to one it does rather than refused, so a table of which model takes which would be a maintenance cost carrying a wrong answer from the week a provider adds a level. The refusal worth making is the one Capabilities already makes.
const ( // EffortNone asks for no reasoning at all, and a model that cannot // oblige refuses the run rather than quietly ignoring it. Measured // against stealth/ox-alpha on 2026-08-23: OpenRouter answers a request // carrying it with 400, "Reasoning is mandatory for this endpoint and // cannot be disabled". That is the right outcome and it is why this is // its own level rather than a synonym for the cheapest one: a caller // who needs a model not to think has been told plainly that this model // always will, instead of being billed for reasoning they asked to // skip. The error is not marked retryable, so it fails once. EffortNone Effort = "none" EffortMinimal Effort = "minimal" EffortLow Effort = "low" EffortMedium Effort = "medium" EffortHigh Effort = "high" EffortXHigh Effort = "xhigh" EffortMax Effort = "max" )
type Event ¶
type Event struct {
Kind Kind
// Text is the delta for KindText and KindThinking.
Text string
// Tool describes the call for KindToolCall and KindToolResult.
Tool *ToolEvent
// Usage is the turn's cost for KindTurn, and the run's total for
// KindDone. It is zero on every other kind.
Usage Usage
// Stop is why a turn or a run ended, on KindTurn and KindDone. It is
// empty on every other kind.
Stop Stop
}
Event is one thing that happened during a run.
The stream is the only output of an agent: SSE, a terminal, a log and a test are all consumers of this type, which is what keeps the loop free of any opinion about where its output goes.
type Finish ¶
type Finish struct{ Stop Stop }
Finish is why a turn ended, recorded where it ended.
It is the Event's Stop, kept so a conversation read back later can still tell an answer that was finished from one the token ceiling cut off. Neither wire format has a field for it, so no backend sends it.
type Hook ¶ added in v0.3.0
type Hook func(ctx context.Context, ev HookEvent) HookResult
Hook is one consumer decision at one point of the run. It holds its own state by closing over it: a hook that allows a thing once is a closure over a bool, not an object registered with this package.
A hook runs in the tool's hot path — between the model asking and the tool running — so slow work belongs behind WithTimeout or Async. A panic out of a hook is recovered and, on BeforeToolCall, denies the call: a guard that crashed must not wave the request through.
func Async ¶ added in v0.3.0
Async wraps a hook so it runs detached from the run: the stream does not wait for it, and its Deny and Inject are dropped, because by the time an asynchronous answer arrives there is no result left to amend. It exists for audit and metrics, the hooks whose output nobody reads mid-run.
func WithTimeout ¶ added in v0.3.0
WithTimeout wraps a hook so it cannot hang the run, and cancels the context it handed out when it does: a wrapper that only returns while the work keeps going is not a timeout but an orphaned goroutine per call — for the execHook case, an orphaned process per call.
A hook that exceeds d is treated as having denied a BeforeToolCall — fail closed, since the only hooks worth timing out are guards — and as having said nothing otherwise.
type HookEvent ¶ added in v0.3.0
type HookEvent struct {
// Point is which moment fired. A hook registered at one point can be
// handed to another by mistake; reading this first is cheaper than
// reasoning about a Result that will never arrive.
Point HookPoint
// Tool is the name of the tool about to run, or just finished.
Tool string
// Input is the raw JSON the model sent, on both points.
Input string
// Result is what the tool returned, on AfterToolCall only.
Result string
// Err is non-nil when the tool failed, on AfterToolCall only. The run
// continues either way; a failed tool is reported to the model.
Err error
// Retry is true when this tool name was already denied by a hook
// earlier in this run.
Retry bool
}
HookEvent is what a hook is told about the moment it fired.
Input is raw JSON exactly as the model produced it, decoded by nobody here for the same reason ToolEvent.Input is: the core does not know any tool's schema. Retry reports that an earlier hook already denied this same tool name during this run, so a hook enforcing a policy can stand down rather than deny-loop a model that keeps retrying.
type HookPoint ¶ added in v0.3.0
type HookPoint string
HookPoint names one moment in a run where hooks fire. The set is closed: two points cover the uses that must always happen — gating a tool before it runs, reacting after — and every further point is an API promise held forever, so none is added until a consumer needs it.
const ( // BeforeToolCall fires before a local tool runs. A hook that denies // stops the call: the tool never executes and the model reads the deny // reason as the refusal. Deny is final — it holds regardless of any // interactive approval the caller configured, which is what makes a // hook a guarantee rather than a suggestion. BeforeToolCall HookPoint = "before_tool_call" // AfterToolCall fires after a local tool finished, successfully or // not. A hook here cannot undo the call; what it returns as Inject is // appended to the result the model reads. AfterToolCall HookPoint = "after_tool_call" )
type HookResult ¶ added in v0.3.0
type HookResult struct {
// Deny, when non-empty, blocks a BeforeToolCall. The string is the
// reason the model reads in place of a tool result. On AfterToolCall
// it is too late to block anything and a Deny is ignored.
Deny string
// Inject is text appended to what the model sees. On BeforeToolCall
// there is no result yet to append to, so Inject there is ignored;
// injection belongs on AfterToolCall.
//
// Truncated to MaxInject bytes. The cut is silent because the
// alternative — refusing the whole injection over a long tail —
// punishes the useful first 9,999 characters for the last one.
Inject string
}
HookResult is what a hook decides. Both fields zero means allow, say nothing — the common case, and the reason the struct returns rather than the hook returning two values: a future decision kind should not move every hook's signature.
type Invocation ¶
type Invocation struct {
// ID is the provider's identifier for the call.
ID string
// Index is the call's position in the turn, from zero.
Index int
}
Invocation identifies one tool call within the turn that asked for it.
It travels as a struct rather than as two more parameters because the two fields answer different questions and are wrong to mix up: ID is what pairs a result to its call across the stream, Index is where the model put it.
type Kind ¶
type Kind string
Kind identifies what an Event carries. Switch on it before reading any other field: every field but Kind is meaningful for some kinds and zero for the rest.
const ( // KindText is a fragment of the answer. Text holds the delta, not the // whole answer so far — a consumer that wants the total accumulates. KindText Kind = "text" // KindThinking is a fragment of Claude's reasoning, and arrives only // when the request asked for a visible summary. The raw chain of // thought is never returned by the API under any setting. KindThinking Kind = "thinking" // KindToolCall is the model deciding to use a tool. It is emitted // before the tool runs, so a consumer can show the intent while the // work happens. KindToolCall Kind = "tool_call" // KindToolResult is that tool having finished, successfully or not. KindToolResult Kind = "tool_result" // KindTurn ends one assistant turn and carries what that turn cost. A // turn that used tools is followed by more turns; the last one is // followed by KindDone. KindTurn Kind = "turn" // KindDone ends the run and carries the total cost of every turn in it. KindDone Kind = "done" )
type Message ¶
Message is one turn of the conversation so far.
Its content is a list of parts rather than a string, because a turn is often not prose. An assistant turn that used tools is text and tool calls together, and the turn answering it is tool results; a message that could hold only a string dropped every one of them. What that cost was not cosmetic. A resumed conversation asked the model to carry on from a transcript it had not produced, and cross-call prompt caching could never hit at all, because a replayed prefix cannot byte-match a request whose tool blocks were thrown away on the way in.
func AssistantText ¶
AssistantText is a model turn that was prose and nothing else.
func Trim ¶
Trim drops the oldest messages from a conversation, keeping at most keep of the most recent ones, and reports how many were dropped.
It never returns a slice whose first message carries a ToolResult part. Cutting there would keep an answer with no question: the ToolCall it answers lives in the message before it, which the cut just dropped, and a tool_result naming a call nothing sent is a request every provider this package talks to rejects. When the requested boundary lands inside a call/result pair, the cut advances past the whole pair rather than retreating to keep it: kept never exceeds keep, which is the one promise worth keeping for a caller trimming to fit a budget — dropping a little more than asked is a smaller surprise than trimming to N and getting more than N back.
This is truncation, not summarization. What survives is dropped whole, not compressed — deciding what to preserve and how is a product opinion, and this package does not have one; see nacelle.go's own doc comment on why. A caller wanting a summary in place of what was dropped builds it from the dropped count and its own model call, using this as the mechanical half.
type Part ¶
type Part interface {
// contains filtered or unexported methods
}
Part is one piece of a message's content.
The set is closed: part is unexported, so no type outside this package can join it, and a type switch over the five below is exhaustive today and stays exhaustive. That is why this is an interface and not a struct with a kind and eleven optional fields — which is the shape Event uses, and the shape that would let a backend read a tool call's arguments off a piece of prose.
Every part is implemented on a value receiver, so a literal is a Part and nothing has to be addressable to go into a conversation.
type Reasoning ¶
type Reasoning struct{ Text string }
Reasoning is the model thinking out loud: shown, recorded, and never sent back.
It is representable because the stream emits it, and a conversation that cannot hold what a consumer displayed is the same gap this type exists to close, one level down. Both backends drop it when they build a request, and that is not an oversight. Anthropic accepts a thinking block only with the signature it was issued with, which the stream does not carry, and OpenRouter is asked to exclude reasoning unless the caller opted in. Replaying it would mean paying again for a chain of thought, in a field the providers do not want it replayed in.
type Request ¶
type Request struct {
System string
Messages []Message
Tools []Tool
MCP []mcp.Server
Thinking Thinking
MaxTokens int64
MaxIterations int
// Approve, if set, is asked before every local tool call runs. Nil
// means every call runs unasked — see Approve's own doc comment.
Approve Approve
// Hooks run at fixed points around each local tool call. Nil means
// none; see HookPoint.
Hooks map[HookPoint][]Hook
}
Request is one run, fully described. A backend receives it already validated: the agent has checked it against Capabilities and filled every default, so a backend never has to guess what an empty field meant.
type RetryOptions ¶
type RetryOptions struct {
// Attempts is how many times a run may be started, the first one
// included. One disables retrying without removing the wrapper.
Attempts int
// Base is the delay before the second attempt. It doubles from there.
Base time.Duration
// Max caps the delay however many attempts have failed.
//
// It caps this wrapper's own delay and nothing else, so it is not a
// bound on how long a run can take. The SDKs sleep on Retry-After
// before a failure is ever handed up as transient, and those sleeps
// happen inside each attempt this one then repeats: three attempts over
// three HTTP retries is nine requests and six sleeps the wrapper never
// sees. Under Retry-After: 60 that is roughly six minutes, whatever
// this field says. Budget is the field that bounds it.
Max time.Duration
// Budget is the wall clock a whole run may spend under this wrapper,
// every attempt and every backoff included. Zero leaves it unbounded,
// which is what this wrapper has always done.
//
// It is a context deadline derived once and passed down, and that is
// the point rather than an implementation detail: the sleeps Max cannot
// see are a select on ctx.Done() inside the SDKs, so a deadline
// interrupts one that has already started. Nothing else reaches them.
//
// Being a deadline, it bounds the whole run and not just the retrying:
// a legitimate twenty-minute answer under Budget: 5 * time.Minute is
// cut off at five, on the first attempt, having failed at nothing.
// Envoy's rq-timeout and the AWS SDK's apiCallTimeout mean the same
// thing by the same name, so a caller sizing this one should size it
// against their slowest good run rather than their retry tolerance.
Budget time.Duration
// Logger records the attempts nobody else can see. Defaults to
// slog.Default(), matching Config.Logger, because a retry that says
// nothing makes a run that limped through three attempts look exactly
// like one that sailed through on the first.
Logger *slog.Logger
}
RetryOptions tunes Retry. Every zero field but Budget takes its Default counterpart, so the zero value is the recommended policy rather than no policy. Budget is the exception on purpose: there is no number of seconds that is right for every run, and a default one would quietly start killing the long streaming answers this package exists to carry.
type Role ¶
type Role string
Role is whose turn a Message is.
There are two, because two is what the model APIs agree on. A tool's answer is not a third voice: Anthropic carries it as a block inside the user turn and the OpenAI schema as a message of its own, and reconciling that is a backend's job at its own edge rather than a split this package repeats for both of them.
type Stop ¶
type Stop string
Stop is why a turn or a run ended.
It exists because the alternative is silence: a run truncated by the output ceiling, one that outgrew the context window, and one the model refused all arrive as a well-formed response with a normal ending, and a consumer that does not read this cannot tell any of them from a finished answer.
const ( // StopEnd is the model having finished what it was asked. StopEnd Stop = "end" // StopTools ends a turn the model wants tools run for. More turns // follow, so it is never the reason a run ended. StopTools Stop = "tools" // StopMaxTokens is the output ceiling cutting the answer off. StopMaxTokens Stop = "max_tokens" // StopContext is the conversation having outgrown the context window. StopContext Stop = "context" // StopRefusal is the model declining, which arrives as a successful // response carrying no answer. StopRefusal Stop = "refusal" // StopIterations is MaxIterations reached with the model still asking // for tools. The work is unfinished and nothing went wrong. StopIterations Stop = "iterations" // StopOther is a reason this package does not have a name for. It is // not an error, and a consumer should treat it as unfinished. StopOther Stop = "other" )
type SubAgentOptions ¶ added in v0.4.1
type SubAgentOptions struct {
// Name is the tool name the model calls, defaulting to SubAgentToolName.
// Renaming it renames what the recursion guard strips too.
Name string
// Description is what the model reads when choosing the tool. Empty
// keeps the default, which describes the delegation shape rather than
// any particular task.
Description string
// System replaces the parent's system prompt for the nested run. Empty
// means the parent's.
System string
// MaxIterations caps the nested run, overriding the parent's ceiling
// when positive. Zero inherits; the parent's zero means no cap, which
// is the parent's own decision to make twice if it wants to.
MaxIterations int
// Approve governs tool calls inside the nested run. Nil — the default —
// denies every call: a nested context has nobody to ask, and an approval
// prompt surfacing from inside a tool result would be a question nobody
// can answer honestly. A caller that wants the sub-agent to work hands
// it a policy that decides without asking.
Approve Approve
// Usage receives what every nested turn costs, as it is spent. Nil —
// the default — drops the delegate's spend on the floor, which makes
// the session's own accounting quietly wrong the moment somebody
// delegates: the work happened, the bill arrives, the counters never
// moved. A caller that shows totals anywhere wires this into them.
// It runs on the stream's goroutine; keep it cheap and non-blocking.
Usage func(Usage)
}
SubAgentOptions overrides what the nested agent inherits from its parent's Config. The zero value is a working sub-agent: it runs on the parent's backend and system prompt, under the parent's iteration ceiling, with the parent's tools minus the sub-agent itself.
type Thinking ¶ added in v0.2.0
type Thinking struct {
// Effort defaults to the backend's own default when empty.
Effort Effort
// Budget caps the tokens one turn may spend on reasoning. Zero means
// no ceiling from here, which is not the same as EffortNone: the
// backend still applies whatever it defaults to.
//
// Effort and Budget are two spellings of one idea, and the providers
// disagree about how to take them. Anthropic takes both at once, on
// separate fields. OpenRouter refuses the pair with a 400 and each
// backend therefore resolves it its own way, the OpenRouter one by
// letting a budget win: the levels are documented there as percentages
// of the budget, so the precise number is the coarse one said properly.
// Set one or the other unless a backend swap is the point.
Budget int64
// Show streams the model's reasoning as KindThinking events.
//
// Off by default, which matches the APIs: the raw chain of thought is
// never returned and a readable summary is opt-in. Turning it on
// changes what is displayed, never what is billed. The model thinks
// either way and the tokens are on the invoice either way.
Show bool
}
Thinking is how hard the model thinks, and who gets to see it.
Those are two questions, which is why this is a struct and not the bool it replaced. There used to be a third, and removing it is the point of this type: whether the reasoning travels back over the wire was wired to whether a human wanted to watch it, so the default configuration asked every provider to throw the reasoning away, and every tool call after the first handed the model a blank where its own last thought should have been.
It always travels now. Nothing here asks a provider to withhold it, because the reasoning tokens are billed whether or not they come back and a loop that drops them is the one case where the saving is real and the cost is correctness. Show decides what a consumer is shown, and nothing else.
type Tool ¶
type Tool interface {
// Name is what the model calls. It must be unique within one agent.
Name() string
// Description is prompt engineering, not documentation. Write it for a
// model that has never seen the codebase, and say what the tool is for
// rather than what it returns.
Description() string
// Schema is the JSON Schema of the tool's input, as a decoded object.
Schema() map[string]any
// Run executes the tool. The string it returns is what the model reads,
// so it should be text a reader could follow, not a debug dump.
//
// An error is not fatal: it is reported to the caller and handed back to
// the model, which is usually better placed to decide whether the task
// can still be finished.
//
// Run may be called from several goroutines at once, and an
// implementation has to be ready for it. A model can ask for two tools
// in one turn and a backend runs those together, so this happens on a
// single conversation before anything shares an Agent between
// requests. A tool that keeps a field between calls needs its own
// lock; a tool that only reads what it was built with needs nothing,
// which is why every tool in tools/ and mcp/client is the second kind.
Run(ctx context.Context, input json.RawMessage) (string, error)
}
Tool is something the model can call.
The interface is this package's own rather than any SDK's, because a tool has to be callable by every backend. An Anthropic-shaped tool type in the core would mean the OpenRouter backend converting from a vocabulary that has nothing to do with it.
func NewSubAgentTool ¶ added in v0.4.1
func NewSubAgentTool(cfg Config, opts SubAgentOptions) (Tool, error)
NewSubAgentTool builds a `task`-style delegation tool: a fresh Agent run, on the same backend as cfg but with its own message list and its own context, that works a task to completion and returns only its final answer.
The parent's event stream sees one tool call and one tool result — whatever RunTool already reports — and nothing else. Text, thinking and usage from the nested run are consumed here: a transcript showing two agents talking over each other is a transcript nobody can read.
Everything the nested run does is bounded: its tools are cfg.Tools with the sub-agent removed, its iterations come from opts or cfg.MaxIterations, and its approvals come from opts.Approve, defaulting to deny-all. The tool is built eagerly, so a backend that cannot honour the inherited config fails here rather than the first time the model delegates.
func NewTool ¶
func NewTool[In any](name, description string, run func(ctx context.Context, in In) (string, error)) (Tool, error)
NewTool builds a tool from a Go function.
The schema is generated from In's `json` and `jsonschema` struct tags, so a field is described where it is declared rather than in a JSON literal that drifts from it:
type searchInput struct {
Query string `json:"query" jsonschema:"required,description=What to look for"`
}
In must be a struct. A model calls a tool by naming arguments, and a bare string or slice has no names to give.
type ToolCall ¶
type ToolCall struct {
// ID is the model's identifier for this call, and the one the ToolResult
// answering it carries.
ID string
// Name is the tool that was asked for.
Name string
// Input is the raw JSON the model wrote. It is not decoded here, because
// the core knows no tool's schema, and it is kept byte for byte because
// re-encoding it is what stops a replayed prefix matching the request
// that was cached.
Input json.RawMessage
// Finished reports whether the arguments are whole.
//
// They arrive as JSON fragments, so a run abandoned mid-call leaves a
// call whose Input is a truncated object. Recording it is how a
// transcript stays honest about what happened; the false is how a
// backend knows not to send it, because half an argument list is a
// rejected request rather than a partial one.
Finished bool
}
ToolCall is the model asking for a tool to be run.
type ToolEvent ¶
type ToolEvent struct {
// ID is the model's identifier for this call. A KindToolResult carries
// the same ID as the KindToolCall it answers, which is what lets a
// consumer pair them without tracking order.
ID string
// Index is the call's position in the turn that asked for it.
//
// Tools run concurrently, so results are emitted in the order they
// finish rather than the order they were asked for — which is real
// information, and holding it back until the slowest one lands would
// buy determinism with a UI that stops moving. Index is how a consumer
// that wants the model's order gets it without paying for that.
Index int
// Name is the tool's name. For a tool reached over MCP it is the name
// the server exposes.
Name string
// Input is the raw JSON the model produced. It is not decoded here
// because the core does not know any tool's schema.
Input string
// Result is what the tool returned, on KindToolResult only.
Result string
// Err is non-nil when the tool failed. The run continues: a failed tool
// is reported back to the model, which is usually better placed than
// the caller to decide whether the task can still be finished.
Err error
// Duration is how long the tool took, on KindToolResult only.
Duration time.Duration
// Discarded reports that this call never ran: an attempt that produced
// it was superseded before it could be executed. It arrives as a
// KindToolResult only because the consumer's pairing contract still
// applies — a call started must be closed — not because there is
// anything here worth believing. A consumer replaying its conversation
// should drop a discarded call and its close entirely, the same way the
// backend that discarded it never replays it either.
Discarded bool
// Refused reports that this call never ran because Config.Approve
// declined it, not because the tool itself failed. Err still carries
// what the model is told either way — this is only the distinction a
// consumer wants to render differently: a policy decision, not a bug.
Refused bool
}
ToolEvent is a tool call, before or after it ran.
type ToolResult ¶
type ToolResult struct {
// ID is the ToolCall this answers.
ID string
// Name is the tool that ran, repeated so a result reads on its own.
Name string
// Result is what the model is told: the tool's own text, or what went
// wrong.
Result string
// Failed reports that the tool errored.
//
// A bool rather than an error, because a conversation is a value that is
// stored, compared and replayed and an error is none of those things: it
// does not survive a round trip through JSON, and two conversations
// carrying the same failure would not compare equal. What the model needs
// is in Result either way.
Failed bool
}
ToolResult is what a tool returned, and the other half of the pairing.
type ToolSink ¶
type ToolSink struct {
Approve Approve
Hooks map[HookPoint][]Hook
// contains filtered or unexported fields
}
ToolSink collects tool results for a backend to report.
It is exported for backend implementors and is not otherwise interesting. Backends need it because tool handlers may run concurrently while an event sequence is pulled from a single goroutine, so results are parked here and released between events rather than written from whichever goroutine produced them.
Approve lives here rather than as its own parameter on RunTool: every caller already constructs and threads a ToolSink through to the same place, so a second piece of per-run policy travelling the same path is one field, not a second argument every call site has to carry.
func (*ToolSink) Drain ¶
Drain returns everything reported since the last call, in the order the model asked for it.
Sorting is per batch, not across the whole run, and that is the honest limit: results are released as they land, so two tools that finish either side of a stream event arrive in separate batches and keep their completion order. Holding every result until the slowest tool in the turn returned would make the whole stream deterministic and would also stop the UI moving while the work happens, which is most of what a consumer wants the stream for. ToolEvent.Index is the way out for anyone who needs the model's order regardless of when things finished.
func (*ToolSink) Report ¶
Report records that a tool finished.
It takes whatever a backend hands it, an event carrying no ToolEvent included. Refusing one here would be the tidier trust boundary, but there is nowhere to put the refusal: the signature returns nothing, the caller is usually a tool goroutine with no consumer to hand an error to, and dropping the event silently loses a result the model is still waiting on. Drain is written to cope with it instead, which keeps the bad event visible.
type Unsupported ¶
Unsupported reports that the backend cannot do something the config asked for. It is returned by New rather than swallowed, which is the whole point of Capabilities: losing MCP tools silently looks like a model that will not use them, and that is a bad afternoon.
func (*Unsupported) Error ¶
func (e *Unsupported) Error() string
type Usage ¶
type Usage struct {
InputTokens int64
OutputTokens int64
CacheReadTokens int64
CacheCreationTokens int64
// Cost is what the run was charged, in US dollars.
//
// Only backends whose Capabilities report Cost fill it; the rest leave
// it zero and the caller prices the tokens itself. It is here rather
// than left to every consumer because a gateway that already knows the
// number is more trustworthy than a price table copied into an app and
// then not updated.
//
// It is a float64, which means adding two costs does not always give
// the decimal you expect — 0.0001 plus 0.0002 is 0.00030000000000000003.
// That is deliberate: the wire format is a JSON number, the error is
// around one part in 1e16, and the question this field answers is which
// of two runs cost more. Do not use it as a ledger; compare with a
// tolerance, and bill from the provider's own records.
Cost float64
}
Usage is what a turn or a run cost.
It is reported on every turn rather than only at the end because comparing runs on cost is one of the reasons this package exists, and a total that has to be reconstructed afterwards is a total nobody trusts.
func (Usage) CacheHitRate ¶
CacheHitRate is the share of input this run read from cache rather than paid full price for, from 0 to 1.
It excludes CacheCreationTokens from the denominator on purpose: a write is not a hit, and counting it as attempted-but-missed would understate the rate on a run's first turn, when every prefix is written and none can have been read yet. Zero on a run with no cacheable input at all — that is not a miss, there was nothing to hit.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package anthropic runs a nacelle agent on the Anthropic API.
|
Package anthropic runs a nacelle agent on the Anthropic API. |
|
Package mcp describes the MCP servers an agent may reach.
|
Package mcp describes the MCP servers an agent may reach. |
|
client
Package client runs MCP servers as subprocesses and hands their tools back as ordinary nacelle.Tool values.
|
Package client runs MCP servers as subprocesses and hands their tools back as ordinary nacelle.Tool values. |
|
Package openrouter runs a nacelle agent on OpenRouter.
|
Package openrouter runs a nacelle agent on OpenRouter. |
|
Package tools is the local tool set: reading, writing, editing, searching and running commands inside one directory.
|
Package tools is the local tool set: reading, writing, editing, searching and running commands inside one directory. |