Help us shape the future of Go! Take the 2026 Go Developer Survey

agentrt

package module
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Oct 2, 2026 License: MIT Imports: 27 Imported by: 6

README

agent-runtime

ci

agent-runtime is a Go library for running AI agents that take real actions. The rules about what an agent may do are enforced by ordinary code, outside the AI model, where the model cannot change them.

This page starts with the problem and the approach in plain language, for any reader. The commands and the API follow, for people who want to build on it.

The problem

An AI language model produces text. An agent is a program that lets a model do more than that. The program gives the model a list of actions it may request, called tools, such as reading a file, running a command, or sending a message. The model asks for one, the program carries it out and shows the model the result, and this repeats until the task is done.

That loop is short to write, and every AI vendor's software kit includes one. The hard part begins when the actions are real:

  • A model's choice is a prediction. It is usually sensible and sometimes wrong, and the same input can produce a different choice the next time.
  • A model cannot reliably tell instructions from content. Text inside a file or a web page it reads can redirect it. This is called prompt injection.
  • A model can repeat itself, run for hours, and spend money on every request.
  • Afterwards someone has to be able to say what the agent did and who allowed it.

The common defence is to write the rules into the model's instructions: never delete files, ask before sending anything. That is a request. The model follows it most of the time, and nothing stops it the rest of the time.

The approach

agent-runtime moves the rules out of the model's instructions and into code. The model asks and the runtime decides. The model cannot change the rules. It is told only the outcome of a request and the reason the policy gives, and the agent has no access to the code that applies the rules.

For every action the model requests, the runtime does the same things in the same order:

  1. Record the request exactly as the model made it.
  2. Check its form against the tool's declared inputs. A malformed request never runs. The model is told what was wrong.
  3. Ask the policy, which is code supplied by the person building the agent. The answer is one of four: allow it, deny it, end the run, or pause until a person approves.
  4. Run it if it was allowed, under a time limit, and record the result.

Around that sequence the runtime provides the controls that a real deployment needs:

Control What it means
Approvals tied to the exact request When a run pauses for approval, the person is shown one specific request, and the approval is valid for that request only. It is stored with a fingerprint, called a hash, of the request. If anything differs when the run continues, the approval does not apply. The operator's decision is tied to the same fingerprint, so what was displayed is what gets approved.
Approvals that outlast the process A paused run is a row in a database. The program can exit, the person can decide the next day, and the run continues from where it stopped.
Limits checked before the fact Steps, failures in a row, identical requests that keep giving the same result, elapsed time, model calls, tokens, and spend can each be capped. A cap is checked before the next action or request starts, and reaching one ends the run with that reason recorded.
A complete record Every step, decision, and result is written to a database in the same operation as the change it describes, so the record cannot fall out of step with what happened.
No action repeats on its own If the program dies in the middle of an action, the runtime records the step as interrupted when the run is resumed. An action that changes something is not run again unless the consumer's own check of the outside world says so or an operator approves it, and the approval states that the first attempt may have taken effect.
One process per run A run in progress is leased to the process running it. Another process that tries to continue it is refused until the first finishes or its lease expires.
A database only its owner can use The database file is created so that only its owner can read or write it. One that other users can write is refused, because anyone who can write it could forge an approval. These checks are skipped on Windows.
Repeatable tests The whole loop runs against a scripted agent or recorded model replies, so tests give the same result every time and never call a paid service.

An example

The demo in examples/mcp gives a local model access to a folder of files. The operator's rules file says which tools are reads and which is a write. The example's policy allows reads and sends every write to an operator.

The model reads what it needs, and each read is checked, run, and recorded. When it asks to write a file, the run stops. The operator sees the file path, the number of lines, and a preview of the content, and approves with one command. The run continues and the write happens. The record then shows the reads that were allowed, the write that waited, who approved it, and the write that ran after the approval.

Who uses it

  • repo-steward upgrades a dependency of a Go project, repairs what the upgrade broke, and prepares a pull request that a person approves.
  • casework, which is private, investigates a customer support case about access to a paid feature and proposes one fix that an operator approves.

Both supplied the requirements. A feature is added here when one of them shows the need for it, and not before.

What it is not

agent-runtime runs one agent at a time and is a library, not a service. It does not coordinate several agents, schedule work, manage prompts, or give a model memory. The model loop is not the point either, since any vendor's kit has one. This library exists for the controls around the loop.

Terms used on this page

Term Meaning
Model The AI language model that chooses the next action.
Agent The program that asks the model for a decision at each step.
Tool One action the agent can request. Each tool declares the inputs it accepts and the kind of effect it has: read only, a local change, a remote change, or destructive.
Policy The rules, written as code, that decide whether a requested action runs.
Run One execution of an agent toward a goal, from start to a final state.
Step One decision by the agent and what came of it.
Approval A person's decision to allow one specific request.
Operator The person who runs the agent and grants or refuses approvals.
Consumer A program built on this library, which supplies the agent, the tools, and the policy.

Status

The current release is v0.3.0. The runtime includes the packages that both consumers had written separately, an adapter that puts the tools of any MCP server behind the policy, and the groundwork for outside contributors. These are items 1, 2, and 8 of the roadmap. Items 3 to 6 are not started. Item 7, writing up the ideas, is ongoing.

v0.3.0 followed an audit and a security review of v0.2.1. It added the lease on a run, the database only its owner can use, the pause after an interrupted action, the approval tied to what was displayed, and the adapters' refusal of redirects and of keys over plain http. CHANGELOG.md begins with what an upgrade from v0.2 requires. The three that matter most:

  • Stop every older process and every older agentrt binary that has the database open before the new version first opens it.
  • Run chmod 600 on a database that group or others can write. The new version refuses it until then.
  • A consumer that sets no Config.Reconcile will see a run pause for an operator when an action was interrupted by a crash.

The provider adapters and the MCP adapter are separate modules inside this repository, tagged with a directory prefix: providers/ollama/v0.2.0, providers/anthropic/v0.2.0, providers/openai/v0.2.0, and mcp/v0.2.0. Each requires core v0.3.0.

The API is pre-1.0 and changes when a consumer needs it to.

Where to read more:

  • docs/architecture.md: the loop, approvals, interruption, and persistence.
  • docs/status.md: every control with the test that verifies it.
  • docs/decisions: why it is built this way.
  • CHANGELOG.md: what each release changed and what a consumer has to do about it.
  • Wiki: the same material at length, for people using, extending, or evaluating the runtime.
Compatibility

Pre-1.0, so the promise is narrow and written down rather than implied:

  • within a minor version of the core, the exported API is additive only: a patch release adds symbols and fields and takes none away;
  • a minor bump may change or remove exported API, and the release notes say which symbols and what to do instead;
  • the nested modules version independently, with their own directory-prefixed tags, and each names the core version it requires in its go.mod, so upgrading one does not move the other nested modules, though it raises the core to the version it requires;
  • the SQLite schema only migrates forward. A newer core opens a database written by an older one and applies what is missing; there are no down migrations, so a database is not handed back to an older core. Processes and operator binaries of an older version are stopped before a newer core first opens a database. The operator command never migrates: it refuses a database whose schema is not its own build's and says which side is newer;
  • the replay key is the model name and the canonical JSON of the request (replay.Key), and its shape has not changed. A recording made through render stops matching when a release changes what render produces for a step in it, and the release notes say when that happens. v0.3.0 does this for a reply with more than one tool use and for a reply with no tool use that is longer than 500 bytes.

CONTRIBUTING.md is how a change gets in, and SECURITY.md is what the runtime does and does not defend against.

Quick start

Every module requires Go 1.26 or later, so the current release and the one before it both build them, and CI runs both.

The demo the project leads with is examples/mcp: a local model driving the MCP filesystem server, where reads are allowed and a write stops the run until an operator approves that exact call. It needs Node, to run the server pinned by version and integrity hash in examples/mcp/package-lock.json, an Ollama server holding qwen3:30b-a3b, and a workspace, because the example and the MCP adapter are nested modules:

go work init . ./providers/ollama ./providers/anthropic ./providers/openai ./mcp ./examples/live ./examples/mcp
(cd examples/mcp && npm ci --ignore-scripts)   # once: the pinned server, no install scripts
go run ./examples/mcp pin      # hash the server's tools, print the hints it claims
go run ./examples/mcp run      # load the operator's rules, run to the first write
go run ./cmd/agentrt -db <path> approve <run>   # prints the request, then asks
go run ./examples/mcp run -resume <run>
go run ./cmd/agentrt -db <path> events <run>

approve prints the waiting request and asks for confirmation when it is run at a terminal. In a script it needs -approval <id> or -yes.

