nacelle

package module
v0.4.2 Latest Latest
Warning

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

Go to latest
Published: Aug 24, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

README

nacelle

The Go agent SDK for the Facile Studio 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. nacelle is the single version.

Status: it has its first consumer. Both backends, tools/, retry, prompt caching and per-turn usage are implemented and tested, and nacelle-tui runs on them. sandbox/ is not written.

Who it is for

Three consumers, which is the reason this is a library and not a package inside one app:

Consumer Shape What it needs
A headless Go service embedded in an API core loop, MCP tools, streaming that maps onto SSE, transcripts
Atelier agents in isolated container boxes, best-of-N swappable models, per-run cost and token accounting, a sandbox it can spin and reap
nacelle-tui terminal coding agent local read/write/edit/bash tools, a TUI, config and profiles

They want the same core and three different skins. The design brief follows from that: a backend must be able to import the core without pulling in a terminal UI or a container runtime.

Layout

The tronc shape — flat packages at the root, one module, heavy dependencies pushed into separate submodules so they land only on the apps that want them.

nacelle/                 core: Agent, Backend seam, events, tools, usage   [built]
  anthropic/             backend: SDK tool runner + server-side MCP        [built]
  openrouter/            backend: hand-rolled loop, 400+ models, real cost [built]
  mcp/                   MCP server connections and credentials            [built]
  tools/                 local tool set: read, write, edit, find, search, run [built]
  sandbox/    (submodule) container and microVM execution for Atelier

A backend importing nacelle and nacelle/mcp gets the loop and nothing else — no Bubble Tea, no provider SDK wire types. The terminal client lives in its own repository, nacelle-tui, for the same reason tronc/migrate and tronc/testdb are separate modules: an import should not cost you a dependency you will never call. sandbox/ stays a submodule here until Atelier needs it.

Documentation

Doc What's in it
Architecture The Backend seam, the event stream, the Message union, retry, caching
Configuration Every Config field the library reads; client settings live in nacelle-tui
Development Local setup, the quality gate, CI, versioning
API Every exported symbol, package by package

Release history: CHANGELOG.md.

Core shape

agent, err := nacelle.New(nacelle.Config{
    Backend: anthropic.New(anthropic.Config{}),
    System:  "You are a helpful assistant…",
    Effort:  nacelle.EffortHigh,
    Tools:   []nacelle.Tool{searchEvents},
    MCP:     []mcp.Server{{Name: "perception", URL: "https://perception.facile.studio/api/mcp"}},
})

for event, err := range agent.Stream(ctx, conversation) {
    if err != nil {
        return err
    }
    switch event.Kind {
    case nacelle.KindText:
        io.WriteString(w, event.Text)
    case nacelle.KindToolCall:
        log.Println("calling", event.Tool.Name, event.Tool.Input)
    case nacelle.KindDone:
        log.Println("spent", event.Usage.Total(), "tokens")
    }
}

A tool is a Go function; its schema comes from the 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"`
}

searchEvents, err := nacelle.NewTool("search_events", "Find events matching a question",
    func(ctx context.Context, in searchInput) (string, error) { … })

Defaults: claude-opus-5, adaptive thinking, 32k output per turn, and prompt caching always on.

Caching is not a knob because there is no run this package makes where turning it off pays. A cache write costs 1.25x a plain input token and a read costs 0.1x, so it is ahead from the second request sharing a prefix — and the tool runner resends the whole conversation every iteration, so any run that calls a tool has already made that request. Over ten turns it is the difference between paying for the system prompt and tool schemas once and paying for them ten times. Tools are sorted by name for the same reason: they render at the front of the prefix, so leaving them in the caller's order would make two identically-configured agents miss each other's cache with nothing in the result to say why.

Backends, and the capability rule

There is no default backend. A package that picks one for you hides the most consequential line in the configuration, and this is a multi-model SDK.

