Skip to content

Internals

Joey King edited this page Oct 8, 2026 · 6 revisions

Internals

Files

The driver was one file until v0.3.0, when it was split into the files below as pure moves; what each file holds is what the loop does at that stage. Since v0.4.0 the sequence the loop runs is written once, over a Session, and both the Driver and a Gate run it: a Driver is a gate with an agent.

File Contents
agentrt.go every public type: run, step, decision, observation, tool, policy, approval, agent, reconciliation, event, limits
driver.go Config, NewDriver, Start, StartWithID, the per-driver claim on runs it is executing
loop.go the Driver's loop over a session, and the code it shares with the gate: starting a step with its limit checks, proposing a decision, validation, the one dispatch over policy outcomes (apply) shared with resume, and tool execution
gate.go GateConfig, Gate, Begin and Attach, Session, OpenStep, Verdict, ErrSessionClosed, and Session.Input; see The Gate
step.go starting, recording, and ending a step; settle, which ends a step and its run in one transaction; the late step.tool_finished after an operator's cancel
approval.go the approval hash, the pause, Approve, Reject, ApproveShown, RejectShown, Cancel, expiry and ErrApprovalExpired, and Operator, the driverless decision path on a chosen clock
resume.go Resume, and the attach a gate's Attach shares with it: the approved path, the interrupted path, reconciliation, and the interrupted_side_effect pause
lease.go the run lease: DefaultLeaseTTL, the heartbeat, and release
cache.go the step cache: the loop's kept steps and approvals, and the mirrors handed to the agent and the policy
store.go OpenStore, OpenExisting, migrations (the sixth adds the chain, tool_specs, and the step columns), file-mode checks, queries, page reads, Store.Lease, Store.ListEventsAfter, and Store.ApprovalReason, the lease statements, and the transaction helper that writes state and events together, reading the chain's head and hashing each event inside the same transaction
store_mode_unix.go, store_mode_other.go whether the file-mode rules apply on this platform, and how to read a file's owner
model.go model types, the accounting caller, reservations, retries and Retry-After, usage checks, the price table, token estimation
policy.go PolicyFunc, SideEffectPolicy, DefaultPolicy, NeedApproval, DecodePresentation
schema.go JSON Schema compilation, with no loader beyond the document and the standard metaschemas, and validation; CompileTool, ToolSchema.Check, and CheckArgs, the runtime's acceptance of a tool's arguments without a driver, which NewDriver and the loop go through too
hash.go the canonical JSON encoder, written out and pinned to the stored hashes; content hashes; the boundary check on consumer JSON (checkJSON, checkDepth, and since v0.4.0 checkNumbers, which refuses a number past the bound); ids
canonical.go CanonicalJSON, the exported form of that encoder, which refuses what the runtime refuses where consumer JSON enters, and, since v0.5.0, PolicyJSON, the same form without HTML escaping, in which a policy is handed a request's arguments and its tool's schema
record.go what a step records so its decision can be checked again: the tool spec stored once by its hash in tool_specs, the policy's identity (IdentifiedPolicy), and the view a step.policy event carries. Since v0.5.0
chain.go the event hash chain: eventHash, Store.VerifyEvents, Store.ChainHead, Store.EventHash, and the backfill migration 6 runs. Since v0.5.0
recheck.go Recheck, RecheckOptions, and RecheckReport: it rebuilds each recorded policy evaluation from the events, steps, approvals, and specs, asks a policy, and compares. Since v0.5.0; see Re-checking a Run
replay/ scripted, recording, and replaying models
scripted/ the scripted agent
providers/ the dependency-free helpers every provider adapter shares
render/ recorded steps to model messages, and a response back to a decision
trace/ the aligned and JSON Lines event observers, Sanitize, and WriteRecheck, which renders a re-check report as text
view/ the run, step, and approval read models, full and paged
cmd/agentrt/ the operator command
testkit/ the interruption harness, which also runs over a caller's own loop on a gate, the policy conformance table, schema fuzzing, the render equivalence check, and, since v0.5.0, the re-check helpers a consumer's tests use; see Testing a Consumer
export/ the read-only follower of the events table, its cursors, and the JSON Lines sink; see Exporting Events
approver/ the interface an operator surface decides approvals through, and its store-backed implementation
approver/webhook/ the reference signed HTTP approval channel over approver, and its plain-text rendering; see Approval Channels
api_test.go tests of the v0.3.1 core additions: Store.Lease, Store.ListEventsAfter, ErrApprovalExpired, Operator, Store.ApprovalReason, CheckArgs
schema_internal_test.go the test that CheckArgs compiles a spec once and keeps its cache bounded, which reads the compile counter and so lives inside the package
chain_test.go, recheck_test.go, recheck_run_test.go, recheck_internal_test.go, policyjson_test.go the tests of the chain, the record, Recheck, and the policy form. recheck_internal_test.go re-checks a database written by v0.4.0
examples/scripted/ the runnable example with no model
examples/recheck/ a scripted run re-checked under a stricter policy, and an altered event found by the chain check; see Re-checking a Run
examples/live/ the same tools against a local model, pausing for an operator
examples/mcp/ the MCP filesystem server behind the policy
examples/approver/ a scripted run decided through the webhook as a chat bot would, then resumed; a plain core example with no go.mod of its own