pin prints one line per tool with the annotations the server asserts, which classify nothing and are only there for the operator to disagree with. examples/mcp/rules.json is the operator's answer: four read tools, write_file as a local mutation with the server's own destructive hint overruled in writing, and every other tool the filesystem server offers left out, so run reports them unclassified and the model never sees them. The run then pauses at write_file with the path, the line count, and a preview of the content, and prints the two commands above with the run id and the database filled in. The trail shows the reads that were allowed, the write that waited, the operator who granted it, the write that executed after the grant, and the finish call that completed the run.

The no-network path is examples/scripted, which needs neither a model nor Node:

go test -race ./...
go run ./examples/scripted

It runs a scripted agent against three in-memory tools, including one malformed request the runtime rejects and one destructive request the policy denies, then prints the persisted event trace.

Shape of the API

store, _ := agentrt.OpenStore("runs.db")
driver, _ := agentrt.NewDriver(agentrt.Config{
    Store:  store,
    Agent:  myAgent,               // Decide(ctx, StepInput) (Decision, error)
    Policy: agentrt.DefaultPolicy(), // read/local allow, remote approval, destructive deny
    Tools:  []agentrt.Tool{...},   // Spec() with JSON schema, side effect, timeout
})
run, _ := driver.Start(ctx, "goal", agentrt.DefaultLimits())

A consumer opens a store, which is one SQLite file, builds a driver from its agent, policy, and tools, and starts a run toward a goal.

DefaultPolicy decides by the kind of effect a tool declares. Tools that only read and tools that change local state are allowed. A tool that changes something remote pauses for approval. A destructive tool is denied, and so is a tool whose kind the policy does not know.

DefaultLimits ends a run at 50 steps, at 3 failures in a row, or when the same request has produced the same result 3 times in a row. The caps on model calls, tokens, spend, and time are off until the consumer sets them.

A spend cap only works for a model in the price table. A model missing from the table is counted as free. Model calls are not retried unless ModelConfig.MaxRetries is set.

What a consumer no longer has to write