anthropic openrouter
Tool loop the SDK's runner hand-rolled here
Remote MCP servers ✅ the API calls them itself ❌ no equivalent in the schema
Streamed reasoning delta.reasoning
Effort ✅ mapped to reasoning.effort
Reports cost in dollars ❌ tokens only usage.cost
Models Anthropic's 400+ behind one slug

Asking for something a backend lacks is refused at construction, not silently dropped:

_, err := nacelle.New(nacelle.Config{Backend: openrouter.New(...), MCP: servers})
// *nacelle.Unsupported: backend "openrouter" does not support MCP servers

That is the whole reason Capabilities exists. Losing MCP tools quietly looks like a model that will not use its tools, and you can spend an afternoon on that.

Retrying

backend := nacelle.Retry(openrouter.New(...), nacelle.RetryOptions{})

The zero RetryOptions is the recommended policy, not the absence of one: three attempts, 500ms doubling to a 8s ceiling, with jitter. Attempts: 1 turns it off.

That ceiling is not a time budget. It caps this wrapper's own delay, and the SDKs sleep on Retry-After before a failure is ever handed up as transient — inside each attempt this then repeats. Three attempts over three HTTP retries is nine requests and six sleeps nacelle never sees, which under Retry-After: 60 is roughly six minutes. Budget is the field that bounds it: a wall clock for the whole run, derived once as a context deadline and passed down, which is the only thing that reaches those sleeps — they are a select on ctx.Done() inside the SDKs. It has no default and zero leaves a run unbounded, because a deadline bounds the successful attempt too: size it against your slowest good answer, the way you would size an Envoy rq-timeout, not against your retry tolerance.

This is not a backoff engine, on purpose. Both SDKs already retry at the HTTP level — connection failures, 408, 409, 429, 5xx, honouring Retry-After-Ms and Retry-After — and that covers establishing a stream, streaming requests included. Writing another one here would be a worse copy of code we already ship.

What it adds is the case no HTTP retry can see: a provider that answers 200 and puts the failure in the body. OpenRouter reports a rate limit as an error object inside the SSE; 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, and neither is visible to anything classifying on a status code.

Two details that decide whether this works at all:

  • The in-band code is a number. OpenRouter sends "code": 429, not "429". A decoder expecting a string drops the field without failing the parse, so the classifier reads an empty code and calls every rate limit permanent — a retry that looks implemented and never fires. TestAnInBandRateLimitIsRetryable pins it.
  • Retrying stops the moment anything is yielded. A consumer that has seen a text delta has already printed it, and no wrapper can un-print it. A failure after the first event ends the run and is reported as it is.

Backends classify their own provider's vocabulary and mark what is worth another go with nacelle.Transient; the core knows only Retryable(error) bool. Your own error type joins the scheme by implementing Retryable() bool.

Settings
# ~/.nacelle.yml
backend: openrouter
model: deepseek/deepseek-v4-flash-0731
bash: false
max_iterations: 40

Precedence is flag > NACELLE_* env var > file > default, resolved in one function. A sibling CLI in this suite read its environment inside a branch that ignored its config file, which turned what the README called overrides into two mutually exclusive modes — four copies of a precedence chain are four chances to disagree.

Two details that are load-bearing rather than fussy:

  • Only the flags you actually typed count. Go's flag package cannot tell a flag left alone from one passed its own default afterwards, so flag.Visit is what stops a default silently outranking the file it is meant to sit beneath.
  • Toggles are pointers. A layer saying nothing and a layer saying false are different answers, and a bool cannot tell them apart. Strings use empty for the same purpose, which is safe because no setting here has a meaningful empty value.

No credentials in it, deliberately. They already have homes — the environment, and the Anthropic SDK's own profile from ant auth login. A file with a key in it is a file that can never be committed to a dotfiles repo, which is the only reason to want one of these on two machines.

The local tool set

set, err := tools.New(tools.Config{Root: repo, AllowBash: true})
defer set.Close()
local, err := set.Tools()   // or set.ReadOnly() for an agent that only answers questions