Nested modules, each with its own go.mod so its dependencies stay out of the core: providers/ollama, providers/openai, providers/anthropic, and mcp, plus bench, which has no dependencies at all, and export/otel with its example/ program, which brings OpenTelemetry (see Measuring Agents and Exporting Events), plus proxy, the MCP proxy agentrt-proxy with its fake payment server and its example/ demonstration (see MCP Proxy), plus the two model examples. ./... in the root module does not reach them; see Provider Adapters for the workspace that does.

The gate and the loop

The loop below is what a Driver does. A Gate runs the same steps with the caller standing where Decide does. Gate.Begin and Driver.StartWithID share one begin, Gate.Attach and Driver.Resume share one attach, and a session's Step, Propose, and Execute are the loop's start-a-step, validate-and-apply, and execute stages, called by the caller's code instead of by the loop. TestGate_ExternalLoopMatchesTheDriver is the test that keeps the two the same: rows, events, payloads, transaction boundaries, and clock readings.

The loop

loop runs until the run leaves RUNNING. Each iteration:

  1. read the run row, which model calls and an operator change; stop if it is no longer RUNNING, and stop with ErrLeaseLost if the lease is gone;
  2. on the first iteration, or when the cache is stale, load the run's steps and approvals; refuse to continue from a row that would not decode;
  3. check the step, failure, active-time, elapsed-time, and loop limits;
  4. start a step: claim the next index on the run (compare-and-set on the step count, status, and lease owner) and write step.started;
  5. call Decide with a fresh copy of the steps and approvals;
  6. record the decision verbatim, with unusable JSON moved to the Invalid fields, and write step.decided;
  7. validate; an invalid decision ends the step as invalid_decision and loops;
  8. on complete or fail, settle the step and the run together;
  9. evaluate the policy on a copy of the view, handing it the arguments and the schema in PolicyJSON form, and apply its outcome: on allow, check the lease is held and unexpired, mark the step executing, and write step.policy and step.tool_started in one transaction; execute under the tool's timeout with panic recovery; record the observation with its hash and step.tool_finished; a terminal tool's success or a tool abort settles the run; on deny, end the step; on abort, settle the run as policy_abort; on require_approval, write the approval and pause.

Every write goes through Store.tx, which opens an immediate transaction, runs the mutation, appends the events, commits, and only then delivers the events to the observer. Appending an event reads the chain's head and writes the event's hash in the same transaction (see The record and the chain). An observer never sees an event whose state did not commit. A panic inside the mutation rolls the transaction back and propagates.

Every write the loop makes to a RUNNING run is guarded inside its transaction: the run must still be RUNNING and this driver must hold its lease, or the write fails with ErrRunState or ErrRunLeased, and the loop answers through lost, which returns the run as it now is if an operator cancelled it and ErrLeaseLost otherwise.

The step cache

The loop loads its run's steps and approvals once, when Start or Resume enters it, and keeps them in a runCache. After each write commits, it decodes the columns that write bound, with the same code ListSteps and ListApprovals use, so what it keeps equals a fresh load. A write that fails applies nothing; a row that cannot be applied marks the cache stale and the next iteration reloads. Nothing is kept between loops.