Both existing consumers rebuilt the same provider plumbing, renderer, trace, and operator surface. These packages are in the core module and add no dependency beyond the standard library:

  • providers: transport and HTTP classification for a provider adapter (a timeout or a reply cut off mid-read returned bare, retryable statuses transient with the provider's Retry-After), an HTTP client that follows no redirect, a bounded body read, a check that usage numbers are counts, tool-use id synthesis, a price lookup that refuses an unpriced paid model, and dollar parsing for budgets.
  • render: a run's recorded steps to model messages, with synthesized tool-use ids, a recent-results window, a content cap, and hooks for the opening message, the assistant turn, and the observation; plus render.Decide, which maps a response to a decision and turns a truncated or tool-less reply into a deliberately invalid decision the runtime records and the next render nudges.
  • trace: aligned and JSON Lines observers, and Sanitize, which escapes text that could alter what a terminal displays.
  • view: run, step, and approval read models, read in bounded pages, and the rule for choosing the approval an operator means.
go run ./cmd/agentrt -db runs.db runs
go run ./cmd/agentrt -db runs.db show <run>       # steps and the waiting approval
go run ./cmd/agentrt -db runs.db events <run>     # the audit log
go run ./cmd/agentrt -db runs.db -json events <run>   # the same as JSON Lines
go run ./cmd/agentrt -db runs.db approve <run> -approval <id> -by joey -note "ok"
go run ./cmd/agentrt -db runs.db reject <run> -approval <id> -note "not this file"
go run ./cmd/agentrt -db runs.db cancel <run>

cmd/agentrt reads -db or AGENTRT_DB, prints aligned text or JSON, and exits 0, 1, or 2. -db and -json go before the subcommand. approve, reject, and cancel take their own flags before or after the run id. show and events take them after the run id.

  • It opens an existing database only. It never creates one and never migrates one.
  • runs, show, and events open the database read-only and page their output with -limit and -offset.
  • In text output, approve and reject print the waiting request first. They need -approval <id>, -yes, or a yes typed at a terminal, and the decision applies only if the stored request is still the one that was printed.
  • -by is a label recorded as given, not a login. Whoever can write the database can approve.
  • Text output escapes control characters and characters that change how text is displayed, because goals, tool names, and results come from the model and from servers.
  • It has no resume: resuming executes the approved request and continues the loop, which needs the consumer's agent, tools, and policy.

A run ends COMPLETED, FAILED with a terminal reason, or CANCELLED when an approval is rejected or expires or an operator cancels the run. It pauses in WAITING_FOR_APPROVAL on a hash-bound approval that Approve and Resume continue. A run in progress is leased to its driver, and Resume from another process is refused with ErrRunLeased until the lease expires. A model configured on the driver is wrapped in an accounting caller that agents receive in StepInput: it records every attempt, enforces call, token, and cost limits before dispatch, and retries transient failures up to ModelConfig.MaxRetries times. The replay package provides scripted, recording, and replaying models so tests never call a provider.

Provider adapters

Each adapter is a nested module with its own go.mod, so a consumer imports only the provider it uses and a provider SDK never reaches the core:

  • providers/ollama: a local Ollama server over its native chat API, with no dependency beyond the standard library.
  • providers/openai: any OpenAI-compatible chat completions endpoint, which covers most local servers, with no dependency beyond the standard library.
  • providers/anthropic: the Claude Messages API through the official SDK, with the raw assistant turn round trip that keeps signed thinking blocks alive across a continuation, an opt-in strict schema subset, and a dated price table.
go get github.com/joeylking/agent-runtime/providers/ollama

All three implement agentrt.Model the same way: a Config carrying the model id, New(Config) (*Model, error), no streaming, no retries of its own because retrying is the accounting caller's job and it records every attempt, the provider's raw bytes on every response, and a stop reason normalized to tool_use, end_turn, max_tokens, or refusal where the provider reports one of those, with any other value passed through as the provider gave it. A reply that was cut off or refused comes back with no tool uses, so a partial call can never execute, and with its usage, so the attempt is still charged.

No adapter follows a redirect, because a redirect would carry the key to a host the consumer did not choose. A key travels over plain http only to a loopback address.

  • The Anthropic adapter reads ANTHROPIC_API_KEY and nothing else from the environment. It refuses to build without a key. It also refuses a model its price table does not list, unless Config.Name is set and the consumer prices that name.
  • The OpenAI adapter reads OPENAI_API_KEY only for the default endpoint.
  • The Ollama adapter reads OLLAMA_HOST when no host is configured, and nothing else.

Usage that is not a plausible count is refused. The attempt is charged at the conservative estimate, the reply is not used, and the run ends as model_unavailable. Printing or encoding a Config or a Model redacts the key, except a Config kept in an unexported field of your own struct and printed with %v.

Name() is <provider>:<model> unless the config overrides it, so one price table and one recording directory can hold several providers. anthropic.Prices() prices the paid models; ollama.Free(m) and openai.Free(m) take the built adapters and record their models as free, which is what providers.PriceFor needs to tell a free model from an unpriced one.

No test calls a provider: the Anthropic adapter is exercised against an httptest fake, and each local adapter has one round trip behind the live build tag that skips unless a server answers. Working on the nested modules needs a workspace, which is what CI builds:

go work init . ./providers/ollama ./providers/anthropic ./providers/openai ./mcp ./examples/live ./examples/mcp

MCP servers behind the policy

mcp is a nested module that exposes an MCP server's tools as agentrt.Tools, so they pass the same validation, policy, limits, and audit log as any other tool. The MCP specification says a client must never make decisions from tool annotations, because an untrusted server supplies them, which is the premise of the design:

  • a tool with no operator Rule naming its side-effect class is not registered, and the load report says so;
  • Pin records each tool's name, description, and input schema in a manifest the operator reviews and stores; Load refuses any tool whose live schema or description no longer hashes to the pin, because a description is model input and a changed one is an injection vector;
  • annotations are recorded and cross-checked. They can only contradict an operator's classification, never relax it, and a contradiction refuses the tool unless the rule allows the mismatch, which the report records;
  • an operator may override a description, deny a parameter, and fix a parameter's value; a denied or fixed parameter is removed from the schema the model sees, and a call that supplies one fails;
  • a schema that refers to anything outside itself is refused, so a server cannot make the runtime read a file or fetch a URL;
  • a server started as a child process gets a minimal environment plus the variables the operator names. On Unix it runs in its own process group and is stopped with its descendants on close. It still runs as the operator and can read the operator's files;
  • connecting, initializing, and listing tools are bounded by ConnectTimeout, and an HTTP server's redirects are not followed;
  • the model sees <server>_<tool> unless the rule renames it, so two servers cannot collide;
  • isError becomes a tool error the agent must work around, content is capped, and a server asking for more input is refused.
manifest, _ := mcp.Pin(ctx, server)            // review and store this
tools, report, _ := mcp.Load(ctx, server, manifest, rules)
defer report.Connection.Close()

ADR 4 records why the class is the operator's and why a hint may only refuse. examples/mcp is the whole thing running: the quick start above is its commands.

A live run that stops for an operator

examples/live is the shortest complete consumer: the tools from examples/scripted plus a terminal one to finish on, an Agent that is render.Messages and render.Decide around the model, a stderr trace, and a policy that sends the first change to the counter to an operator. It prints the approve command with the run id filled in.

go run ./examples/live                              # pauses; prints the two below
go run ./cmd/agentrt -db <path> approve <run>
go run ./examples/live -db <path> -resume <run>

It runs ollama:qwen3:30b-a3b by default; -model openai:<id> with -base-url http://127.0.0.1:11434/v1 runs the same agent through the OpenAI-compatible adapter.

Documentation

Overview

Package agentrt is a small runtime for executing tool-using agents under deterministic control. The runtime owns the step loop, argument validation, policy evaluation, limits, persistence, and the audit event log. The agent owns only the decision of what to do next.

A Driver runs the loop: decide, validate, evaluate policy, execute, observe, persist, repeat, until the run completes, fails, or pauses for a hash-bound approval that Approve and Resume continue. Model calls go through an accounting caller that enforces the run's limits; repeated failures and repeated identical outcomes stop the run; a run interrupted mid-step is reconciled by the consumer on Resume, and a lease keeps two processes from executing one run. docs/architecture.md describes the design and docs/status.md what is verified.

Index

Examples

Constants

View Source
const (
	EventRunCreated        = "run.created"
	EventRunStarted        = "run.started"
	EventRunFinished       = "run.finished"
	EventStepStarted       = "step.started"
	EventStepDecided       = "step.decided"
	EventStepPolicy        = "step.policy"
	EventStepToolStarted   = "step.tool_started"
	EventStepToolFinished  = "step.tool_finished"
	EventStepFailed        = "step.failed"
	EventApprovalRequested = "approval.requested"
	EventLimitExceeded     = "limit.exceeded"
	EventLoopDetected      = "loop.detected"
	EventApprovalDecided   = "approval.decided"
	EventRunResumed        = "run.resumed"
	EventStepInterrupted   = "step.interrupted"
	// EventLeaseTakenOver records a Resume taking over a RUNNING run whose
	// lease another owner let expire. Its payload names the previous owner
	// and expiry, read from the wall clock, and the new owner.
	EventLeaseTakenOver  = "lease.taken_over"
	EventModelDispatched = "model.dispatched"
	EventModelCompleted  = "model.completed"
	EventModelFailed     = "model.failed"
)

Event types written by the driver.

View Source
const DefaultLeaseTTL = 30 * time.Second

DefaultLeaseTTL is the lease a driver holds on a run it executes when Config.LeaseTTL is zero. The lease is renewed at a third of it.

View Source
const DefaultMaxRetryAfter = time.Minute

DefaultMaxRetryAfter is the longest wait a provider may ask for before a retry when ModelConfig.MaxRetryAfter is zero.

View Source
const InterruptedSideEffect = "interrupted_side_effect"

InterruptedSideEffect is the approval kind a run pauses on when Resume, with no Config.Reconcile, finds a step interrupted while executing a tool that is not ReadOnly. Its presentation says the tool started and its outcome is unknown; approving runs it again, rejecting ends the run.

View Source
const MaxPageText = 64 << 10

MaxPageText is the most characters the page reads (ListRunsPage, ListStepsPage, ListApprovalsPage, ListEventsPage) return from any one text column, so a crafted database cannot make a front end that lists it read without bound. A capped text column keeps its first MaxPageText characters and ends in a marker giving its full length; a capped JSON column is replaced by {"truncated":true,"length":n,"head":"<its first MaxPageText characters>"}, which is still JSON. Each page read returns the total its listing would have, from a COUNT, so a front end can say how much it did not show without reading it.

Variables

View Source
var ErrApprovalChanged = errors.New("agentrt: approval is not the one that was shown")

ErrApprovalChanged means the stored approval is no longer the one the operator was shown.

View Source
var ErrApprovalHash = errors.New("agentrt: approval hash does not match its recorded request")

ErrApprovalHash means a stored approval no longer matches its own fields.

View Source
var ErrLeaseLost = errors.New("agentrt: run lease lost")

ErrLeaseLost is returned by a Start or Resume whose loop lost its lease on the run: another owner took it over, or it expired unrenewed. The loop stops before its next side effect and leaves the step in flight to the next owner's reconciliation.

View Source
var ErrNotFound = errors.New("agentrt: not found")

ErrNotFound is returned when a run does not exist.

View Source
var ErrNotPending = errors.New("agentrt: approval is not pending")

ErrNotPending means an approval has already been decided or expired.

View Source
var ErrRunState = errors.New("agentrt: run is not in the required state")

ErrRunState means a run, or one of its steps, is not in the state the operation requires: a second Resume lost the race to the first, a live loop found the run cancelled under it, or an operator acted on a run that is not waiting. Status transitions are compare-and-set inside the transaction that makes them, so exactly one caller wins.

View Source
var ErrSchemaVersion = errors.New("agentrt: database schema version does not match this build")

ErrSchemaVersion means a database's schema is not the one this build of the runtime writes. OpenExisting returns it rather than migrating, and OpenStore returns it for a database a newer build has migrated.

View Source
var ErrSymlink = errors.New("agentrt: database path is a symbolic link")

ErrSymlink is returned by OpenExisting when the path it is given is a symbolic link. It is not a case of ErrInsecureMode: the fix is to name the database file itself, not to change a mode, and the link's target is never examined. The error wrapping it names the path.

View Source
var ErrUnsafeSchema = errors.New("agentrt: database holds a trigger or view")

ErrUnsafeSchema means a database holds a trigger or a view. The runtime creates neither, and either would run SQL of the file's author inside the runtime's own statements, so both openers refuse such a file.

View Source
var ErrUnusableUsage = errors.New("unusable usage")

ErrUnusableUsage marks usage a provider reported that cannot be charged: a negative count, one too large to hold, a count that is not an integer, or more cached input than input. A Model that finds such usage returns a ServedError wrapping it; the accounting caller then charges the conservative estimate, as it does for usage it finds unusable itself, and the call fails as model_unavailable without a retry.

Functions

func Approve

func Approve(ctx context.Context, store *Store, obs Observer, runID, approvalID, by, note string) error

Approve records an approval decision without a driver, for command-line front ends that decide approvals in a process that will not resume the run. The hash is recomputed from the stored fields first. Expiry is judged by the wall clock.

func ApproveShown added in v0.3.0

func ApproveShown(ctx context.Context, store *Store, obs Observer, runID, approvalID, shownHash, by, note string) error

ApproveShown is Approve for a front end that displayed the approval first: shownHash is the hash of what the operator read, and the decision is refused with ErrApprovalChanged if the stored approval differs. The comparison is made in the transaction that records the decision, and the update is conditional on the hash, so a writer cannot change the row between the check and the decision.

func Cancel added in v0.1.1

func Cancel(ctx context.Context, store *Store, obs Observer, runID, by, note string) error

Cancel is Driver.Cancel without a driver, for command-line tools that decide on runs they did not start. It stamps the wall clock.

func DecodePresentation added in v0.2.0

func DecodePresentation[T any](a Approval) (T, error)

DecodePresentation unmarshals an approval's presentation into T, for a front end that renders it or a consumer that rechecks it before resuming.

func EstimateInputTokens

func EstimateInputTokens(req ModelRequest) int

EstimateInputTokens is the conservative input estimate the limits use: one token per three bytes of the serialized request. Consumers can use it to budget before a run.

func Reject

func Reject(ctx context.Context, store *Store, obs Observer, runID, approvalID, by, note string) error

Reject records a rejection without a driver and ends the run as CANCELLED.

func RejectShown added in v0.3.0

func RejectShown(ctx context.Context, store *Store, obs Observer, runID, approvalID, shownHash, by, note string) error

RejectShown is Reject with the same binding as ApproveShown.

Types

type Agent

type Agent interface {
	Decide(ctx context.Context, in StepInput) (Decision, error)
}

Agent decides what happens next.

type Approval

type Approval struct {
	ID           string          `json:"id"`
	RunID        string          `json:"run_id"`
	StepID       string          `json:"step_id"`
	Kind         string          `json:"kind"`
	Capability   json.RawMessage `json:"capability"`
	Presentation json.RawMessage `json:"presentation"`
	Request      ToolRequest     `json:"request"`
	Hash         string          `json:"hash"`
	Status       ApprovalStatus  `json:"status"`
	CreatedAt    time.Time       `json:"created_at"`
	ExpiresAt    time.Time       `json:"expires_at,omitzero"`
	DecidedAt    time.Time       `json:"decided_at,omitzero"`
	DecidedBy    string          `json:"decided_by,omitempty"`
	Note         string          `json:"note,omitempty"`
	// DecodeError is set when a stored column of the approval could not be
	// decoded, as on Step. Such an approval cannot be decided or resumed.
	DecodeError string `json:"decode_error,omitempty"`
}

Approval is a durable, hash-bound request for a human decision. Its hash covers the kind, capability, presentation, and the exact tool request, so a changed request cannot inherit an old approval. On resume the runtime executes the recorded request and nothing else.

type ApprovalStatus

type ApprovalStatus string

ApprovalStatus is the state of an approval.

const (
	ApprovalPending  ApprovalStatus = "pending"
	ApprovalApproved ApprovalStatus = "approved"
	ApprovalRejected ApprovalStatus = "rejected"
	ApprovalExpired  ApprovalStatus = "expired"
)

type Config

type Config struct {
	Store    *Store
	Agent    Agent
	Policy   Policy
	Tools    []Tool
	Observer Observer
	// Model, when set, is wrapped in a ModelCaller that agents receive in
	// StepInput. Scripted agents leave it nil.
	Model *ModelConfig
	// Reconcile, when set, runs before a run found mid-step is continued by
	// Resume. Consumers reconcile their own journaled operations and say
	// whether the run continues, completed, waits, or is in conflict.
	Reconcile func(ctx context.Context, view RunView) (Reconciliation, error)
	// Now and NewID may be overridden by tests.
	Now   func() time.Time
	NewID func() string
	// LeaseTTL is how long the lease a driver takes on a run it executes
	// lasts without renewal; a heartbeat renews it at a third of that.
	// Zero means DefaultLeaseTTL. Lease times are read from the wall
	// clock, never from Now.
	LeaseTTL time.Duration
	// LeaseOwner names this driver in the leases it takes, for an operator
	// reading them. The owner stored is the name followed by a random
	// suffix drawn per Driver, so two drivers, in one process or two,
	// never hold a lease as the same owner, and a restarted process waits
	// out the leases its previous life held like any other process. Empty
	// means the random part alone.
	LeaseOwner string
}

Config configures a Driver.

type ContentBlock

type ContentBlock struct {
	Type string `json:"type"`
	// Text applies to text blocks.
	Text string `json:"text,omitempty"`
	// ToolUseID, Name, and Input apply to tool_use blocks; ToolUseID also
	// links a tool_result block to the call it answers.
	ToolUseID string          `json:"tool_use_id,omitempty"`
	Name      string          `json:"name,omitempty"`
	Input     json.RawMessage `json:"input,omitempty"`
	// Content and IsError apply to tool_result blocks.
	Content string `json:"content,omitempty"`
	IsError bool   `json:"is_error,omitempty"`
}

ContentBlock is one part of a message. Types are text, tool_use, and tool_result, matching native tool-use APIs closely enough that a provider adapter is a mapping, not a redesign.

type Decision

type Decision struct {
	Kind DecisionKind `json:"kind"`
	// Tool and Args apply to tool_call.
	Tool string          `json:"tool,omitempty"`
	Args json.RawMessage `json:"args,omitempty"`
	// Reason is the agent's stated rationale, recorded as data.
	Reason string `json:"reason,omitempty"`
	// Result applies to complete.
	Result json.RawMessage `json:"result,omitempty"`
	// Message applies to fail.
	Message string `json:"message,omitempty"`
	// InvalidArgs and InvalidResult are set only on a recorded decision:
	// arguments or a result the agent returned that were not usable JSON,
	// kept verbatim in place of Args or Result, which are then empty. When
	// either is not UTF-8, both are base64 and InvalidBase64 is set. A valid
	// decision never has them, so its stored form is unchanged by them.
	InvalidArgs   string `json:"invalid_args,omitempty"`
	InvalidResult string `json:"invalid_result,omitempty"`
	InvalidBase64 bool   `json:"invalid_base64,omitempty"`
}

Decision is the agent's output for one step. It is recorded verbatim before it is validated, so the audit log shows what the agent asked for even when the request was invalid.

type DecisionKind

type DecisionKind string

DecisionKind classifies what the agent asked for.

const (
	DecideToolCall DecisionKind = "tool_call"
	DecideComplete DecisionKind = "complete"
	DecideFail     DecisionKind = "fail"
	// KindTruncated and KindNoToolCall are what a model agent records when
	// the model asked for nothing executable: a reply cut off by the output
	// cap, and a reply with no tool use at all. They are deliberately not
	// valid decisions, so the driver records an invalid_decision
	// observation, the step counts against the consecutive failure limit,
	// and the next render nudges the model from the recorded kind. See
	// render.Decide.
	KindTruncated  DecisionKind = "truncated"
	KindNoToolCall DecisionKind = "no_tool_call"
)

type Driver

type Driver struct {
	// contains filtered or unexported fields
}

Driver runs the step loop. It is safe to reuse for many runs but a single run is executed by one call at a time.

func NewDriver

func NewDriver(cfg Config) (*Driver, error)

NewDriver validates the configuration and compiles every tool schema.

func (*Driver) Approve

func (d *Driver) Approve(ctx context.Context, runID, approvalID, by, note string) error

Approve marks a pending approval approved after recomputing its hash from the stored fields. The run stays WAITING until Resume. Expiry is judged by the driver's clock.

func (*Driver) Cancel added in v0.1.1

func (d *Driver) Cancel(ctx context.Context, runID, by, note string) error

Cancel ends a run that is not terminal as CANCELLED with reason operator_cancelled. It is for a run the operator no longer wants: one waiting for an approval, one already approved but not yet resumed, or one left interrupted. A step still in flight is failed with an observation naming the cancellation, and pending approvals are left as they are. A loop still executing the run finds it cancelled at its next write and stops there; the runtime does not interrupt a tool already running, and what such a tool returns is appended to the cancelled run as a late step.tool_finished event.

func (*Driver) Reject

func (d *Driver) Reject(ctx context.Context, runID, approvalID, by, note string) error

Reject marks a pending approval rejected and ends the run as CANCELLED.

func (*Driver) Resume

func (d *Driver) Resume(ctx context.Context, runID string) (Run, error)

Resume continues a run. A WAITING run needs its awaiting step's latest approval to be approved: policy is re-evaluated while the run is still WAITING, and the recorded request is executed only if its arguments still match the tool's schema and policy allows it or asks for exactly the approval that was granted. The move to RUNNING is compare-and-set, so of two concurrent resumes one executes the request and the other returns ErrRunState. A RUNNING run is being executed by the owner of its lease: while that lease is live and not this driver's, or this driver is executing the run in another call, Resume returns ErrRunLeased and changes nothing. A RUNNING run whose lease has expired, or that has none, was interrupted: Resume takes the lease, the step in flight is marked interrupted with an observation, and the consumer's reconciliation decides what follows. Without one, a step interrupted while executing a tool that is not ReadOnly waits for an operator before it can run again; anything else continues.

Example

A remote mutation pauses the run on an approval bound by hash to the exact request. Approve records the decision; Resume executes that request and nothing else, then continues the loop.

package main

import (
	"context"
	"fmt"
	"time"

	agentrt "github.com/joeylking/agent-runtime"
	"github.com/joeylking/agent-runtime/scripted"
)

// exampleTool answers with the arguments it was given. The examples are about
// the controls around a call, not about what a tool does.
type exampleTool struct{ spec agentrt.ToolSpec }

func (t exampleTool) Spec() agentrt.ToolSpec { return t.spec }

func (t exampleTool) Call(_ context.Context, c agentrt.ToolCall) (agentrt.ToolResult, error) {
	return agentrt.ToolResult{Content: c.Args, Summary: "echoed " + t.spec.Name}, nil
}

func newExampleTool(name string, se agentrt.SideEffect) exampleTool {
	return exampleTool{spec: agentrt.ToolSpec{
		Name: name, Description: name, SideEffect: se, Timeout: 2 * time.Second,
		InputSchema: []byte(`{"type":"object","properties":{"n":{"type":"integer"}},"additionalProperties":false}`),
	}}
}

// exampleIDs numbers ids in the order the driver asks for them, so an
// example's output does not change from run to run.
func exampleIDs() func() string {
	n := 0
	return func() string {
		n++
		return fmt.Sprintf("id-%d", n)
	}
}

func must[T any](v T, err error) T {
	if err != nil {
		panic(err)
	}
	return v
}

func main() {
	ctx := context.Background()
	store := must(agentrt.OpenStore(":memory:"))
	defer store.Close()

	agent := &scripted.Agent{Decisions: []agentrt.Decision{
		scripted.ToolCall("push", `{"n":7}`, "publish the counter"),
		scripted.Complete(`{"pushed":7}`),
	}}
	d := must(agentrt.NewDriver(agentrt.Config{
		Store: store, Agent: agent, Policy: agentrt.DefaultPolicy(),
		Tools: []agentrt.Tool{newExampleTool("push", agentrt.RemoteMutation)},
		NewID: exampleIDs(),
	}))
	run := must(d.Start(ctx, "publish the counter", agentrt.DefaultLimits()))
	fmt.Println(run.Status)

	a := must(store.ListApprovals(ctx, run.ID))[0]
	fmt.Println(a.ID, a.Kind, a.Status, string(a.Presentation))

	if err := d.Approve(ctx, run.ID, a.ID, "joey", "looks right"); err != nil {
		panic(err)
	}
	run = must(d.Resume(ctx, run.ID))
	fmt.Println(run.Status, run.Reason, string(run.Result))
}
Output:
WAITING_FOR_APPROVAL
id-3 remote_mutation pending {"args":{"n":7},"tool":"push"}
COMPLETED goal_completed {"pushed":7}

func (*Driver) Start

func (d *Driver) Start(ctx context.Context, goal string, limits Limits) (Run, error)

Start creates a run and executes it until it reaches a terminal status or pauses for approval. The returned run reflects the persisted state.

func (*Driver) StartWithID

func (d *Driver) StartWithID(ctx context.Context, id, goal string, limits Limits) (out Run, err error)

StartWithID is Start with a caller-chosen run id, so a consumer can key its own tables by the same identifier. The id must be unique.

type ErrAbortRun

type ErrAbortRun struct {
	Detail string
}

ErrAbortRun may be returned by a tool to end the run as FAILED with reason tool_abort. It is for conditions that make continuing pointless, such as a frozen proposal found invalid at publication.

func (ErrAbortRun) Error

func (e ErrAbortRun) Error() string

type ErrInsecureMode added in v0.3.0

type ErrInsecureMode struct {
	Path   string
	Mode   fs.FileMode
	Reason string
}

ErrInsecureMode is returned by both openers for a database, or a WAL file beside it, that another user could write: anyone who can write the file can forge an approval, because an approval's hash has no secret in it. It names the file and its mode. A sidecar owned by another user than the database is refused the same way. The rules apply where file modes mean owner, group, and others, which excludes Windows.

func (ErrInsecureMode) Error added in v0.3.0

func (e ErrInsecureMode) Error() string

type ErrLimit

type ErrLimit struct {
	Reason TerminalReason
	Detail string
}

ErrLimit is returned by a ModelCaller when a run limit would be exceeded.

func (ErrLimit) Error

func (e ErrLimit) Error() string

type ErrModelUnavailable

type ErrModelUnavailable struct {
	Attempts int
	Last     error
}

ErrModelUnavailable is returned when every attempt failed.

func (ErrModelUnavailable) Error

func (e ErrModelUnavailable) Error() string

func (ErrModelUnavailable) Unwrap

func (e ErrModelUnavailable) Unwrap() error

type ErrRunLeased added in v0.3.0

type ErrRunLeased struct {
	RunID     string
	Owner     string
	ExpiresAt time.Time
}

ErrRunLeased is returned by Resume for a RUNNING run whose lease is held by another owner and has not expired, or that this driver is executing in another call: the run is being executed elsewhere, and Resume changes nothing. It matches ErrRunState with errors.Is. A loop finds it too when another owner took its run over.

func (ErrRunLeased) Error added in v0.3.0

func (e ErrRunLeased) Error() string

func (ErrRunLeased) Unwrap added in v0.3.0

func (e ErrRunLeased) Unwrap() error

type Event

type Event struct {
	Seq     int64
	RunID   string
	StepID  string
	At      time.Time
	Type    string
	Payload json.RawMessage
}

Event is one row of the append-only audit log.

type Limits

type Limits struct {
	// MaxSteps is the maximum number of steps a run may start.
	MaxSteps int `json:"max_steps"`
	// MaxConsecutiveToolFailures ends the run when this many consecutive steps
	// produced a failure observation (tool error, policy denial, or an invalid
	// decision).
	MaxConsecutiveToolFailures int `json:"max_consecutive_tool_failures"`
	// LoopThreshold ends the run when the same tool call with the same
	// arguments has produced the same observation this many times in a row.
	// Repeating a call whose result changed is progress and is allowed.
	LoopThreshold int `json:"loop_threshold"`
	// MaxModelCalls counts every attempt, including retries and ambiguous
	// ones. Zero means unlimited.
	MaxModelCalls int `json:"max_model_calls,omitempty"`
	// MaxOutputTokensPerCall caps each request's output. Zero leaves the
	// agent's value. A token or cost limit projects each call's output at
	// its cap, so under either limit a request must carry a cap, from this
	// field or its own MaxOutputTokens; one with neither is refused before
	// dispatch as limit_tokens or limit_cost, because it cannot be bounded.
	MaxOutputTokensPerCall int `json:"max_output_tokens_per_call,omitempty"`
	// MaxTotalTokens and MaxEstimatedCost are estimated limits: they are
	// checked before each call against totals plus a conservative estimate
	// for the call. Zero means unlimited.
	MaxTotalTokens   int    `json:"max_total_tokens,omitempty"`
	MaxEstimatedCost Micros `json:"max_estimated_cost_micros,omitempty"`
	// MaxActiveTime bounds the sum of step durations while RUNNING;
	// approval waits are excluded. MaxElapsedTime is an absolute deadline
	// from creation, including waits. ApprovalTTL bounds how long an
	// approval may stay pending: once it passes, the approval expires and
	// the run is cancelled when it is next touched. It does not bound an
	// approval already decided. GrantTTL bounds how long an approved grant
	// may wait for Resume, measured from its decision: Resume finding a
	// grant older than that expires it and cancels the run instead of
	// executing it. Zero disables each; a run stored before GrantTTL
	// existed decodes it as zero.
	MaxActiveTime  time.Duration `json:"max_active_time,omitempty"`
	MaxElapsedTime time.Duration `json:"max_elapsed_time,omitempty"`
	ApprovalTTL    time.Duration `json:"approval_ttl,omitempty"`
	GrantTTL       time.Duration `json:"grant_ttl,omitempty"`
}

Limits are the caps the driver enforces before each step, and before each model call for the model limits. A zero optional limit is unlimited.

func DefaultLimits

func DefaultLimits() Limits

DefaultLimits returns conservative defaults.

type Message

type Message struct {
	Role    string         `json:"role"` // user or assistant
	Content []ContentBlock `json:"content"`
}

Message is one turn of the conversation the agent renders for the model.

type Micros

type Micros int64

Micros is money in millionths of a currency unit.

type Model

type Model interface {
	// Name identifies the model for accounting and pricing.
	Name() string
	Generate(ctx context.Context, req ModelRequest) (ModelResponse, error)
}

Model generates responses. Implementations are provider adapters; the runtime wraps every Model in a ModelCaller and agents only see that.

type ModelCall

type ModelCall struct {
	ID           string
	RunID        string
	StepID       string
	Attempt      int
	Status       ModelCallStatus
	Model        string
	Usage        Usage
	Cost         Micros
	Latency      time.Duration
	Error        string
	DispatchedAt time.Time
	CompletedAt  time.Time
}

ModelCall is one persisted attempt.

type ModelCallStatus

type ModelCallStatus string

ModelCallStatus is the outcome of one attempt.

const (
	CallDispatched ModelCallStatus = "dispatched"
	CallOK         ModelCallStatus = "ok"
	CallError      ModelCallStatus = "error"
	// CallAmbiguous means the request was sent and no answer came back, so
	// the provider may or may not have charged for it. It counts as a call
	// and is charged conservatively.
	CallAmbiguous ModelCallStatus = "ambiguous"
)

type ModelCaller

type ModelCaller interface {
	Generate(ctx context.Context, req ModelRequest) (ModelResponse, error)
}

ModelCaller is the agent's only handle to the model. It enforces limits before every call, records every attempt, and applies prices.

type ModelConfig

type ModelConfig struct {
	Model       Model
	Prices      PriceTable
	CallTimeout time.Duration // per attempt; default 2 minutes
	MaxRetries  int           // transient retries per Generate; zero means none
	Backoff     time.Duration // base backoff; default 1 second
	// MaxRetryAfter is the longest TransientError.RetryAfter honoured;
	// default DefaultMaxRetryAfter. A provider that asks for longer ends
	// the retries as model_unavailable rather than being retried early.
	MaxRetryAfter time.Duration
}

ModelConfig configures the wrapper.

type ModelRequest

type ModelRequest struct {
	System          string     `json:"system,omitempty"`
	Messages        []Message  `json:"messages"`
	Tools           []ToolSpec `json:"tools,omitempty"`
	MaxOutputTokens int        `json:"max_output_tokens,omitempty"`
}

ModelRequest is one generation request.

type ModelResponse

type ModelResponse struct {
	Text       string          `json:"text,omitempty"`
	ToolUses   []ToolUse       `json:"tool_uses,omitempty"`
	StopReason string          `json:"stop_reason,omitempty"`
	Usage      Usage           `json:"usage"`
	Raw        json.RawMessage `json:"raw,omitempty"`
}

ModelResponse is one generation result.

type Observation

type Observation struct {
	Kind    ObservationKind `json:"kind"`
	Content json.RawMessage `json:"content,omitempty"`
	Summary string          `json:"summary,omitempty"`
	// ContentHash is the SHA-256 of the canonical JSON content. Loop
	// detection keys on it with the tool and its arguments.
	ContentHash string `json:"content_hash,omitempty"`
}

Observation is what a step produced for the agent to consider next.

func (Observation) Failure

func (o Observation) Failure() bool

Failure reports whether the observation counts toward the consecutive failure limit.

type ObservationKind

type ObservationKind string

ObservationKind classifies what the agent sees after a step.

const (
	ObserveToolResult      ObservationKind = "tool_result"
	ObserveToolError       ObservationKind = "tool_error"
	ObservePolicyDenied    ObservationKind = "policy_denied"
	ObserveInvalidDecision ObservationKind = "invalid_decision"
	ObserveInterrupted     ObservationKind = "interrupted"
)

type Observer

type Observer func(Event)

Observer receives every event after it has been committed.

type Policy

type Policy interface {
	Evaluate(ctx context.Context, req ToolRequest, view RunView) (PolicyDecision, error)
}

Policy decides whether a validated tool request may execute. It is evaluated by the runtime and the agent has no influence over it.

type PolicyDecision

type PolicyDecision struct {
	Outcome PolicyOutcome `json:"outcome"`
	Reason  string        `json:"reason,omitempty"`
	// Kind, Capability, and Presentation apply to require_approval: the run
	// pauses on an approval that records them, hash-bound to the request,
	// and Resume executes the request once it is approved.
	Kind         string          `json:"kind,omitempty"`
	Capability   json.RawMessage `json:"capability,omitempty"`
	Presentation json.RawMessage `json:"presentation,omitempty"`
}

PolicyDecision is the policy's answer for one request.

func NeedApproval added in v0.2.0

func NeedApproval(kind, reason string, capability, presentation any) (PolicyDecision, error)

NeedApproval builds a require_approval decision, marshalling the capability an operator would be granting and the presentation they are shown. Both enter the approval's hash, so a changed presentation cannot inherit an old approval. It is named for what a policy does rather than for the RequireApproval outcome it carries.

Example

NeedApproval builds the require_approval decision a policy returns, and DecodePresentation reads back what the operator was shown.

package main

import (
	"context"
	"fmt"
	"time"

	agentrt "github.com/joeylking/agent-runtime"
	"github.com/joeylking/agent-runtime/scripted"
)

// exampleTool answers with the arguments it was given. The examples are about
// the controls around a call, not about what a tool does.
type exampleTool struct{ spec agentrt.ToolSpec }

func (t exampleTool) Spec() agentrt.ToolSpec { return t.spec }

func (t exampleTool) Call(_ context.Context, c agentrt.ToolCall) (agentrt.ToolResult, error) {
	return agentrt.ToolResult{Content: c.Args, Summary: "echoed " + t.spec.Name}, nil
}

func newExampleTool(name string, se agentrt.SideEffect) exampleTool {
	return exampleTool{spec: agentrt.ToolSpec{
		Name: name, Description: name, SideEffect: se, Timeout: 2 * time.Second,
		InputSchema: []byte(`{"type":"object","properties":{"n":{"type":"integer"}},"additionalProperties":false}`),
	}}
}

// exampleIDs numbers ids in the order the driver asks for them, so an
// example's output does not change from run to run.
func exampleIDs() func() string {
	n := 0
	return func() string {
		n++
		return fmt.Sprintf("id-%d", n)
	}
}

func must[T any](v T, err error) T {
	if err != nil {
		panic(err)
	}
	return v
}

// release is what this policy shows an operator. It is the presentation, so it
// enters the approval's hash and a changed presentation cannot inherit an old
// approval.
type release struct {
	Repo  string `json:"repo"`
	Files int    `json:"files"`
}

func main() {
	ctx := context.Background()
	store := must(agentrt.OpenStore(":memory:"))
	defer store.Close()

	policy := agentrt.PolicyFunc(func(_ context.Context, req agentrt.ToolRequest, _ agentrt.RunView) (agentrt.PolicyDecision, error) {
		if req.Spec.SideEffect == agentrt.ReadOnly {
			return agentrt.PolicyDecision{Outcome: agentrt.Allow, Reason: "reads are allowed"}, nil
		}
		return agentrt.NeedApproval("release", "publishing is an operator's decision",
			map[string]string{"tool": req.Spec.Name},
			release{Repo: "joeylking/repo-steward", Files: 3})
	})
	agent := &scripted.Agent{Decisions: []agentrt.Decision{
		scripted.ToolCall("push", `{"n":7}`, "publish the counter"),
	}}
	d := must(agentrt.NewDriver(agentrt.Config{
		Store: store, Agent: agent, Policy: policy,
		Tools: []agentrt.Tool{newExampleTool("push", agentrt.RemoteMutation)},
		NewID: exampleIDs(),
	}))
	run := must(d.Start(ctx, "publish the counter", agentrt.DefaultLimits()))

	a := must(store.ListApprovals(ctx, run.ID))[0]
	shown := must(agentrt.DecodePresentation[release](a))
	fmt.Println(run.Status, a.Kind, string(a.Capability))
	fmt.Println(shown.Repo, shown.Files)
}
Output:
WAITING_FOR_APPROVAL release {"tool":"push"}
joeylking/repo-steward 3

type PolicyFunc

type PolicyFunc func(ctx context.Context, req ToolRequest, view RunView) (PolicyDecision, error)

PolicyFunc adapts a function to the Policy interface.

func (PolicyFunc) Evaluate

func (f PolicyFunc) Evaluate(ctx context.Context, req ToolRequest, view RunView) (PolicyDecision, error)

Evaluate implements Policy.

type PolicyOutcome

type PolicyOutcome string

PolicyOutcome is what the policy decided about a tool request.

const (
	Allow           PolicyOutcome = "allow"
	RequireApproval PolicyOutcome = "require_approval"
	Deny            PolicyOutcome = "deny"
	Abort           PolicyOutcome = "abort"
)

type Price

type Price struct {
	InputPerMTok       Micros `json:"input_per_mtok"`
	OutputPerMTok      Micros `json:"output_per_mtok"`
	CachedInputPerMTok Micros `json:"cached_input_per_mtok"`
}

Price is a model's price per million tokens.

type PriceTable

type PriceTable map[string]Price

PriceTable maps model names to prices. A model absent from the table costs nothing, which is the right answer for local models and the wrong one for paid ones, so consumers pass a table for every paid model.

type ReconcileOutcome

type ReconcileOutcome string

ReconcileOutcome is what a consumer's reconciliation concluded about a run found mid-step.

const (
	// ReconcileContinue means the loop may proceed with the next decision.
	ReconcileContinue ReconcileOutcome = "continue"
	// ReconcileCompleted means the interrupted operation had already
	// succeeded; the run completes with Result and no further decision.
	ReconcileCompleted ReconcileOutcome = "completed"
	// ReconcileWaiting means the interrupted request must wait for an
	// approval again. Reconciliation.Pause carries the require_approval
	// decision to pause on; the interrupted step returns to
	// awaiting_approval with a new hash-bound approval, and Resume executes
	// it once approved. Without Pause, or with no interrupted tool call to
	// pause, the run fails as reconcile_conflict.
	ReconcileWaiting ReconcileOutcome = "waiting"
	// ReconcileConflict means external state contradicts the record; the
	// run fails with reconcile_conflict and Detail.
	ReconcileConflict ReconcileOutcome = "conflict"
)

type Reconciliation

type Reconciliation struct {
	Outcome ReconcileOutcome
	Result  json.RawMessage
	Detail  string
	// Pause applies to ReconcileWaiting: the require_approval decision the
	// interrupted request pauses on, as a policy would return it.
	Pause *PolicyDecision
}

Reconciliation is the structured result of a consumer's reconciliation. An Outcome outside the four above fails the run as internal_error.

type Run

type Run struct {
	ID           string
	Goal         string
	Status       RunStatus
	Reason       TerminalReason
	ReasonDetail string
	Limits       Limits
	// StepCount is the number of steps started, including the current one.
	StepCount  int
	CreatedAt  time.Time
	StartedAt  time.Time
	FinishedAt time.Time
	// Result is the payload of a Complete decision, if any.
	Result json.RawMessage
	// Model accounting totals across every attempt.
	ModelCalls    int
	Usage         Usage
	EstimatedCost Micros
	// ActiveTime is the sum of finished step durations.
	ActiveTime time.Duration
}

Run is the persisted state of one execution.

type RunStatus

type RunStatus string

RunStatus is the lifecycle state of a run.

const (
	StatusRunning            RunStatus = "RUNNING"
	StatusWaitingForApproval RunStatus = "WAITING_FOR_APPROVAL"
	// StatusInterrupted is not written by the runtime: a run interrupted
	// mid-step stays RUNNING, and Resume finds it by its lease. Resume and
	// Cancel accept a run in it as they accept a RUNNING one.
	StatusInterrupted RunStatus = "INTERRUPTED"
	StatusCompleted   RunStatus = "COMPLETED"
	StatusFailed      RunStatus = "FAILED"
	StatusCancelled   RunStatus = "CANCELLED"
)

func (RunStatus) Terminal

func (s RunStatus) Terminal() bool

Terminal reports whether no further steps can occur for a run in this status.

type RunView

type RunView struct {
	Run       Run
	Steps     []Step
	Approvals []Approval
}

RunView is the read-only view a policy may consult.

type ServedError added in v0.3.0

type ServedError struct {
	Usage Usage
	Err   error
}

ServedError is returned by a Model when the provider served the request, and so billed it, but the response could not be used: it would not decode, or carried a tool call whose arguments are not JSON. Usage is what the provider reported, and the accounting caller charges it; without this an unusable reply would cost nothing on the books.

func (ServedError) Error added in v0.3.0

func (e ServedError) Error() string

func (ServedError) Unwrap added in v0.3.0

func (e ServedError) Unwrap() error

type SideEffect

type SideEffect string

SideEffect classifies what a tool may do. Policy is evaluated on it.

const (
	ReadOnly       SideEffect = "read_only"
	LocalMutation  SideEffect = "local_mutation"
	RemoteMutation SideEffect = "remote_mutation"
	Destructive    SideEffect = "destructive"
)

type SideEffectPolicy

type SideEffectPolicy map[SideEffect]PolicyOutcome

SideEffectPolicy maps each side-effect class to an outcome. It is the runtime's only built-in policy; consumers wrap or replace it.

func DefaultPolicy

func DefaultPolicy() SideEffectPolicy

DefaultPolicy allows reads and local mutations, requires approval for remote mutations, and denies destructive tools.

Example

DefaultPolicy denies a destructive tool, and the denial is an observation the agent must work around rather than an error: the run continues until the consecutive-failure limit ends it.

package main

import (
	"context"
	"fmt"
	"time"

	agentrt "github.com/joeylking/agent-runtime"
	"github.com/joeylking/agent-runtime/scripted"
)

// exampleTool answers with the arguments it was given. The examples are about
// the controls around a call, not about what a tool does.
type exampleTool struct{ spec agentrt.ToolSpec }

func (t exampleTool) Spec() agentrt.ToolSpec { return t.spec }

func (t exampleTool) Call(_ context.Context, c agentrt.ToolCall) (agentrt.ToolResult, error) {
	return agentrt.ToolResult{Content: c.Args, Summary: "echoed " + t.spec.Name}, nil
}

func newExampleTool(name string, se agentrt.SideEffect) exampleTool {
	return exampleTool{spec: agentrt.ToolSpec{
		Name: name, Description: name, SideEffect: se, Timeout: 2 * time.Second,
		InputSchema: []byte(`{"type":"object","properties":{"n":{"type":"integer"}},"additionalProperties":false}`),
	}}
}

// exampleIDs numbers ids in the order the driver asks for them, so an
// example's output does not change from run to run.
func exampleIDs() func() string {
	n := 0
	return func() string {
		n++
		return fmt.Sprintf("id-%d", n)
	}
}

func must[T any](v T, err error) T {
	if err != nil {
		panic(err)
	}
	return v
}

func main() {
	ctx := context.Background()
	store := must(agentrt.OpenStore(":memory:"))
	defer store.Close()

	agent := &scripted.Agent{Decisions: []agentrt.Decision{
		scripted.ToolCall("wipe", `{"n":1}`, "clear the workspace"),
	}}
	d := must(agentrt.NewDriver(agentrt.Config{
		Store: store, Agent: agent, Policy: agentrt.DefaultPolicy(),
		Tools: []agentrt.Tool{newExampleTool("wipe", agentrt.Destructive)},
		NewID: exampleIDs(),
	}))
	run := must(d.Start(ctx, "clear the workspace", agentrt.Limits{
		MaxSteps: 5, MaxConsecutiveToolFailures: 1, LoopThreshold: 3,
	}))

	steps := must(store.ListSteps(ctx, run.ID))
	fmt.Println(run.Status, run.Reason)
	fmt.Println(steps[0].Policy.Outcome, steps[0].Policy.Reason)
	fmt.Println(steps[0].Observation.Kind)
}
Output:
FAILED repeated_tool_failures
deny side effect destructive is deny by policy
policy_denied

func (SideEffectPolicy) Evaluate

Evaluate implements Policy. A side-effect class with no mapping is denied.

type Step

type Step struct {
	ID          string
	RunID       string
	Index       int
	Status      StepStatus
	Decision    *Decision
	Policy      *PolicyDecision
	Observation *Observation
	StartedAt   time.Time
	FinishedAt  time.Time
	// DecodeError is set when a stored column of the step could not be
	// decoded; the fields it would have filled are left empty. It lets a
	// damaged row be listed, shown, and cancelled rather than failing every
	// read of its run, and a driver refuses to continue such a run.
	DecodeError string
}

Step is one iteration of the loop: a decision and what it caused.

type StepInput

type StepInput struct {
	Run       Run
	Steps     []Step
	Approvals []Approval
	Tools     []ToolSpec
	// Model is the agent's only handle to the model. It is nil when the
	// driver was configured without one, as for scripted agents.
	Model ModelCaller
}

StepInput is everything the agent receives when asked to decide. Steps are all prior steps in order; the agent is stateless between calls.

type StepStatus

type StepStatus string

StepStatus is the state of one step.

const (
	StepDeciding         StepStatus = "deciding"
	StepAwaitingApproval StepStatus = "awaiting_approval"
	StepExecuting        StepStatus = "executing"
	StepDone             StepStatus = "done"
	StepFailed           StepStatus = "failed"
	StepInterrupted      StepStatus = "interrupted"
)

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store persists runs, steps, and events in SQLite. It is the single implementation; there is no store interface until a second one is needed.

A Store opened on ":memory:" lives for the life of the connection and is meant for unit tests. A file-backed store uses WAL mode and survives process restarts.

func OpenExisting added in v0.3.0

func OpenExisting(path string, readOnly bool) (*Store, error)

OpenExisting opens the database at path without creating or migrating it, which is what an operator's tool wants: a mistyped path is an error, not a new empty database, and a newer binary does not change the schema under the consumer that owns the file. readOnly opens it for reading only; a read-only store never writes to the file, nor changes its mode.

The path is the file itself: a symbolic link is refused with ErrSymlink rather than followed, so what is opened is what the operator named. The mode rules and the trigger and view refusal are OpenStore's.

func OpenStore

func OpenStore(path string) (*Store, error)

OpenStore opens or creates the database at path and applies migrations. Concurrent first opens are safe: the version is read and the migrations applied inside one IMMEDIATE transaction. A database migrated by a newer build is refused with ErrSchemaVersion, one holding a trigger or a view with ErrUnsafeSchema.

A new database is created readable and writable by its owner only, and SQLite gives its WAL files the database's mode. An existing one that group or others can write is refused with ErrInsecureMode; one they can only read is made owner-only, WAL files included, when this process owns it.

func (*Store) Close

func (s *Store) Close() error

Close releases the database.

func (*Store) DB

func (s *Store) DB() *sql.DB

DB exposes the underlying database for consumers that keep their own tables in the same file and for tests. The runtime's tables are its own.

The handle shares the store's single connection. A transaction a consumer opens on it holds that connection until it commits or rolls back, so it must not call Store methods or the driver meanwhile. Write transactions begin IMMEDIATE and wait on the busy timeout for another process's writer rather than failing when they upgrade from a read.

Holding the connection, with a transaction or with rows not yet closed, stalls every write the loop makes. The lease heartbeat of a file-backed store renews on a connection of its own, but a loop stalled past its lease's expiry forfeits the run: it starts no tool or model call once the expiry has passed, and another process may take the run over.

func (*Store) GetApproval

func (s *Store) GetApproval(ctx context.Context, runID, id string) (Approval, error)

GetApproval loads one approval of a run.

func (*Store) GetRun

func (s *Store) GetRun(ctx context.Context, id string) (Run, error)

GetRun loads a run by id.

func (*Store) GetRunCapped added in v0.3.0

func (s *Store) GetRunCapped(ctx context.Context, id string) (Run, error)

GetRunCapped is GetRun read the way ListRunsPage reads a run: every text column capped at MaxPageText, and limits or times that do not decode read as zero. It is what a front end showing one run of a database it may not trust reads; the driver never uses it.

func (*Store) ListApprovals

func (s *Store) ListApprovals(ctx context.Context, runID string) ([]Approval, error)

ListApprovals returns a run's approvals in creation order.

func (*Store) ListApprovalsPage added in v0.3.0

func (s *Store) ListApprovalsPage(ctx context.Context, runID string, status ApprovalStatus, limit, offset int) ([]Approval, int, error)

ListApprovalsPage is ListApprovals bounded the same way: at most limit approvals of a run in creation order after skipping offset, only those with the given status unless status is empty, and how many there are. Capability and presentation are capped as JSON columns; a request over MaxPageText is read as its run, step, and tool name with its arguments replaced by {"truncated":true,"length":n}, and DecodeError says so. An approval read this way is for showing: to decide one, read it with GetApproval, whose hash the decision is bound to.

func (*Store) ListEvents

func (s *Store) ListEvents(ctx context.Context, runID string) ([]Event, error)

ListEvents returns the events of a run in sequence order.

func (*Store) ListEventsPage added in v0.3.0

func (s *Store) ListEventsPage(ctx context.Context, runID string, limit, offset int) ([]Event, int, error)

ListEventsPage is ListEvents bounded the same way: at most limit events of a run in sequence order after skipping offset, each text column capped at MaxPageText and a time that does not parse read as zero, and the run's number of events.

func (*Store) ListModelCalls

func (s *Store) ListModelCalls(ctx context.Context, runID string) ([]ModelCall, error)

ListModelCalls returns a run's model call attempts in dispatch order.

func (*Store) ListRuns

func (s *Store) ListRuns(ctx context.Context) ([]Run, error)

ListRuns returns every run, newest first. Order is insertion order: stored timestamps drop trailing zeros and do not sort as text.

func (*Store) ListRunsPage added in v0.3.0

func (s *Store) ListRunsPage(ctx context.Context, limit, offset int) ([]Run, int, error)

ListRunsPage is ListRuns for a front end reading a database it may not trust: at most limit runs, newest first, after skipping offset, with every text column capped at MaxPageText, and the number of runs in the database. Limits that do not decode, or that were capped, read as zero, and so does a time that does not parse, rather than failing the page.

func (*Store) ListSteps

func (s *Store) ListSteps(ctx context.Context, runID string) ([]Step, error)

ListSteps returns the steps of a run in index order.

func (*Store) ListStepsPage added in v0.3.0

func (s *Store) ListStepsPage(ctx context.Context, runID string, limit, offset int) ([]Step, int, error)

ListStepsPage is ListSteps bounded the same way: at most limit steps of a run in index order after skipping offset, and the run's number of steps. A decision, policy, or observation column over MaxPageText is not read whole: only the decision's kind and tool, the policy's outcome, or the observation's kind and summary are, and DecodeError names the column and its length. A page is for showing; the driver never reads one.

func (*Store) PendingApprovalIDs added in v0.3.0

func (s *Store) PendingApprovalIDs(ctx context.Context) (map[string][]string, error)

PendingApprovalIDs maps each run waiting for approval to the ids of its pending approvals, in creation order, in one query. It is what a listing of many runs needs without reading every approval's request.

func (*Store) PendingApprovalIDsOf added in v0.3.0

func (s *Store) PendingApprovalIDsOf(ctx context.Context, runIDs []string, max int) (map[string][]string, error)

PendingApprovalIDsOf is PendingApprovalIDs for the named runs only, with at most max ids per run, each capped at MaxPageText: what a page of runs needs to say which approval each is waiting on, bounded by the page.

type TerminalReason

type TerminalReason string

TerminalReason explains why a run reached a terminal status. Reasons are generic to the runtime; consumers record their own outcome codes separately.

const (
	ReasonGoalCompleted        TerminalReason = "goal_completed"
	ReasonGoalFailed           TerminalReason = "goal_failed"
	ReasonLimitSteps           TerminalReason = "limit_steps"
	ReasonLimitModelCalls      TerminalReason = "limit_model_calls"
	ReasonLimitTokens          TerminalReason = "limit_tokens"
	ReasonLimitCost            TerminalReason = "limit_cost"
	ReasonLimitActiveTime      TerminalReason = "limit_active_time"
	ReasonLimitElapsedTime     TerminalReason = "limit_elapsed_time"
	ReasonApprovalExpired      TerminalReason = "approval_expired"
	ReasonModelUnavailable     TerminalReason = "model_unavailable"
	ReasonRepeatedToolFailures TerminalReason = "repeated_tool_failures"
	ReasonLoopDetected         TerminalReason = "loop_detected"
	ReasonPolicyAbort          TerminalReason = "policy_abort"
	ReasonToolAbort            TerminalReason = "tool_abort"
	ReasonApprovalRejected     TerminalReason = "approval_rejected"
	ReasonOperatorCancelled    TerminalReason = "operator_cancelled"
	ReasonReconcileConflict    TerminalReason = "reconcile_conflict"
	ReasonAgentError           TerminalReason = "agent_error"
	ReasonInternalError        TerminalReason = "internal_error"
)

type Tool

type Tool interface {
	Spec() ToolSpec
	Call(ctx context.Context, call ToolCall) (ToolResult, error)
}

Tool is a capability the agent may request. The runtime validates arguments against Spec().InputSchema and evaluates policy before Call is invoked.

type ToolCall

type ToolCall struct {
	RunID  string
	StepID string
	Args   json.RawMessage
}

ToolCall carries the driver-supplied execution identity into a tool.

type ToolRequest

type ToolRequest struct {
	RunID  string          `json:"run_id"`
	StepID string          `json:"step_id"`
	Spec   ToolSpec        `json:"spec"`
	Args   json.RawMessage `json:"args"`
}

ToolRequest is a schema-validated request handed to the policy.

type ToolResult

type ToolResult struct {
	Content json.RawMessage
	Summary string
}

ToolResult is what a tool returns on success.

type ToolSpec

type ToolSpec struct {
	Name        string          `json:"name"`
	Description string          `json:"description"`
	InputSchema json.RawMessage `json:"input_schema"`
	SideEffect  SideEffect      `json:"side_effect"`
	// Timeout bounds a single call. It is required.
	Timeout time.Duration `json:"timeout"`
	// Terminal tools complete the run when they succeed; their result
	// becomes the run result and no further decision is requested.
	Terminal bool `json:"terminal,omitempty"`
}

ToolSpec describes a tool to the runtime and to the agent.

type ToolUse

type ToolUse struct {
	ID   string          `json:"id"`
	Name string          `json:"name"`
	Args json.RawMessage `json:"args"`
}

ToolUse is a tool call the model asked for.

type TransientError

type TransientError struct {
	Err error
	// RetryAfter is how long the provider asked the caller to wait before
	// trying again, or zero when it did not say.
	RetryAfter time.Duration
}

TransientError marks a provider failure worth retrying, such as a rate limit or a 5xx response.

func (TransientError) Error

func (e TransientError) Error() string

func (TransientError) Unwrap

func (e TransientError) Unwrap() error

type Usage

type Usage struct {
	InputTokens       int `json:"input_tokens"`
	OutputTokens      int `json:"output_tokens"`
	CachedInputTokens int `json:"cached_input_tokens,omitempty"`
}

Usage is what the provider reported.

Directories

Path Synopsis
bench module
cmd
agentrt command
Command agentrt is the operator surface over a runs database: list runs, show one, print its event log, and approve, reject, or cancel.
Command agentrt is the operator surface over a runs database: list runs, show one, print its event log, and approve, reject, or cancel.
examples
scripted command
Command scripted runs one deterministic agent run against in-memory tools and prints the persisted event trace.
Command scripted runs one deterministic agent run against in-memory tools and prints the persisted event trace.
live module
mcp module
mcp module
Package providers holds the dependency-free helpers every provider adapter shares: transport and HTTP error classification, a bounded body read, an HTTP client with a size limit, a backstop timeout, and no redirects, the endpoint and usage checks, tool-use id synthesis, and price table helpers.
Package providers holds the dependency-free helpers every provider adapter shares: transport and HTTP error classification, a bounded body read, an HTTP client with a size limit, a backstop timeout, and no redirects, the endpoint and usage checks, tool-use id synthesis, and price table helpers.
anthropic module
ollama module
openai module
Package render is the generic half of a model-driven agent: it turns a run's recorded steps into a message list and a model response into a decision.
Package render is the generic half of a model-driven agent: it turns a run's recorded steps into a message list and a model response into a decision.
Package replay provides Model implementations that need no provider: a scripted model for unit tests, and a recorder and replayer pair so that a real model's responses can be captured once and served deterministically afterwards.
Package replay provides Model implementations that need no provider: a scripted model for unit tests, and a recorder and replayer pair so that a real model's responses can be captured once and served deterministically afterwards.
Package scripted provides an Agent that replays a fixed list of decisions.
Package scripted provides an Agent that replays a fixed list of decisions.
Package trace provides the two observers an operator wants while a run executes: aligned lines for a terminal and JSON Lines for a collector.
Package trace provides the two observers an operator wants while a run executes: aligned lines for a terminal and JSON Lines for a collector.
Package view builds read models over a store for operator surfaces: what runs exist, what a run did, and which approval is waiting.
Package view builds read models over a store for operator surfaces: what runs exist, what a run did, and which approval is waiting.

Jump to

Keyboard shortcuts

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