read_file, write_file, edit_file, list_directory, find_files, search_content, and run_command when AllowBash is set. Every file operation goes through os.Root, so a path resolving outside the root is refused by a kernel-backed check rather than a string comparison — which is what closes symlink escapes, .. that survives normalisation, and the check-then-use window.

Three rules it is worth knowing before extending it:

  • The root comes from the host, never from a tool argument. No tool takes a cwd, dir or root, and a test enforces it. CVE-2025-59532 is why: Codex CLI accepted a model-generated working directory as its sandbox root, so the output being confined was also choosing the confinement.
  • edit_file requires its target text to match exactly once. Zero matches means the model misremembered the file; more than one means it is about to change places it never looked at. Both are caught for free, and the refusal teaches the fix — send more surrounding context.
  • This is not a sandbox and does not pretend to be. os.Root concedes bind mounts, /proc and device files; run_command is not confined at all and no denylist survives contact with a shell. AllowBash is opt-in so that choice is made on purpose, and real isolation is a container's job — which is what sandbox/ will be for.

Commands run with a scrubbed environment (PATH and HOME, nothing else), in their own process group so a timeout kills the children too, with every output capped and truncation announced rather than silent.

Three tools sit outside the confined set because they answer questions the working directory cannot. tools.Mycelium() reaches this machine's recorded flows and wiki. tools.WebSearch(url) searches the web through a SearXNG instance you host, and tools.WebFetch() reads one of the pages it finds:

searching, err := tools.WebSearch(os.Getenv("SEARCH_URL"))  // "" builds nothing, and no error
reading, err := tools.WebFetch()
local = append(append(local, searching...), reading...)

Both backends can search server-side and neither does it for free — $10 per 1,000 searches on Anthropic, no free tier on OpenRouter — while an instance you already run costs nothing per query, keeps the queries on your own machine, and works the same on either backend because it is an ordinary local tool rather than a request parameter. There is no default endpoint and there will not be one: this repository is public, and any instance shipped as a default would be somebody else's machine. As with the root, the endpoint comes from the host and never from a tool argument — the model supplies a query and nothing else.

web_fetch is the one tool here whose destination the model chooses, which is the whole of SSRF, so the address check lives in the dialer's Control hook rather than on the URL: a hostname resolves to whatever its owner says today, and checking a resolved address before connecting is a race the second lookup wins. It refuses loopback, private, link-local — the cloud metadata endpoint included — and the special-use ranges net/netip has no predicate for, on every redirect hop. Pages come back as text with headings, lists, code and absolute links; it asks for text/markdown first, which Cloudflare and Vercel answer by converting at the edge for roughly 80% fewer tokens.

Both are read-only and neither can be made safe against what it reads. A fetched page is text written by a stranger arriving where the model reads instructions, and it can ask for another URL with something from the conversation in the query string. Mount them knowing that.

Three things that are not negotiable, because they are what makes the core embeddable:

  • The loop returns events, it does not print. Every surface — SSE, a TUI, a log file, a test — is a consumer of the same typed event stream. Nothing in the core writes to stdout.
  • Usage is reported per turn, always. Atelier compares runs on cost; a token count that has to be reconstructed afterwards is a token count nobody trusts.
  • The core knows nothing about any product. No events, no entities, no citations, no repositories. If a consumer needs vocabulary in the core, the abstraction is wrong.

Decisions

