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

agentrt

package module
v0.5.0 Latest Latest
Warning

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

Go to latest
Published: Oct 8, 2026 License: MIT Imports: 30 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.
A record you can check Each step records which version of the tool it was decided against and, if the policy names itself, which policy decided it, and each event in the record is linked to the one before it. An operator can check that the record has not been altered, and can replay a finished run through a policy to see whether it would decide the same, which is also how a changed policy is tried against past runs. The check against alteration holds up to a point the operator has written down somewhere the database's writers cannot reach, since anyone who can write the database could relink the rest. The replay shows what the policy would answer, not what a tool would do.
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.

These controls do not depend on the runtime running the loop. A program that already has its own loop, from another agent kit or written by hand, can hand each action it wants to take to the runtime, which checks it, asks the policy, pauses for approval when needed, runs it, and records it, in the same way. The program decides what to try next, and the runtime decides whether it happens.

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 loop is not the point either, since any vendor's kit has one. The library includes one, and its controls also work for a loop you already have. It exists for the controls around each action.

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.
Gate The part of the runtime that takes each action a program's own loop proposes, applies the controls, runs the action, and records it.
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.5.0. It carries re-checkable decisions (item 10): each step records its tool's spec and the policy's identity, the events are hash-chained (agentrt verify), and a finished run can be replayed through a policy (Recheck). It adds a schema migration, so every older process must be stopped before the first open, and one behaviour change: a policy is handed the arguments and the schema in PolicyJSON form on every path. Both consumers still run on v0.4.0 until they are re-pinned. v0.4.0 added the gate, which lets a program with its own loop use the runtime's controls, the exported agentrt.CanonicalJSON, and a security fix for numbers that crashed schema validation, present in every release through v0.3.2. The nested module proxy, a local MCP proxy built on the gate, is tagged at v0.2.0 as an experiment. 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, the evaluation vocabulary, the test kit, event export, and the approval channel, shipped in v0.3.1, and both consumers run on them. 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, mcp/v0.2.1, export/otel/v0.1.2, and proxy/v0.2.0. On main each requires core v0.5.0; the providers' tags still pin v0.3.0, which v0.4.0 only adds to. bench/v0.1.1 has no requirements at all.

The current release, v0.5.0, carries item 10 of the roadmap: re-checkable decisions. It adds a schema migration, so every older process must be stopped before the first open, and one behaviour change: a policy is handed the arguments and the schema in one form, PolicyJSON, on every path. The consumers, repo-steward and casework, still run on v0.4.0 until they are re-pinned.

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.
  • docs/essays: the ideas written out for someone deciding whether to trust an agent with a real system, each claim linked to the test behind it.
  • 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, for a reply with no tool use that is longer than 500 bytes, and for a run with a step interrupted while deciding inside the recent-results window: the window now counts rendered steps, where v0.2.1 counted raw steps, so such a run renders differently.

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 ./bench ./export/otel ./proxy ./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.

Using the gate from your own loop

A program with its own loop gives the runtime the tools and the policy and hands it each decision. A Gate takes the place of the driver: it records, checks, and authorizes each proposed action, pauses for an approval when policy asks, and runs the tool itself, so an action runs only when the policy allows it, once per approval, however the loop is written. The gate is in v0.4.0. ADR 7 explains why it exists and what it does not cover, and the architecture describes the session and what an abandoned one leaves behind.

gate, _ := agentrt.NewGate(agentrt.GateConfig{
    Store: store, Policy: agentrt.DefaultPolicy(), Tools: tools,
})
s, _ := gate.Begin(ctx, "run-1", "goal", agentrt.DefaultLimits())
defer s.Close()
for !s.Done() {
    step, _ := s.Step(ctx)           // nil when a limit has ended the run
    if step == nil {
        break
    }
    v, _ := step.Propose(ctx, yourLoopDecides(s)) // an agentrt.Decision
    if v.Outcome == agentrt.VerdictAllowed {
        step.Execute(ctx)            // runs the allowed request, once
    }
    // VerdictPending: the run waits on an approval, and the session is over
}

// After an operator approves, in this process or another:
s, v, _ := gate.Attach(ctx, "run-1")
if v.Outcome == agentrt.VerdictAllowed {
    s.Current().Execute(ctx)         // exactly the request that was approved
}

Propose answers with a verdict: allowed, denied, pending, or ended. A denial is an observation and the loop continues. Session.Input returns what an agent would be handed, for a loop that decides from the run's record. testkit.Scenario.Loop runs the crash and resume harness over a loop like this one. ExampleGate in example_test.go is the complete, tested version.

The gate in front of an MCP host

A program that is not written in Go can still have its actions go through the gate, if it is an MCP host: an application that runs a model and gives it tools from MCP servers. agentrt-proxy is a small local program the host starts in place of those servers. It offers the host only the tools the operator classified and pinned, and every call the model makes passes the gate: it is recorded, checked, held to the policy, and executed only when the policy allows it or an operator approved that exact call. A call waiting for approval tells the model so in plain words, and the operator decides with agentrt, outside the host. Each call is its own run.

It governs only the calls that go through it. A host that also gives the model a shell or file access lets the model go around it, and even approve its own request. docs/proxy.md says what it does and does not do, and go run ./proxy/example shows it with no model at all. It is an experiment, released as proxy/v0.1.0; the Go library is the full form.

Re-checking a run

A finished run can be put in front of a policy again. agentrt.Recheck rebuilds each policy evaluation the run recorded, with the request, the tool's spec by hash, and the run as the policy saw it, asks the policy given, and reports where it decides differently. Under the policy the run ran with it is a determinism check; under a changed one it names the steps the change would have stopped. testkit.Recheck does this in a test, and agentrt-proxy recheck for the proxy's recorded calls. agentrt verify checks the events' hash chain, and export.Follower.Head gives a head to keep. A policy is handed the same form of the arguments and the schema (PolicyJSON) on the loop, on resume, and in a re-check. A run recorded before v0.5.0 is reported as not re-checkable. docs/architecture.md has the rules and the limits, ADR 9 the reasoning, and go run ./examples/recheck shows both checks with no model.

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>
go run ./cmd/agentrt -db runs.db verify           # the events' hash chain

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.
  • verify opens it read-only, checks the hash chain over every run's events, -from and -to a range of seq, and prints the head and "intact" with the seq and hash of the last event checked, or the first event that does not chain, exiting 1 for a break or a -to not reached. An intact chain means the events are unaltered only up to a head you kept somewhere the database's writers cannot reach: keep the last checked seq and hash, and pass them back as -head SEQ:HASH, which fails unless the chain still reaches that seq with that hash.
  • 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 ./bench ./export/otel ./proxy ./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.

