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 ¶
- Constants
- Variables
- func Approve(ctx context.Context, store *Store, obs Observer, ...) error
- func ApproveShown(ctx context.Context, store *Store, obs Observer, ...) error
- func Cancel(ctx context.Context, store *Store, obs Observer, runID, by, note string) error
- func CanonicalJSON(raw json.RawMessage) ([]byte, error)
- func CheckArgs(spec ToolSpec, args json.RawMessage) error
- func DecodePresentation[T any](a Approval) (T, error)
- func EstimateInputTokens(req ModelRequest) int
- func PolicyJSON(raw json.RawMessage) (json.RawMessage, error)
- func Reject(ctx context.Context, store *Store, obs Observer, ...) error
- func RejectShown(ctx context.Context, store *Store, obs Observer, ...) error
- type Agent
- type Approval
- type ApprovalStatus
- type ChainBreak
- type Config
- type ContentBlock
- type Decision
- type DecisionKind
- type DecisionOrigin
- type Driver
- func (d *Driver) Approve(ctx context.Context, runID, approvalID, by, note string) error
- func (d *Driver) Cancel(ctx context.Context, runID, by, note string) error
- func (d *Driver) Reject(ctx context.Context, runID, approvalID, by, note string) error
- func (d *Driver) Resume(ctx context.Context, runID string) (Run, error)
- func (d *Driver) Start(ctx context.Context, goal string, limits Limits) (Run, error)
- func (d *Driver) StartWithID(ctx context.Context, id, goal string, limits Limits) (Run, error)
- type ErrAbortRun
- type ErrInsecureMode
- type ErrLimit
- type ErrModelUnavailable
- type ErrRunLeased
- type Event
- type Gate
- type GateConfig
- type IdentifiedPolicy
- type Limits
- type Message
- type Micros
- type Model
- type ModelCall
- type ModelCallStatus
- type ModelCaller
- type ModelConfig
- type ModelRequest
- type ModelResponse
- type Observation
- type ObservationKind
- type Observer
- type OpenStep
- func (st *OpenStep) Execute(ctx context.Context) (Observation, error)
- func (st *OpenStep) Fail(ctx context.Context, err error) (Verdict, error)
- func (st *OpenStep) ID() string
- func (st *OpenStep) Index() int
- func (st *OpenStep) Model() ModelCaller
- func (st *OpenStep) Propose(ctx context.Context, decision Decision) (Verdict, error)
- type Operator
- func (o Operator) Approve(ctx context.Context, runID, approvalID, by, note string) error
- func (o Operator) ApproveShown(ctx context.Context, runID, approvalID, shownHash, by, note string) error
- func (o Operator) Cancel(ctx context.Context, runID, by, note string) error
- func (o Operator) Reject(ctx context.Context, runID, approvalID, by, note string) error
- func (o Operator) RejectShown(ctx context.Context, runID, approvalID, shownHash, by, note string) error
- type Policy
- type PolicyDecision
- type PolicyFunc
- type PolicyIdentity
- type PolicyOutcome
- type Price
- type PriceTable
- type RecheckOptions
- type RecheckReport
- type RecheckResult
- type RecheckedEvaluation
- type ReconcileOutcome
- type Reconciliation
- type Run
- type RunStatus
- type RunView
- type ServedError
- type Session
- type SideEffect
- type SideEffectPolicy
- type SpecSource
- type Step
- type StepInput
- type StepStatus
- type Store
- func (s *Store) ApprovalReason(ctx context.Context, runID, approvalID string) (string, error)
- func (s *Store) ChainHead(ctx context.Context) (seq int64, hash string, err error)
- func (s *Store) Close() error
- func (s *Store) DB() *sql.DB
- func (s *Store) EventHash(ctx context.Context, seq int64) (string, error)
- func (s *Store) GetApproval(ctx context.Context, runID, id string) (Approval, error)
- func (s *Store) GetRun(ctx context.Context, id string) (Run, error)
- func (s *Store) GetRunCapped(ctx context.Context, id string) (Run, error)
- func (s *Store) Lease(ctx context.Context, runID string) (owner string, until time.Time, err error)
- func (s *Store) ListApprovals(ctx context.Context, runID string) ([]Approval, error)
- func (s *Store) ListApprovalsPage(ctx context.Context, runID string, status ApprovalStatus, limit, offset int) ([]Approval, int, error)
- func (s *Store) ListEvents(ctx context.Context, runID string) ([]Event, error)
- func (s *Store) ListEventsAfter(ctx context.Context, runID string, seq int64, limit int) ([]Event, error)
- func (s *Store) ListEventsPage(ctx context.Context, runID string, limit, offset int) ([]Event, int, error)
- func (s *Store) ListModelCalls(ctx context.Context, runID string) ([]ModelCall, error)
- func (s *Store) ListRuns(ctx context.Context) ([]Run, error)
- func (s *Store) ListRunsPage(ctx context.Context, limit, offset int) ([]Run, int, error)
- func (s *Store) ListSteps(ctx context.Context, runID string) ([]Step, error)
- func (s *Store) ListStepsPage(ctx context.Context, runID string, limit, offset int) ([]Step, int, error)
- func (s *Store) PendingApprovalIDs(ctx context.Context) (map[string][]string, error)
- func (s *Store) PendingApprovalIDsOf(ctx context.Context, runIDs []string, max int) (map[string][]string, error)
- func (s *Store) ToolSpec(ctx context.Context, hash string) (ToolSpec, error)
- func (s *Store) VerifyEvents(ctx context.Context, fromSeq, toSeq int64) (VerifyReport, error)
- type TerminalReason
- type Tool
- type ToolCall
- type ToolRequest
- type ToolResult
- type ToolSchema
- type ToolSpec
- type ToolUse
- type TransientError
- type Usage
- type Verdict
- type VerdictOutcome
- type VerifyReport
Examples ¶
Constants ¶
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.
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.
const DefaultMaxRetryAfter = time.Minute
DefaultMaxRetryAfter is the longest wait a provider may ask for before a retry when ModelConfig.MaxRetryAfter is zero.
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.
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 ¶
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.
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.
var ErrApprovalHash = errors.New("agentrt: approval hash does not match its recorded request")
ErrApprovalHash means a stored approval no longer matches its own fields.
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.
var ErrNotFound = errors.New("agentrt: not found")
ErrNotFound is returned when a run does not exist.
var ErrNotPending = errors.New("agentrt: approval is not pending")
ErrNotPending means an approval has already been decided or expired.
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.
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.
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.
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.
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.
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
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
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.
Types ¶
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 (*Driver) Approve ¶
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
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) Resume ¶
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}
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
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.
type ErrModelUnavailable ¶
type ErrModelUnavailable struct {
}
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
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
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.
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
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.
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 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
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
ID is the step's id, which its tool call carries as ToolCall.StepID.
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
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
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) 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.
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 ¶
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" )
type ServedError ¶ added in v0.3.0
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
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
Done reports that the session is over and every further call fails.
func (*Session) Input ¶ added in v0.4.0
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
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
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 ¶
func (p SideEffectPolicy) Evaluate(_ context.Context, req ToolRequest, _ RunView) (PolicyDecision, error)
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
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 ¶
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
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
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) 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
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 ¶
GetApproval loads one approval of a run.
func (*Store) GetRunCapped ¶ added in v0.3.0
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
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 ¶
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 ¶
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 ¶
ListModelCalls returns a run's model call attempts in dispatch order.
func (*Store) ListRuns ¶
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
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) 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
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
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
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" 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.
Source Files
¶
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. |