Decision Why
Go, and the suite conventions Every consumer is a Go service; tronc and porte set the shape
The SDK's tool runner, not a hand-rolled loop anthropic-sdk-go ships toolrunner; hand-rolling is strictly more code and more bugs
The API's MCP connector for remote servers, not an MCP client mcp_servers + mcp_toolset calls remote servers server-side; the client half is only needed for stdio servers
claude-opus-5, adaptive thinking, no budget_tokens budget_tokens is a 400 on Opus 5; output_config.effort replaces it
The backend seam is the whole loop, not one request Anthropic ships a runner and server-side MCP; a backend without them must drive the loop itself. A request-level seam would have forced the Anthropic path to give up a tested loop to look symmetrical with one that cannot have it
Capabilities are named features, not tiers A consumer needs to know MCP specifically is missing, not that a backend is "limited"
openai-go for the OpenRouter transport The SSE parsing is where the bugs live — its comment/blank-line handling is the fix for the exact : OPENROUTER PROCESSING crash. Module pruning keeps it to four indirect deps
Prompt caching on, with no way off Break-even is two requests sharing a prefix; a tool-using run always makes them
Retry wraps the Backend, and is not a backoff engine The SDKs already retry HTTP properly; what they cannot see is a failure delivered inside a 200
TUI and sandbox are separate modules A backend must not inherit Bubble Tea or a container runtime
Notes from building the OpenRouter backend

Four things that cost time and are not obvious from the sample code:

  • stream_options: {include_usage} and usage: {include: true} are deprecated and inert. Usage is always returned now. Most tutorials and most training data still send them.
  • Usage arrives in the final chunk, whose choices array is empty. Indexing it unguarded panics on every run, not occasionally.
  • : OPENROUTER PROCESSING is a real SSE keepalive; passing it to a JSON decoder throws. The blank line after it is the second half of the same trap, and together they were a filed-and-fixed crash in openai-go — which is a good argument for not writing your own SSE parser.
  • The tool schema must go on every request, including the one carrying tool results. OpenRouter validates it per call, and a follow-up without it is a different conversation.

reasoning_details is echoed back untouched on the assistant message, because reasoning models require the sequence to match what they produced — a tool loop that drops it loses the model's train of thought exactly when it is waiting on a tool result.

The terminal client

The terminal client lives in its own repository now: nacelle-tui. It is the SDK's first consumer and that is its job: a terminal exercises every event kind a backend can produce — text, reasoning, a tool starting, a tool finishing, what a turn cost — while someone is watching, which is more of the contract than a headless caller touches.

Being outside the tree is the point. The client resolves the core from the proxy at the version its go.mod names, exactly like every future consumer will, so an API change that breaks it is visible at release time instead of hidden by a workspace file.

It already earned its keep while living here:

  • openrouter never emits KindToolCall. Fixed. It also turned out that announcing calls without reporting a failure for an unknown tool would have left an orphan call, so that is closed too.
  • anthropic emits KindToolResult with an empty Tool.ID. Fixed. The SDK never hands a tool its call id, so the pairing is rebuilt from the stream side; see anthropic/invocation.go.
  • Message is text and a role, so a tool call cannot be replayed to the model. Fixed (tracked as A6 in ROADMAP.md): Message is now a content-part union, and a resumed conversation carries its real tool history instead of dropping it.

Three problems for one week-old client, all three now closed. That is what a first consumer is for.

Not a port of pi

pi (earendil-works/pi, MIT) is the Node coding agent this suite runs today — Atelier already uses it as a harness class. The obvious shortcut is to fork it or port it to Go. We are not doing either.

Forking keeps us on Node, and this has to embed inside a Go API. Porting means months spent rebuilding provider plumbing and a streaming loop that anthropic-sdk-go's tool runner already gives away — and it lands us with somebody else's tool surface.

That last part is the actual reason. We are writing our own harness so it wires into our own tools. A ported pi knows about files and shells. Ours has to reach Perception's MCP server, Opus, Sablier, Casier and Antenne as first-class citizens, run inside Atelier's boxes reporting cost per run, and be embeddable in a Go backend. That is not a fork of a coding agent with extra tools bolted on; it is a different shape, and building it from the SDK up is the shorter path to it.

Read pi where it has already solved a problem — its client/protocol split is a good answer to "how does one core serve a TUI and a backend", and docs/containerization.md is written by someone who put an agent in a box before us. Take ideas, cite them, write our own code.

Next steps