Measuring, testing, watching, and approving from elsewhere

Four additions sit around the loop rather than in it. They shipped in v0.3.1.

  • Measuring an agent. bench is the vocabulary for scoring an agent that acts: each trial lands in exactly one outcome, every count is shown with what it was counted out of, and one unsafe outcome disqualifies the run's mode. Results are only compared within one commit. The scenarios and the judging stay yours. It is a nested module with no dependencies.
  • Testing without a model. testkit helps a consumer prove what the runtime promises about its own tools and policy: it crashes the loop at a chosen point and resumes it in a fresh driver, checks a table of policy decisions, fuzzes tool arguments against their schema, and checks that an agent renders the same model request every time. It is a core package.
  • Watching runs in a tool you already have. export follows a database's events from a separate process, read-only, and export/otel turns them into OpenTelemetry spans for whatever collector the OTEL_* environment names. The consumer needs no code. export is a core package; export/otel is a nested module because it brings OpenTelemetry.
  • Approving from a chat bot or a browser. approver is the interface an operator surface decides approvals through, and approver/webhook is a signed HTTP channel over it, so an approval can be granted without access to the database file. The decision is still bound to the request that was shown. Both are core packages.
go get github.com/joeylking/agent-runtime/bench
go get github.com/joeylking/agent-runtime/export/otel

Both resolve to the released tags. In a workspace of this repository they resolve to the tree.

// measuring: read result files, print the comparison, refusing mixed commits
files, _ := bench.ReadDir("results"); out, err := bench.Compare(bench.Latest(files), bench.CompareOptions{})

// testing: crash after the tool's effect, resume, and assert the contract held
res := testkit.Run(t, testkit.Scenario{Tools: tools, Policy: policy, Agent: agent}, testkit.Crash{Step: 0, At: testkit.AfterToolEffect})

// watching: follow the database from another process and send spans to the collector
//   OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 go run ./export/otel/example -db runs.db

// approving: serve the signed channel over the database; the secret is at least 32 bytes
h, _ := webhook.New(webhook.Config{Approver: approver.New(store, nil), Secret: secret}); http.ListenAndServe(addr, h)

The package docs say the rest: bench, testkit, export, export/otel, approver, and approver/webhook, which lists the routes, the signature, and what the channel does not do. examples/approver runs a scripted run to an approval and decides it through the webhook, with no model and no network beyond loopback:

go run ./examples/approver

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. A Gate holds the same controls for a loop the runtime does not own: the caller decides, and the gate records, checks, and executes each proposed action, as the Driver, which runs on it, does for its agent. 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 ErrApprovalExpired error = notPending("agentrt: approval has expired")

ErrApprovalExpired means a decision found the approval expired: its ApprovalTTL had passed by the deciding clock, so the decision expired it and cancelled the run as approval_expired, or it had been expired before. It matches ErrNotPending with errors.Is, which an expired approval has always returned, and the message is unchanged: it names the approval and, for one the decision expired, the expiry time.

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 ErrSessionClosed = errors.New("agentrt: session is closed")

ErrSessionClosed is returned by a call on a session that was closed, or that an earlier call ended with an error, which it wraps.

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 CanonicalJSON added in v0.4.0

func CanonicalJSON(raw json.RawMessage) ([]byte, error)

CanonicalJSON returns the form of a JSON value the runtime hashes: object keys in byte order, no insignificant whitespace, every number as its literal was written, and every string by its exact contents in one fixed escaping. Documents that differ only in key order, whitespace, or how a string's characters are escaped have the same form; documents that differ in any number's literal or any string's contents do not, so 10, 10.0, and 1e1 are three values. It refuses, with the reason, what the runtime refuses where consumer JSON enters: malformed JSON, nesting past 256 levels, invalid UTF-8, an escaped surrogate outside a pair, a repeated key, and a number past the bound. Everything it accepts it encodes without loss. Content and approval hashes are computed over this form, which TestContentHash_Golden pins.

func CheckArgs added in v0.3.1

func CheckArgs(spec ToolSpec, args json.RawMessage) error

CheckArgs is CompileTool(spec) then Check(args). The compiled schemas of the last few specs it was given are kept, so a loop over one spec does not recompile; a caller holding many specs keeps CompileTool's results. A spec NewDriver would refuse is an error, CompileTool's.

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 PolicyJSON added in v0.5.0

func PolicyJSON(raw json.RawMessage) (json.RawMessage, error)

PolicyJSON returns the form of a JSON value a policy is handed: object keys in byte order, no insignificant whitespace, every number as its literal was written, and every string by its exact contents, escaped only as JSON requires: '"', '\', and control characters. Unlike CanonicalJSON it leaves '<', '>', '&', U+2028, and U+2029 as they are, so a policy matching "&&" in an argument finds it. It refuses what CanonicalJSON refuses. Two documents with the same CanonicalJSON have the same PolicyJSON, so the form is the same whether it is made from what an agent wrote or from what the runtime stored, which is compact and HTML-escaped. Hashes are never computed over this form.

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 ChainBreak added in v0.5.0

type ChainBreak struct {
	Seq int64
	// Expected is the hash recomputed from the previous event's stored
	// hash and this event's stored fields; Found is the hash stored, cut
	// at 128 characters.
	Expected string
	Found    string
	// Detail says what is wrong in words the runtime chose.
	Detail string
}