The agent and the policy each get a mirror of the cache: bytes of their own, copied only for a step that changed since the last handout, and fresh structs over them at every handout. Neither can reach the driver's copy or the other's. Performance has the measurements and the equivalence tests; Driver.reload, reachable only from tests, runs the old reread-every-step path for comparison.

The record and the chain

A step that names a registered tool records the hash of its spec when its decision is recorded, and the spec is stored once under that hash in tool_specs. A step the policy evaluated records the policy's identity. Its step.policy event carries policy_id, spec_hash, the spec handed to the policy, and view, the fields of the run that no row keeps. None of it reads a clock, and TestDriver_ClockReadingsAreStable still pins the order of readings.

The request a policy is handed is made in one place for every path. The loop and a gate's Propose make PolicyJSON from the agent's arguments and the registered schema; Resume and Attach make it from the approval's stored request, which is compacted and HTML-escaped, and from the schema registered now; Recheck makes it from the stored decision and the stored spec. All give the same bytes for one request (TestPolicy_LoopAndResumeHandedTheSameBytes, TestPolicy_GateProposeAndAttachHandedTheSameBytes). The approval hash, the content hash, loop detection, and the arguments a tool is called with are over or from the unchanged forms.

An event's hash is the SHA-256 of seven netstrings over the previous event's hash and the event's own fields as stored. Store.tx reads the head after it has the write lock and writes the hash with the event, so the chain follows commit order across every run. Event has no hash field. VerifyEvents recomputes it a page at a time, hashing a long field in chunks of MaxPageText as it reads it.

Migration 6 adds tool_specs, steps.spec_hash, steps.policy_id, and events.hash, and computes the hash of every event already stored, in seq order, in the migration's transaction. TestStore_ReleasedMigrationsAreUnchanged pins the released migrations by a hash of their text, and a migration is folded into that pin when the release that carries it is tagged, as 5 and 6 were (Releasing).

Recheck is read-only. It pages the events and steps, reads the approvals and specs it needs whole, and rebuilds for each step.policy carrying a view the request, the run, the first steps, and the first approvals with the status each had by the seq of its approval.decided. The rules and the limits are on Re-checking a Run.

Clocks

Two clocks are read. Config.Now stamps what is recorded and judges approval expiry and the time limits, one reading per recorded state change, in an order pinned by TestDriver_ClockReadingsAreStable: a consumer with a deterministic clock keys its own records on that sequence. The wall clock stamps leases and nothing else, and is never Now. See Testing.

Identity

Run and step ids come from Config.NewID, defaulting to random 128-bit hex ids; StartWithID lets a consumer supply a run id it has already recorded elsewhere. The lease owner is Config.LeaseOwner plus a random suffix per driver. Config.Now supplies time. All are overridable for tests.

Resume paths

Resume dispatches on the run's state, after refusing a run this driver is already executing.

  • WAITING_FOR_APPROVAL: take the awaiting step's latest approval; expire it if its ApprovalTTL or GrantTTL has passed; require it approved and its hash intact; check the recorded arguments against the tool's current schema; evaluate the policy while the run is still waiting; and apply the outcome through the same apply the loop uses, so the move to RUNNING, the lease, step.policy, and the tool start commit together.
  • RUNNING: refuse with ErrRunLeased while another owner's lease is live; otherwise take the lease and mark the steps in flight interrupted in one transaction, then reconcile, or pause an interrupted side effect for an operator, or continue. See Interruption and Reconciliation.
  • Terminal: refuse with ErrRunState.

Connections

The store uses one SQLite connection in WAL mode, plus, for a file-backed store opened for writing, a second connection used only by the lease heartbeat. The loop's seven hot statements are prepared on the main connection at open. Queries must not be issued inside an open transaction on the same connection, or the process deadlocks: load what you need before tx. A consumer transaction on DB() holds the main connection and stalls the loop, but not the heartbeat.

Adding a public type

Only when a consumer demonstrates the need. The runtime stays small on purpose; an abstraction without a consumer is refused.

Clone this wiki locally