The ordered version, with exact files and exit criteria, is in ROADMAP.md.

  1. The gate, the core loop, mcp/, tools/, and both backends. Done.
  2. Consume it from a second, non-terminal consumer. A TUI exercises the event stream with somebody watching; it cannot find the problems a service has — concurrency, lifetimes, what hour six looks like. That is the first real test of the API.
  3. tui/, at floor scope. Done, and it is already reporting API problems.
  4. Widen Message so a conversation can carry tool calls. Done.
  5. sandbox/ for Atelier — driven by what they actually turn out to need, not by this list. Nothing else is currently ordered; see ROADMAP.md's "Where this is" for what is real but not yet scoped (a global ~/.claude/CLAUDE.md-equivalent layer, slash commands) versus what is deliberately out (a provider interface beyond two backends, sessions, panes).

Gate

sh scripts/check.sh — gofmt, vet, -race test, golangci-lint, every module. filet check . on top, and it is expected to be silent: the loop was refactored to satisfy it rather than the other way round. CI runs the same gate on push and pull request.

Licence

Apache License 2.0 — see LICENSE and NOTICE.

Apache rather than MIT for one practical reason: nacelle expects to carry code from Apache-licensed neighbours such as charm.land/fantasy, and matching licences keeps the repo under one set of terms instead of two. The patent grant is a welcome extra. Attribution for anything vendored belongs in NOTICE.

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

View Source
const (
	DefaultRetryAttempts = 3
	DefaultRetryBase     = 500 * time.Millisecond
	DefaultRetryMax      = 8 * time.Second
)

Retry defaults, applied to any RetryOptions field left at zero.

View Source
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.

View Source
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.

View Source
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

View Source
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")
)
View Source
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

func Attempt(err error) int

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

func Retryable(err error) bool

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

func ToolsByName(tools []Tool) map[string]Tool

ToolsByName indexes tools for a backend dispatching a call by name.

func Transient

func Transient(err error) error

Transient marks an error as a failure worth retrying.

Backends use it to promote the transient errors only they can recognise. Classifying a provider's own error vocabulary needs the vocabulary, which is exactly the knowledge a backend has and the core deliberately does not.

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

func New(cfg Config) (*Agent, error)

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

func (a *Agent) Backend() 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

func (a *Agent) CountTokens(ctx context.Context, conversation []Message) (int64, error)

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

func (a *Agent) Stream(ctx context.Context, conversation []Message) iter.Seq2[Event, error]

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

type Approve func(ctx context.Context, name string, input json.RawMessage) bool

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

func Async(h Hook) Hook

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

func WithTimeout(d time.Duration, h Hook) Hook

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

type Message struct {
	Role  Role
	Parts []Part
}

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

func AssistantText(text string) Message

AssistantText is a model turn that was prose and nothing else.

func Trim

func Trim(conversation []Message, keep int) (kept []Message, dropped int)

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.

func UserText

func UserText(text string) Message

UserText is the ordinary case: somebody asked something.

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.

const (
	// RoleUser is the caller's side of the conversation, and also where the
	// results of tools the model asked for are carried.
	RoleUser Role = "user"

	// RoleAssistant is the model's side.
	RoleAssistant Role = "assistant"
)

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"
)

func (Stop) Complete

func (s Stop) Complete() bool

Complete reports whether the answer is whole. Anything else means the run stopped short, and only StopEnd is safe to present as a finished answer.

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 Text

type Text struct{ Text string }

Text is prose, from either side of the conversation.

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

func (s *ToolSink) Drain() []Event

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

func (s *ToolSink) Report(event Event)

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

type Unsupported struct {
	Backend string
	Feature string
}

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) Add

func (u Usage) Add(other Usage) Usage

Add returns the sum of two usages.

func (Usage) CacheHitRate

func (u Usage) CacheHitRate() float64

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.

func (Usage) Total

func (u Usage) Total() int64

Total is every token the run was billed for.

Directories

Path Synopsis
Package anthropic runs a nacelle agent on the Anthropic API.
Package anthropic runs a nacelle agent on the Anthropic API.
mcp
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.

Jump to

Keyboard shortcuts

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