ChainBreak is an event whose stored hash is not the one its fields and the previous event's stored hash give, or whose seq does not follow the previous event's.

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"`
	// Origin is who proposed the step, empty when unspecified. An empty
	// origin is not stored, so a decision without one is recorded exactly
	// as before Origin existed; a value outside the known set makes the
	// decision invalid.
	Origin DecisionOrigin `json:"origin,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 DecisionOrigin added in v0.4.0

type DecisionOrigin string

DecisionOrigin records who proposed a step. The runtime records it and attaches no behaviour to it: policy does not see it.

const (
	// OriginModel is a model's decision.
	OriginModel DecisionOrigin = "model"
	// OriginPlan is a step of a promoted deterministic plan.
	OriginPlan DecisionOrigin = "plan"
	// OriginOperator is a step an operator proposed.
	OriginOperator DecisionOrigin = "operator"
)

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.

The loop is the agent's decisions passed through a Session, the same one a Gate hands to a loop the runtime does not own; a Gate is a Driver with no agent.

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) (Run, 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 Gate added in v0.4.0

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

Gate holds the runtime's controls for a loop the runtime does not own, such as another agent framework's or a hand-written one. The caller decides; the gate records each decision, validates it, evaluates policy, pauses for approvals, enforces the limits, and runs the tools, with the same records, events, transactions, and clock readings as a Driver, which is a gate with an agent. A Gate is safe to reuse for many runs, and refuses a second session on a run it holds.

Example

A loop the runtime does not own proposes each action to a Gate, which records it, checks it, and runs what policy allows. A remote mutation pauses the run on an approval; once it is approved, Attach hands back exactly the approved request to execute, and the loop goes on.

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

	g := must(agentrt.NewGate(agentrt.GateConfig{
		Store: store, Policy: agentrt.DefaultPolicy(),
		Tools: []agentrt.Tool{newExampleTool("read", agentrt.ReadOnly), newExampleTool("push", agentrt.RemoteMutation)},
		NewID: exampleIDs(),
	}))
	// The external loop's decisions, by step index.
	plan := []agentrt.Decision{
		scripted.ToolCall("read", `{"n":1}`, "look first"),
		scripted.ToolCall("push", `{"n":7}`, "publish the counter"),
		scripted.Complete(`{"pushed":7}`),
	}

	s := must(g.Begin(ctx, "run-1", "publish the counter", agentrt.DefaultLimits()))
	var v agentrt.Verdict
	for v.Outcome != agentrt.VerdictPending {
		step := must(s.Step(ctx))
		v = must(step.Propose(ctx, plan[step.Index()]))
		fmt.Println(step.Index(), v.Outcome)
		if v.Outcome == agentrt.VerdictAllowed {
			fmt.Println(must(step.Execute(ctx)).Summary)
		}
	}
	fmt.Println(v.Run.Status, v.Approval.Kind)

	if err := agentrt.Approve(ctx, store, nil, "run-1", v.Approval.ID, "joey", "looks right"); err != nil {
		panic(err)
	}
	s, v, err := g.Attach(ctx, "run-1")
	if err != nil {
		panic(err)
	}
	defer s.Close()
	fmt.Println(v.Outcome, v.Request.Spec.Name, string(v.Request.Args))
	fmt.Println(must(s.Current().Execute(ctx)).Summary)

	step := must(s.Step(ctx))
	v = must(step.Propose(ctx, plan[step.Index()]))
	fmt.Println(step.Index(), v.Outcome, v.Run.Status, string(v.Run.Result))
}
Output:
0 allowed
echoed read
1 pending
WAITING_FOR_APPROVAL remote_mutation
allowed push {"n":7}
echoed push
2 ended COMPLETED {"pushed":7}

func NewGate added in v0.4.0

func NewGate(cfg GateConfig) (*Gate, error)

NewGate validates the configuration and compiles every tool schema.

func (*Gate) Attach added in v0.4.0

func (g *Gate) Attach(ctx context.Context, runID string) (*Session, Verdict, error)

Attach opens a session on an existing run, doing what Driver.Resume does before its loop. A WAITING run must have its awaiting step's latest approval approved; policy is re-evaluated, and the verdict on the recorded request is returned: Allowed leaves that step open, Current, for Execute, with the run still WAITING until Execute moves it to RUNNING, taking the lease, as it starts the tool. A RUNNING run whose lease is live and not this gate's, or that this gate holds in another session, is refused with ErrRunLeased and nothing changes. One whose lease has expired, or that has none, is taken over: the step in flight is marked interrupted, and GateConfig.Reconcile decides what follows; without it, a step interrupted while executing a tool that is not ReadOnly pauses as interrupted_side_effect. A verdict of Pending or Ended comes with a session that is already over; a zero verdict means the session is open with no step, ready for Step.

An Allowed verdict is the policy's answer at the time it was given, and GrantTTL is judged here against the grant's decision. The approved step may wait for Execute, which judges GrantTTL again, so a grant is never executed after it expired however long the caller waits.

func (*Gate) Begin added in v0.4.0

func (g *Gate) Begin(ctx context.Context, runID, goal string, limits Limits) (*Session, error)

Begin creates a run with a caller-chosen id, which must be unique, and opens a session on it, as Driver.StartWithID does before its first step: the run is RUNNING under this gate's lease, which the session holds until it ends or is closed.

type GateConfig added in v0.4.0

type GateConfig struct {
	Store    *Store
	Policy   Policy
	Tools    []Tool
	Observer Observer
	// Model, when set, is wrapped in the ModelCaller each step hands out
	// from OpenStep.Model, charged to that step and held to the run's
	// limits.
	Model *ModelConfig
	// Reconcile, when set, runs before Attach continues a run found
	// mid-step, as Config.Reconcile does for Resume.
	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 and LeaseOwner are Config's: the lease a session holds on
	// its run, renewed by a heartbeat at a third of the TTL, and the name
	// its owner id starts with. Lease times are read from the wall clock,
	// never from Now.
	LeaseTTL   time.Duration
	LeaseOwner string
}

GateConfig configures a Gate. It is Config without an Agent: the loop that decides belongs to the caller.

type IdentifiedPolicy added in v0.5.0

type IdentifiedPolicy interface {
	Policy
	PolicyID() string
}

IdentifiedPolicy is a Policy that names itself, so a recorded decision says which policy made it: a version, a digest of its rules, whatever lets an operator tell two policies apart. NewDriver and NewGate read the identity once, and refuse one longer than 256 bytes, not valid UTF-8, or holding a control character. It is recorded on each step the policy evaluated, as Step.PolicyID, and in its step.policy event. A policy that does not implement it is recorded with an empty identity.

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, or a Gate's
	// Attach or Execute, 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 OpenStep added in v0.4.0

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

OpenStep is a session's step in flight. Its decision is proposed once, and an Allowed one executed once; Fail ends it without a decision.

func (*OpenStep) Execute added in v0.4.0

func (st *OpenStep) Execute(ctx context.Context) (Observation, error)

Execute runs the request an Allowed verdict let through, with the tool's timeout, a panic observed as an error, and its observation recorded even when ctx is cancelled after the tool ran, in which case the cancellation is returned with it. The tool is not started once the lease is lost, and the write that starts it requires the lease unexpired. A terminal tool that succeeds completes the run, and one that returns ErrAbortRun fails it; Done then reports the session over and Run the run.

The Allowed verdict is the policy's answer when it was given. For a step Propose allowed, the gap until Execute is covered by the session's lease and by no other limit, exactly as an agent's slow Decide is: the time limits are checked at Step. For the approved step Attach allowed, which holds no lease, Execute judges Limits.GrantTTL again on a fresh reading of GateConfig.Now: a grant that expired since is expired as Attach would have expired it, the run cancelled as approval_expired, nothing executed, and the session ended with an error that matches ErrApprovalExpired.

func (*OpenStep) Fail added in v0.4.0

func (st *OpenStep) Fail(ctx context.Context, err error) (Verdict, error)

Fail ends a step whose decision could not be made, because the model calls behind it, or whatever else decides, returned err: the run fails with the limit's reason for an ErrLimit from Model, as model_unavailable for ErrModelUnavailable, and as agent_error otherwise, exactly as the Driver ends a run whose agent returned err. A cancelled ctx instead leaves the step in flight, for Attach to mark interrupted, and returns the cancellation.

func (*OpenStep) ID added in v0.4.0

func (st *OpenStep) ID() string

ID is the step's id, which its tool call carries as ToolCall.StepID.

func (*OpenStep) Index added in v0.4.0

func (st *OpenStep) Index() int

Index is the step's position in the run, from zero.

func (*OpenStep) Model added in v0.4.0

func (st *OpenStep) Model() ModelCaller

Model is the step's only handle to the model: every call is recorded, charged to this step, and held to the run's call, token, and cost limits before it is sent, and none is sent once the lease is lost. A limit refuses the call with ErrLimit and ends nothing by itself; pass the error to Fail to end the run with its reason. Model is nil when the gate has no GateConfig.Model, and for the approved step Attach returns, whose decision was made before.

func (*OpenStep) Propose added in v0.4.0

func (st *OpenStep) Propose(ctx context.Context, decision Decision) (Verdict, error)

Propose records a decision verbatim, as the Driver records its agent's, validates it, and acts on it up to, and not including, running a tool. A complete or fail decision ends the run (Ended). An invalid decision, or a tool call the policy denies, ends the step with a failure observation, which counts toward the consecutive failure limit, and the session continues (Denied). A tool call the policy aborts ends the run (Ended), and one that requires approval pauses it on a hash-bound approval (Pending). A tool call the policy allows leaves the step open for Execute (Allowed); nothing is written for it until Execute, so a step abandoned there is still deciding, and Attach marks it interrupted without running it.

type Operator added in v0.3.1

type Operator struct {
	Store    *Store
	Observer Observer
	Now      func() time.Time
}

Operator decides approvals and cancels runs without a driver, as the package-level Approve, Reject, ApproveShown, RejectShown, and Cancel do, stamping and judging expiry by Now. A channel's tests set Now to put an approval before or past its expiry; the zero Now is the wall clock, so Operator{Store: s, Observer: o}.Approve is Approve(ctx, s, o, ...).

func (Operator) Approve added in v0.3.1

func (o Operator) Approve(ctx context.Context, runID, approvalID, by, note string) error

Approve is the package-level Approve on o's clock.

func (Operator) ApproveShown added in v0.3.1

func (o Operator) ApproveShown(ctx context.Context, runID, approvalID, shownHash, by, note string) error

ApproveShown is the package-level ApproveShown on o's clock.

func (Operator) Cancel added in v0.3.1

func (o Operator) Cancel(ctx context.Context, runID, by, note string) error

Cancel is the package-level Cancel on o's clock.

func (Operator) Reject added in v0.3.1

func (o Operator) Reject(ctx context.Context, runID, approvalID, by, note string) error

Reject is the package-level Reject on o's clock.

func (Operator) RejectShown added in v0.3.1

func (o Operator) RejectShown(ctx context.Context, runID, approvalID, shownHash, by, note string) error

RejectShown is the package-level RejectShown on o's clock.

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. The request's arguments and its tool's input schema are handed in PolicyJSON form, the same bytes in the loop, on resume, and on a re-check, so a policy that reads those bytes, matching "&&" in an argument, say, decides alike on each. A policy that unmarshals them sees what the agent and the tool gave.

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 PolicyIdentity added in v0.5.0

type PolicyIdentity string

PolicyIdentity compares the identity a run's evaluations recorded with the identity of the policy they were re-checked under.

const (
	// IdentityUnknown means one side, or both, has no identity: the policy
	// does not implement IdentifiedPolicy, or the evaluation did not record
	// one. Whether the policy changed cannot be told from the record.
	IdentityUnknown PolicyIdentity = "unknown"
	// IdentityChanged means both sides have one and they differ.
	IdentityChanged PolicyIdentity = "changed"
	// IdentityUnchanged means every evaluation recorded the identity the
	// policy has now.
	IdentityUnchanged PolicyIdentity = "unchanged"
)

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 RecheckOptions added in v0.5.0

type RecheckOptions struct {
	// Tools, when set, are the tools as registered now: each request is
	// handed the spec its tool's Spec returns now, by name, in place of
	// the one recorded, and its arguments are checked against that spec's
	// schema as a fresh decision's are, so the report says whether today's
	// policy with today's tools would have decided the same. A request
	// naming a tool not among them, or whose arguments its schema now
	// refuses, differs: it would be refused before the policy. Nil hands
	// each request the spec recorded with it.
	Tools []Tool
}

RecheckOptions are what Recheck takes beyond the run and the policy.

type RecheckReport added in v0.5.0

type RecheckReport struct {
	RunID     string    `json:"run_id"`
	RunStatus RunStatus `json:"run_status"`
	// PolicyID is the identity of the policy re-checked under, empty when
	// it does not implement IdentifiedPolicy, and Identity compares it
	// with the identities the evaluations recorded.
	PolicyID string         `json:"policy_id"`
	Identity PolicyIdentity `json:"identity"`
	// SpecSource is SpecCurrent when RecheckOptions.Tools was set.
	SpecSource SpecSource `json:"spec_source"`
	// Notes say what holds for the whole run: one not terminal, re-checked
	// up to its current state, and one recorded before v0.5.0.
	Notes []string `json:"notes,omitempty"`
	// Evaluations are the run's step.policy events in the order they were
	// written.
	Evaluations []RecheckedEvaluation `json:"evaluations"`
}

RecheckReport is what Recheck found for one run.

func Recheck added in v0.5.0

func Recheck(ctx context.Context, store *Store, runID string, policy Policy, opts RecheckOptions) (RecheckReport, error)

Recheck replays each policy evaluation a run recorded through policy, in the order they were written, and reports, evaluation by evaluation, whether the policy decides the same now. An evaluation is a step.policy event carrying the view of the run its policy saw; a step that was resumed after an approval has one for the resume as well. For each it rebuilds what the policy was handed:

  • the request: the run and step ids, the arguments as the step's decision recorded them, and the spec the event names (Store.ToolSpec), or the current spec when RecheckOptions.Tools is set, the arguments and the schema in PolicyJSON form, made from the stored bytes, which is the form the loop handed the policy from the agent's;
  • the run: its id, goal, limits, and creation time, which is its start time, from run.created, and its status, step count, model calls, tokens, estimated cost, and active time from the event's view; the reason, detail, finish time, and result are empty, as they are while a policy can be asked;
  • the steps: the run's first ones, as many as the view counted, each as stored now. A step before the evaluated one is finished before the policy is asked and never written again, so it is what the policy saw;
  • the approvals: the run's first ones, as many as the view counted, each read whole and as stored but with the status it had when the event was written: the last approval.decided before the event, by seq, or pending, with no decision time, decider, or note, when there was none. An approval that has no approval.decided at all is judged by its stored decided_at against the event's time instead.

The policy's answer is compared with the recorded decision: the outcome and, for require_approval, the approval kind and the hash of the approval it asks for, computed as the runtime computes it, so a changed capability or presentation differs although the outcome does not. The reason is never compared. A policy error, or a decision whose capability or presentation is not usable JSON, is a difference, unless the recorded decision was refused as invalid for the same reason, with the same outcome and kind: that is the same, with the reason in Detail.

A step.policy written by a reconciliation's or an interrupted side effect's pause is reported as not a policy evaluation, and one written before v0.5.0, which recorded neither spec nor view, as not re-checkable, as is one whose record the page reads cut: a step.policy event or a step longer than MaxPageText, which an approval's large capability or presentation in the step.policy can make. A policy that failed recorded no evaluation: its step is listed as not re-checkable, "policy failed; no evaluation recorded", at the seq of its step.failed. A gate's Allowed verdict that was never executed, an approved request Attach let through whose grant then expired among them, recorded no evaluation either, and leaves nothing to list beyond the step. A run that is not terminal is re-checked up to its current state, and a note says so. Recheck writes nothing and reads no clock: it reads the run, its events and steps a page at a time, each text column capped at MaxPageText; the approvals the views counted whole, as GetApproval reads one, at most 1024 of them, an evaluation whose view counted more being not re-checkable; and each spec whole. It returns ErrNotFound for a run the store does not have, and an error for a policy whose identity NewDriver would refuse.

What it shows is limited by what the policy reads: a re-check under the policy the run ran with differs only where that policy decides from something other than what it is handed. It does not show that the record is unaltered; Store.VerifyEvents does, up to a head kept elsewhere.

Example

Recheck hands each decision a finished run recorded to another policy, rebuilt as the first policy saw it, and reports which it would have decided otherwise.

package main

import (
	"context"
	"fmt"
	"os"

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

// versioned names a SideEffectPolicy, which has no identity of its own, by
// the version of the configuration it was built from.
type versioned struct {
	agentrt.SideEffectPolicy
	id string
}

func (p versioned) PolicyID() string { return p.id }

// Recheck hands each decision a finished run recorded to another policy,
// rebuilt as the first policy saw it, and reports which it would have
// decided otherwise.
func main() {
	ctx := context.Background()
	store := must(agentrt.OpenStore(":memory:"))
	defer store.Close()

	lenient := versioned{agentrt.DefaultPolicy(), "policy v1"}
	agent := &scripted.Agent{Decisions: []agentrt.Decision{
		scripted.ToolCall("read", `{"n":1}`, ""),
		scripted.ToolCall("write", `{"n":2}`, ""),
		scripted.Complete(`{}`),
	}}
	d := must(agentrt.NewDriver(agentrt.Config{
		Store: store, Agent: agent, Policy: lenient, NewID: exampleIDs(),
		Tools: []agentrt.Tool{newExampleTool("read", agentrt.ReadOnly), newExampleTool("write", agentrt.LocalMutation)},
	}))
	run := must(d.Start(ctx, "read, then write", agentrt.DefaultLimits()))

	stricter := versioned{agentrt.SideEffectPolicy{agentrt.ReadOnly: agentrt.Allow}, "policy v2"}
	rep := must(agentrt.Recheck(ctx, store, run.ID, stricter, agentrt.RecheckOptions{}))
	fmt.Println(rep.Same())
	if err := trace.WriteRecheck(os.Stdout, rep); err != nil {
		panic(err)
	}
}
Output:
false
run id-1 COMPLETED: 1 of 2 re-checked evaluations differ; policy changed: recorded "policy v1", given "policy v2"; recorded specs
DIFFERENT step 1 seq 10 write: allow -> deny: no policy for side effect "local_mutation"
same      step 0 seq 5 read: allow

func (RecheckReport) Complete added in v0.5.0

func (r RecheckReport) Complete() bool

Complete reports whether every evaluation listed was re-checked or is not a policy evaluation: none is RecheckNotRecheckable. It does not say that anything was re-checked; a run with no evaluation is complete.

func (RecheckReport) Differences added in v0.5.0

func (r RecheckReport) Differences() int

Differences is how many evaluations differ.

func (RecheckReport) MarshalJSON added in v0.5.0

func (r RecheckReport) MarshalJSON() ([]byte, error)

MarshalJSON writes the report's fields with same, differences, rechecked, not_recheckable, and complete beside them, so a reader of the JSON need not count.

func (RecheckReport) NotRecheckable added in v0.5.0

func (r RecheckReport) NotRecheckable() int

NotRecheckable is how many evaluations the record could not rebuild.

func (RecheckReport) Rechecked added in v0.5.0

func (r RecheckReport) Rechecked() int

Rechecked is how many evaluations the policy was asked again: those the same and those different.

func (RecheckReport) Same added in v0.5.0

func (r RecheckReport) Same() bool

Same reports whether no evaluation re-checked differs. An evaluation that was not re-checked, because no policy made it or the record cannot rebuild it, is not a difference, so a report that re-checked nothing is the same: Rechecked says how many were, and Complete whether any was left out. A gate wants all three.

type RecheckResult added in v0.5.0

type RecheckResult string

RecheckResult is what one recorded step.policy came to.

const (
	// RecheckSame is an evaluation the policy answered alike: the same
	// outcome and, for require_approval, the same approval kind and hash.
	RecheckSame RecheckResult = "same"
	// RecheckDifferent is an evaluation it answered otherwise, or failed
	// to answer.
	RecheckDifferent RecheckResult = "different"
	// RecheckNotEvaluation is a step.policy no policy wrote: a
	// reconciliation's or an interrupted side effect's pause. There is
	// nothing to re-check.
	RecheckNotEvaluation RecheckResult = "not_a_policy_evaluation"
	// RecheckNotRecheckable is an evaluation the record cannot rebuild: one
	// recorded before v0.5.0, which kept neither the spec nor the view,
	// one whose record is damaged or larger than a page carries, or one
	// whose policy failed, which recorded no evaluation to rebuild.
	RecheckNotRecheckable RecheckResult = "not_recheckable"
)

type RecheckedEvaluation added in v0.5.0

type RecheckedEvaluation struct {
	// StepIndex, StepID, and Seq name the step and the event; Tool is the
	// tool its decision named. OnResume is an evaluation made when an
	// approved request was resumed, which is a line of its own beside the
	// step's first.
	StepIndex int    `json:"step_index"`
	StepID    string `json:"step_id"`
	Seq       int64  `json:"seq"`
	Tool      string `json:"tool"`
	OnResume  bool   `json:"on_resume,omitempty"`
	// Result is what it came to.
	Result RecheckResult `json:"result"`
	// Recorded is the decision recorded; Rechecked is the policy's answer
	// now, nil when it was not asked or failed.
	Recorded  PolicyDecision  `json:"recorded"`
	Rechecked *PolicyDecision `json:"rechecked,omitempty"`
	// RecordedHash and RecheckedHash are the hashes of the approvals the
	// two require_approval decisions ask for, over their kind,
	// capability, and presentation and the request each was made for:
	// the recorded request, and the request re-checked, which carries the
	// current spec under SpecCurrent.
	RecordedHash  string `json:"recorded_hash,omitempty"`
	RecheckedHash string `json:"rechecked_hash,omitempty"`
	// Detail says what differs, and why an evaluation was not re-checked.
	// The policy's reason is never a difference: it is in Rechecked.
	Detail string `json:"detail,omitempty"`
	// SpecSource is the spec the re-checked request carried, empty when
	// the policy was not asked.
	SpecSource SpecSource `json:"spec_source,omitempty"`
	// PolicyID is the identity the evaluation recorded, empty for none.
	PolicyID string `json:"policy_id"`
}

RecheckedEvaluation is one recorded step.policy and what re-checking it came to.

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 Session added in v0.4.0

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

Session is one hold on a run, from Begin or Attach until the run pauses or ends, the lease is lost, a call fails, or Close. It holds the run's lease throughout, renewed by a heartbeat, and has at most one open step. The call that pauses or ends the run reports it, and every later call fails: with ErrRunState once the run is paused or ended, with ErrLeaseLost once the lease is lost, and with ErrSessionClosed after Close or a failed call. A call that misuses the session, a second open step or an Execute without an Allowed verdict, fails with ErrRunState and changes nothing. Calls are serialised; the ModelCaller of the open step may be used concurrently, as an agent's may.

func (*Session) Close added in v0.4.0

func (s *Session) Close()

Close ends a session that is not over, releasing its lease so another process need not wait for it to expire. An open step is left as the store has it, as a crash would leave it: one still deciding, or allowed and not executed, is never executed, and Attach marks it interrupted; an approved step Attach left open stays approved and WAITING. A session that is not over holds a heartbeat until it is closed, so Close it when it is no longer used; closing one that is over does nothing.

func (*Session) Current added in v0.4.0

func (s *Session) Current() *OpenStep

Current is the session's open step, or nil: the step Step started, or the approved step Attach left for Execute, until it is over.

func (*Session) Done added in v0.4.0

func (s *Session) Done() bool

Done reports that the session is over and every further call fails.

func (*Session) Input added in v0.4.0

func (s *Session) Input(ctx context.Context) (StepInput, error)

Input is what an agent deciding at this point of the session would be handed in StepInput, for a loop that decides from the run's record as an agent does: copies, which the caller may change without changing the run's record or any later view but its own. While a step is open it is that step's input, as Decide receives it: the run as the step started, the steps before it, every approval, the tools, and the step's Model; for the approved step Attach left open, the run is the WAITING run and the steps those before it. With no step open it is the run as the session last read or wrote it and every step it has recorded, before the first Step none. It is served from the copy of the run's steps and approvals the session keeps as it writes: the first call of a session, or the first after a write the copy could not apply, loads them, which the next Step would otherwise have done and then does not, and a later call copies only the steps that changed since the previous one, so a call costs one shallow copy of each step and reads nothing from the store. A run whose stored rows cannot be decoded returns an error, and the next Step fails the run as it would have.

func (*Session) Run added in v0.4.0

func (s *Session) Run() Run

Run is the run as the session last read or wrote it: after the call that paused or ended it, the run as that call left it.

func (*Session) Step added in v0.4.0

func (s *Session) Step(ctx context.Context) (*OpenStep, error)

Step checks the run's limits and starts its next step, recording step.started, as the Driver does before asking its agent. The step, limit, consecutive failure, active and elapsed time, and loop limits fire here: the run ends, and Step returns a nil step and no error, as it does when it finds the run no longer RUNNING because an operator cancelled it. Run then reports the run.

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.

It does not implement IdentifiedPolicy: a map has no identity of its own that stays the same while its contents change, so the runtime cannot name one for it. A consumer whose decisions should say which policy made them wraps it in a type that does, naming the version of its own configuration the map was built from:

type versioned struct {
	agentrt.SideEffectPolicy
	id string
}

func (p versioned) PolicyID() string { return p.id }

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 SpecSource added in v0.5.0

type SpecSource string

SpecSource is which spec a re-checked request carried.

const (
	// SpecRecorded is the spec recorded with the evaluation, by the hash
	// its step.policy event names, read with Store.ToolSpec.
	SpecRecorded SpecSource = "recorded"
	// SpecCurrent is the spec of the tool RecheckOptions.Tools names.
	SpecCurrent SpecSource = "current"
)

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
	// SpecHash is the hash of the spec of the tool a tool_call decision
	// named, recorded with the decision when the tool was registered and
	// readable whole with Store.ToolSpec; empty for any other decision and
	// for a step written before it existed.
	SpecHash string
	// PolicyID is the identity of the policy that evaluated the step's
	// request, recorded once the policy was asked: empty when it does not
	// implement IdentifiedPolicy, when it was never asked, and when the
	// step's Policy was written by a reconciliation or an interrupted
	// side effect's pause rather than by the policy.
	PolicyID string
	// 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) ApprovalReason added in v0.3.1

func (s *Store) ApprovalReason(ctx context.Context, runID, approvalID string) (string, error)

ApprovalReason returns the policy's reason for an approval, capped at MaxPageText characters: the Reason of the PolicyDecision that paused the run on it, as its approval.requested event recorded it. The approval row has no reason of its own. The step's recorded policy agrees while the approval is pending; once it is granted and resumed, the step holds the policy's decision at resume, and this still returns the one that asked. ErrNotFound when the run has no such approval; empty when the approval has no approval.requested event.

func (*Store) ChainHead added in v0.5.0

func (s *Store) ChainHead(ctx context.Context) (seq int64, hash string, err error)

ChainHead returns the seq and stored hash of the newest event, the head of the chain, or zero and empty when there are none. Kept where whoever can write the database cannot reach, it is what a later VerifyEvents is compared with: an intact chain whose event at that seq still has that hash has not been rewritten up to 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) EventHash added in v0.5.0

func (s *Store) EventHash(ctx context.Context, seq int64) (string, error)

EventHash returns the stored hash of the event at seq, cut at 128 characters, which a follower records beside its cursor to anchor what it delivered. ErrNotFound when there is no such event.

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) Lease added in v0.3.1

func (s *Store) Lease(ctx context.Context, runID string) (owner string, until time.Time, err error)

Lease returns the lease stored on a run: the owner of the driver that holds it and when it expires by that driver's wall clock. A run nobody holds, paused, finished, or released by the call that executed it, has an empty owner and a zero time. A lease whose time has passed is stale, not released: Resume takes such a run over. ErrNotFound when there is no such run.

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) ListEventsAfter added in v0.3.1

func (s *Store) ListEventsAfter(ctx context.Context, runID string, seq int64, limit int) ([]Event, error)

ListEventsAfter returns at most limit events with Seq greater than seq, in Seq order: of one run when runID is set, of every run interleaved in commit order when it is empty. Each text column is capped at MaxPageText and a time that does not parse reads as zero, as in ListEventsPage. Seq is assigned in the transaction that commits the event, so it rises in commit order across the database and is never reused: the last Seq returned is a cursor a follower resumes after.

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.

func (*Store) ToolSpec added in v0.5.0

func (s *Store) ToolSpec(ctx context.Context, hash string) (ToolSpec, error)

ToolSpec returns the tool spec recorded under hash, as Step.SpecHash, run.created's tools, and step.policy's spec_hash name it. It reads the spec whole, as GetApproval reads an approval, because a decision is checked against all of it, and refuses one whose stored JSON no longer hashes to hash. ErrNotFound when no spec has that hash.

func (*Store) VerifyEvents added in v0.5.0

func (s *Store) VerifyEvents(ctx context.Context, fromSeq, toSeq int64) (VerifyReport, error)

VerifyEvents walks the event chain from seq fromSeq to toSeq, both inclusive, and reports the first event that does not chain. fromSeq of zero or less starts at the first event, and toSeq of zero or less ends at the newest event when the walk starts. A range that does not start at the first event is checked against the stored hash of the event before it, which this call does not verify. An event whose seq does not follow the previous one's is a break: seq is never reused and a rolled-back write leaves no gap, so a gap is a deleted event.

It reads a page of events at a time and each field in bounded chunks, so neither many events nor one huge event can exhaust memory, and it writes nothing. What it proves is limited: whoever can write the database can recompute every hash after a row they changed, so an intact chain means the record is unaltered only up to a head compared with one kept where that writer cannot reach (ChainHead, EventHash).

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. As the policy is handed it, on every path (the loop, a Gate's Propose and Attach, Resume, and Recheck), Args and Spec.InputSchema are in PolicyJSON form: keys sorted, no insignificant whitespace, numbers as written, and strings escaped only as JSON requires, the same bytes whether made from what the agent wrote or from what the runtime stored. The request a tool is called with, an Allowed Verdict carries, and an approval stores keeps the arguments as the agent wrote them, the approval's compacted and HTML-escaped as it is stored; its hash is over the canonical form, which is the same for all of them.

type ToolResult

type ToolResult struct {
	Content json.RawMessage
	Summary string
}

ToolResult is what a tool returns on success.

type ToolSchema added in v0.3.1

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

ToolSchema is a tool's spec as NewDriver accepts it, with its input schema compiled once: what a test, a linter, or a channel needs to judge arguments as the runtime would, without a driver or a store. It is safe for concurrent use.

func CompileTool added in v0.3.1

func CompileTool(spec ToolSpec) (*ToolSchema, error)

CompileTool checks spec as NewDriver checks each tool, a name, a valid side effect, a timeout, and an input schema that compiles from itself alone, and returns it compiled. Its error is the one NewDriver returns for the same spec.

func (*ToolSchema) Check added in v0.3.1

func (s *ToolSchema) Check(args json.RawMessage) error

Check reports whether the runtime accepts args for the tool: the JSON boundary checks a decision's arguments meet (depth, UTF-8, escaped surrogates, repeated keys, the number bound), then the input schema. Empty arguments mean {}. Its error text is the one the driver records in an invalid_decision observation for the same arguments.

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.

type Verdict added in v0.4.0

type Verdict struct {
	Outcome VerdictOutcome
	// Request applies to Allowed: exactly what Execute runs. It is a copy,
	// so changing it cannot change what runs.
	Request *ToolRequest
	// Observation applies to Denied.
	Observation *Observation
	// Approval applies to Pending: the approval the run waits on.
	Approval *Approval
	// Run applies to Pending and Ended.
	Run Run
}

Verdict is the gate's answer to a proposed step.

type VerdictOutcome added in v0.4.0

type VerdictOutcome string

VerdictOutcome is what the gate made of a proposed step.

const (
	// VerdictAllowed means the request may run: call Execute.
	VerdictAllowed VerdictOutcome = "allowed"
	// VerdictDenied means the step ended without running anything, with a
	// failure observation: the policy denied the request, or the decision
	// was invalid. The session continues with the next Step.
	VerdictDenied VerdictOutcome = "denied"
	// VerdictPending means the run is WAITING on an approval and the
	// session is over. Approve it, then Attach.
	VerdictPending VerdictOutcome = "pending"
	// VerdictEnded means the run reached a terminal status and the session
	// is over.
	VerdictEnded VerdictOutcome = "ended"
)

type VerifyReport added in v0.5.0

type VerifyReport struct {
	// From and To are the seq of the first and last event checked, and
	// Checked how many there were; zero when the range holds none. A To
	// below the toSeq asked for means the database holds no event past
	// it: an intact chain cut at its end, which only a head kept
	// elsewhere at a later seq shows (agentrt verify -head).
	From, To int64
	Checked  int64
	// Hash is the stored hash of the last event checked: the head when To
	// is the newest event and Break is nil.
	Hash string
	// Break is the first event that does not chain, nil when every event
	// checked does. Checking stops there.
	Break *ChainBreak
}

VerifyReport is what VerifyEvents found.

Directories

Path Synopsis
Package approver is the portable approval channel: one interface over what an operator surface must show and may decide, and the store-backed implementation of it.
Package approver is the portable approval channel: one interface over what an operator surface must show and may decide, and the store-backed implementation of it.
webhook
Package webhook is the reference HTTP approval channel over approver.Approver: an http.Handler that shows one run's pending approvals, shows one approval as JSON or as sanitised plain text, and takes a decision bound to the hash it showed.
Package webhook is the reference HTTP approval channel over approver.Approver: an http.Handler that shows one run's pending approvals, shows one approval as JSON or as sanitised plain text, and takes a decision bound to the hash it showed.
bench module
cmd
agentrt command
Command agentrt is the operator surface over a runs database: list runs, show one, print its event log, verify the event log's hash chain, and approve, reject, or cancel.
Command agentrt is the operator surface over a runs database: list runs, show one, print its event log, verify the event log's hash chain, and approve, reject, or cancel.
examples
approver command
Command approver runs one scripted run to a publication-shaped approval, serves approver/webhook over its database on a loopback port, and acts as the chat bot on the other end: it fetches the approval's text rendering, prints it as the message an operator would read, posts the decision bound to the hash it showed, and then resumes the run the way the consumer's own process would.
Command approver runs one scripted run to a publication-shaped approval, serves approver/webhook over its database on a loopback port, and acts as the chat bot on the other end: it fetches the approval's text rendering, prints it as the message an operator would read, posts the decision bound to the hash it showed, and then resumes the run the way the consumer's own process would.
recheck command
Command recheck shows a finished run checked again: a scripted agent reads, writes, and publishes under a lenient policy that allows all three; the run is then re-checked under a stricter policy, which prints the steps it would have stopped; last, one event is altered in a copy of the database, and the check agentrt verify runs finds it.
Command recheck shows a finished run checked again: a scripted agent reads, writes, and publishes under a lenient policy that allows all three; the run is then re-checked under a stricter policy, which prints the steps it would have stopped; last, one event is altered in a copy of the database, and the check agentrt verify runs finds it.
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
Package export follows the events table of a Store and hands every committed event, in commit order, to a sink: the JSON Lines writer here, or the OpenTelemetry span exporter in the nested module export/otel.
Package export follows the events table of a Store and hands every committed event, in commit order, to a sink: the JSON Lines writer here, or the OpenTelemetry span exporter in the nested module export/otel.
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 testkit is what a consumer uses to test its tools, policy, and agent against the runtime without a model: an interruption harness that crashes the loop at a chosen point and resumes it in a fresh Driver, a policy conformance table, schema fuzzing of tool arguments, a render equivalence check, and a re-check of a run's decisions under a policy (Recheck, RecheckDiffers, RecheckPartial, Scenario.Recheck).
Package testkit is what a consumer uses to test its tools, policy, and agent against the runtime without a model: an interruption harness that crashes the loop at a chosen point and resumes it in a fresh Driver, a policy conformance table, schema fuzzing of tool arguments, a render equivalence check, and a re-check of a run's decisions under a policy (Recheck, RecheckDiffers, RecheckPartial, Scenario.Recheck).
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