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

agentsafe

package module
v0.6.0 Latest Latest
Warning

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

Go to latest
Published: Oct 12, 2026 License: Apache-2.0 Imports: 25 Imported by: 0

README

agentsafe

CI OpenSSF Scorecard Go Reference

Status: v0.5.0, pre-1.0 (CHANGELOG). The guarantees below are proven under the conditions stated; the gaps to production are tracked item by item in SCORECARD.md.

go get github.com/Ashutosh2308Bhardwaj/agentsafe

Correctness primitives for LLM agents that act on money. Idempotent tool execution, durable resume, human approval gates, pre-write validation, and reconciliation of what the agent claimed against what actually happened. Go; the core uses the standard library only, and storage backends and model adapters are separate modules.

I own a payouts platform that disburses ₹400M a month to 40,000+ people. The failure I've spent the most time on is the gateway timeout that arrives after the money moved. I wanted to know what that failure looks like when the caller is an LLM agent, so I built an agent, broke it deliberately, wrote down every failure I saw, and built this library from that list. Nothing in it is speculative: every primitive answers a failure I reproduced, and every guarantee below is a test or a run you can repeat.

See it in a minute (Go only, no credentials)

An agent charges a customer through a billing MCP server, the answer is lost, and the agent asks again: straight to the server, then through agentsafe-mcp. The second scene kills the proxy mid-call.

git clone https://github.com/Ashutosh2308Bhardwaj/agentsafe && cd agentsafe && ./demo.sh
1. The answer is lost on the way back, so the agent retries
   Straight to the billing server:
     call  → charged 10.00 (charge 1)
     retry → charged 10.00 (charge 2)
     customer charged 2 times ✗
   Through agentsafe-mcp:
     call  → charged 10.00 (charge 1)
     retry → charged 10.00 (charge 1)   answered from agentsafe's log, not charged again
     customer charged 1 time ✓

2. agentsafe-mcp itself crashes after the charge, before the answer reaches the agent
   ...
     retry → outcome unknown: not retried, never guessed (check the billing system)
     customer charged 1 time ✓

Quickstart (5 minutes, no API key)

An agent that can pay an invoice at most once, and only after a human approves, surviving restarts in between:

// Quickstart: an agent that can pay an invoice at most once, and only after a human approves.
package main

import (
	"context"
	"fmt"
	"os"

	"github.com/Ashutosh2308Bhardwaj/agentsafe"
)

// Payout is what the model sends to pay one invoice.
type Payout struct {
	InvoiceID string            `json:"invoice_id"`
	Amount    agentsafe.Decimal `json:"amount"`
}

// sendPayout is your code. KeyFrom(ctx) is the operation's idempotency key: give it to your payment
// provider, and a retry after a crash or a timeout can't pay twice.
func sendPayout(ctx context.Context, p Payout) (string, error) {
	_ = agentsafe.KeyFrom(ctx)
	fmt.Println("paying", p.InvoiceID, p.Amount)
	return "paid", nil
}

func main() {
	ctx := context.Background()
	pay := agentsafe.Func("send_payout", "Pay an approved invoice", sendPayout,
		agentsafe.Idempotent("invoice_id"),                       // once per invoice, ever
		agentsafe.NeedsApproval(func(p Payout) any { return p })) // a human decides first

	// An offline stand-in for the model. For Claude: anthropic.New(sdk.NewClient()).
	model := &agentsafe.ScriptedModel{Final: "INV-1 paid.", Plan: []agentsafe.FunctionCall{
		{Name: "send_payout", Arguments: `{"invoice_id":"INV-1","amount":"4200.50"}`}}}

	r, err := agentsafe.New(model, &agentsafe.FileLog{Path: "payout-run.jsonl"},
		agentsafe.WithTools(pay), agentsafe.WithAuthorizer(agentsafe.AllowList("you@example.com")))
	must(err)

	if len(os.Args) > 1 && os.Args[1] == "approve" {
		st, err := r.Continue(ctx) // rebuilt from the log: a new process knows what's waiting
		must(err)
		if st.Waiting == nil {
			fmt.Println("nothing to approve; the run is", st.Status)
			return
		}
		st, err = r.Approve(ctx, st.Waiting.Key, "you@example.com")
		must(err)
		fmt.Println(st.Status, "-", st.Text)
		return
	}
	st, err := r.Start(ctx, "You pay approved invoices.", "Pay invoice INV-1.")
	must(err)
	fmt.Printf("%s: %s\napprove it, now or after a reboot: go run . approve\n", st.Status, st.Waiting.Summary)
}

func must(err error) {
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
}
$ go run .
awaiting_approval: {"invoice_id":"INV-1","amount":"4200.50"}
approve it, now or after a reboot: go run . approve

$ go run . approve          # a new process: it finds the waiting payout in the log
paying INV-1 4200.50
finished - INV-1 paid.

$ go run . approve          # again: nothing is paid twice
nothing to approve; the run is finished

To use a real model, replace the ScriptedModel with anthropic.New(sdk.NewClient()) (go get github.com/Ashutosh2308Bhardwaj/agentsafe/anthropic), gemini.New(client, model), or &openai.Model{...} (agentsafe/openai) for Groq, OpenAI, Ollama or vLLM. Then add agentsafe.Check(...) to ground arguments against your data before anyone is asked to approve, and reconcile.Audit (agentsafe/reconcile) to check the run against your systems of record. This exact program is examples/quickstart, and CI runs it as three processes.

What goes wrong when the caller is a model

From docs/FAILURES.md, observed against real models (Gemini, then openai/gpt-oss-120b on Groq):

  • It retries blindly. Told "timeout, outcome unknown" after a write that had succeeded, it retried after 19 tokens of thought. The ledger got 5 rows, and its report said 4.
  • It retries under a new identity. When a tool result was lost, the model re-issued the same call with a new call id, so deduplicating on call ids misses it.
  • It assumes success. When a result was never delivered, it reasoned "already recorded" and reported it as fact.
  • It's confidently wrong in specific places. In one batch, 6 of 8 runs proposed at least one wrong amount, every one on a row where one side is legitimately empty.
  • It notices, then hides it. Given another account's balance, its reasoning said "That's odd… possibly a mistake", and its answer approved the payout anyway, dropping the account id.
  • The APIs enforce almost nothing. Two results for one call, a call with no result, schema-valid JSON with a made-up amount: 0: all accepted.

The model can't tell "failed" from "succeeded but unconfirmed", and it isn't its job to. Delivery from this caller is at-least-once whatever you do; exactly-once has to come from the effect.

Framework-independent, through MCP: the proxy

Most agents aren't Go programs: they're LangGraph, the OpenAI Agents SDK, Claude Code, getting their tools from MCP servers. Put agentsafe-mcp in front of a server, in the agent's MCP configuration, and every tool call goes through agentsafe: logged before it's forwarded, a repeated operation answered from the log, an unknown outcome retried only with a key the server honours, and approval: always tools waiting for a person. The agent's code doesn't change.

Today it's a tool proxy for one stdio MCP server, and it fails closed: tools without a policy aren't exposed. Streamable HTTP and several servers are planned.

go install github.com/Ashutosh2308Bhardwaj/agentsafe/mcp/cmd/agentsafe-mcp@latest
{ "command": "agentsafe-mcp", "args": ["--log", "calls.jsonl", "--policy", "policy.json", "--", "npx", "-y", "@acme/billing-mcp"] }

mcp/README.md has the policy file and the approval commands; mcp/examples/langgraph is a LangGraph agent doing it, run in CI; docs/MCP_PROXY.md is the design, including what the proxy can't guarantee (it sees calls, not the model's decisions).

The primitives

r, err := agentsafe.New(model, &agentsafe.FileLog{Path: "run.jsonl"}, agentsafe.WithTools(tools...),
	agentsafe.WithStartedBy("scheduler"), // who asked for the run
	agentsafe.WithAuthorizer(agentsafe.All(agentsafe.AllowList("ops@company"), agentsafe.NotRequester()))) // who may decide
st, err := r.Start(ctx, system, task)     // or r.Continue(ctx) after a crash, in any process
r.Approve(ctx, key, "ops@company")         // a gated call waits durably until someone ALLOWED decides
rep := reconcile.Audit(expected, actual, events, claims)   // did it do what it claimed?
primitive what it does file
Wrap any function agentsafe.Func("send_payout", "...", sendPayout, agentsafe.Idempotent("invoice_id"), agentsafe.NeedsApproval(summary), agentsafe.Check(againstLedger), agentsafe.Timeout(10*time.Second)): the schema comes from the input struct, unknown fields are refused, and the function reads its idempotency key with KeyFrom(ctx) to pass to the system it calls. tooltest.SameKey proves your system really acts once per key, even for simultaneous calls. functool.go, tooltest/
Model adapters OpenAI-compatible (Groq, OpenAI, Ollama, vLLM, …) in agentsafe/openai (standard library only); Claude in agentsafe/anthropic and Gemini in agentsafe/gemini (official SDKs). A run resumed after a crash or an approval pause sends Claude exactly the conversation it sent before, thinking blocks / thought signatures included, which current models require. openai/, anthropic/, gemini/
Event log + state machine Every decision and result is fsync'd before anything acts on it, and a failed write or fsync stops the run and poisons that log handle (no retrying an fsync: PostgreSQL's "fsyncgate"); a new process continues from what is actually on disk. State is rebuilt from the log; the runner holds none. A transition function rejects impossible histories: finishing with a call unresolved, a result for something never started, a second result for one call, skipping an approval. eventlog.go, state.go, runner.go
Pluggable storage, fenced Backends implement a two-method LineStore (read lines; append line N only if N-1 exist); hash chain, sealing and verification live in the shared Journal, so no backend can get them wrong. The conditional append is the fencing token: a runner whose lease expired while it was paused is refused (ErrConflict) before it can act. storetest.Run checks any backend against the contract. Backends: file (built in), SQLite (agentsafe/sqlite, pure Go) and Postgres (agentsafe/postgres, for runs shared between machines; lease times on the database clock). journal.go, filestore.go, storetest/, sqlite/, postgres/
Idempotent tools A write tool declares which fields define the operation. The key is derived from those, never the model's call id and never free text. A repeat is replayed from the log, same key + different values is a conflict (never an upsert), and the key is passed into the effect so a crash between "done" and "logged" can't double it. idempotent.go
Pre-write validation Validate checks every consequential value against the source before anything runs or anyone is asked. The refusal says what the source says, so the model fixes it in one step. gate.go
Approval gate Irreversible calls pause as awaiting_approval in the log. Approve or reject later, from any process, addressed by the operation's key. Approving twice is a no-op; a decision can't be flipped, and a rejected operation the model proposes again is refused without asking the human again; the approver sees validated values only. An Authorizer decides who may approve or reject (allowlist, maker-checker, amount thresholds); with none set, every decision is refused, and refused attempts are logged. gate.go, authz.go
Budget as a pause Running out of steps pauses the run instead of ending it; extending it is a logged decision with who made it. state.go
Traces from the log OpenTelemetry GenAI-convention spans (invoke_agent, chat, execute_tool) derived from the log, so the trace can't disagree with it, and the same log gives the same trace. OTLP/JSON export. trace/, cmd/trace
Timeouts and panics A call that times out, panics, or returns ErrOutcomeUnknown is in doubt, never failed: a timed-out payout may have been charged. Idempotent tools are retried with the same key (the gateway dedupes); still unknown → ErrInDoubt, nothing logged, and Continue resolves it later. Other tools: the model is told the outcome is unknown. A panicking tool never takes the process down. exec.go
Sealed at rest Arguments, results, prompts and approval summaries are encrypted in the log (AES-256-GCM, key rotation; or your own Codec, e.g. a KMS), each bound to its line. Who approved what, keys, and the hash chain stay readable for audit; resume decrypts, so a crashed run continues with the real values. Deleting a key erases its runs. Console output and exported traces are masked separately (Runner.Redact, Trace.Redact), so nothing sensitive reaches a log aggregator or tracing vendor. seal.go, redact.go
Reconciliation Compares what should exist (computed from source data by plain code), what the log says, and what the systems of record hold: field by field, every payment tied to exactly one approval. Values are compared by type, exactly: 4200 ≠ "4200", a missing field ≠ null, Decimal("4200.50") = 4200.5, float drift is reported, never tolerated.

What's proven

Full tables in docs/EVIDENCE.md.

  • Crash anywhere, pay once. kill -9 at all 8 points of a real run (inside tools, mid-approval, right after the gateway charged) → resume → 8/8 correct, 0 duplicates, exactly 1 payment each.
  • The fix is what works, not luck. Same 172 random kills, same model decisions: 27 duplicate writes → 0 when the effect and its key can't be separated. Disable the key at the gap point and the duplicate comes straight back.
  • Wrong values never land. 6 of 8 real runs proposed a wrong amount; 0 were written. Each was refused and corrected in the next step.
  • Silent failures get caught. A gateway that under-pays, a ledger that drops a write, a gateway that ignores idempotency keys, a payment made outside the agent: in every case the agent's report, the tool results and the trace all looked clean. The checker caught 4/4. In 3 of the 4, the fault was in the other system.
  • The safety is cheap where it counts. About 16 µs of CPU per tool call, plus three durable log writes; the writes cost what your disk's fsync costs. Resuming a 1,000-event run takes about 3 ms. Numbers and how to reproduce them: docs/PERFORMANCE.md.

What it doesn't do

  • A transaction can't span someone else's system. For external effects the guarantee depends on the downstream system honouring an idempotency key, or offering a status lookup by key. If it offers neither, the best you can honestly promise is at-least-once plus reconciliation.
  • Idempotency stops doing something twice, not doing it wrong. A wrong first write would be locked in; that's why validation runs before the write.
  • A trace is the agent's account, not evidence about the world. It can't tell a safe retry from a double charge. Only the gateway's records can.
  • Calls run one at a time, by design: after a crash, at most one call is ever in the "may have executed" state.
  • The GenAI semantic conventions are still at Development status upstream.

Run it

go test ./...                                   # state machine, idempotency, gate, trace, checker
go run ./examples/reconcile -mock               # scripted model: free, deterministic
GROQ_API_KEY=… go run ./examples/reconcile      # a real model: reconciles, proposes a payout, waits at the gate
go run ./examples/reconcile -run <id> -approve <key>
go run ./examples/reconcile -run <id> -check    # reconcile any run against its systems of record
go run ./cmd/trace examples/reconcile/out/<id>-log.jsonl
Examples

Each runs offline (scripted model, fake external system), and each has a test that runs it as real processes.

Example What goes wrong What agentsafe does
quickstart A payout must wait for a human, across restarts The run waits durably at the approval gate; approving in a new process pays once
refund The model proposes the wrong amount; the wrong person approves; the process dies right after the money moved Check refuses the amount against the real charge; the Authorizer and maker-checker refuse the approvers; the refund is retried with its key: one refund. Without the key: two
langgraph A Python LangGraph agent bills through an MCP server; charging needs approval agentsafe-mcp in its MCP config, no agentsafe code: pending, a person approves from the command line, charged once, repeats answered from the log
outbox Killed after a database write, before the agent logged it; killed between the write and its email The credit, its key and its email commit in one transaction; the outbox worker resends with a key; reconciliation fails the control run
subscription An HTTP call times out, or the process dies, right after the API made a billed change The unknown outcome is retried with the same idempotency key: one invoice. Without the key: two
reconcile A model reconciling a ledger gets values wrong; a gateway under-pays or drops a write Grounding refuses wrong values; the approval gate; reconciliation catches what every report missed

examples/reconcile is a payout-reconciliation agent: it compares a ledger with a bank settlement file, records each discrepancy once, and re-issues payouts the bank never settled, behind an approval. gateway.go is a file-backed fake payment gateway with idempotency keys, status lookup, lost responses, a crash point, and four silent-fault modes.

Documentation

Overview

Package agentsafe provides correctness primitives for LLM agents that act on consequential systems: a durable event log, an explicit run state machine, and (from week 3 S2) idempotent tool execution and resume. It has no dependencies outside the Go standard library.

Example

A payout agent: the payment is idempotent (one per invoice, ever) and waits for a human approval, durably.

package main

import (
	"context"
	"fmt"
	"os"
	"path/filepath"

	"github.com/Ashutosh2308Bhardwaj/agentsafe"
)

type Invoice struct {
	InvoiceID string            `json:"invoice_id" desc:"the invoice being paid"`
	Payee     string            `json:"payee"`
	Amount    agentsafe.Decimal `json:"amount" desc:"in rupees, e.g. 4200.50"`
}

// gateway is a stand-in payment system: one payment per idempotency key.
type gateway struct{ payments map[string]string }

func (g *gateway) pay(ctx context.Context, in Invoice) (string, error) {
	key := agentsafe.KeyFrom(ctx)
	if _, done := g.payments[key]; !done {
		g.payments[key] = fmt.Sprintf("%s %s", in.Payee, in.Amount)
	}
	return "paid", nil
}

func tempLog() (*agentsafe.FileLog, func()) {
	dir, err := os.MkdirTemp("", "agentsafe-example")
	if err != nil {
		panic(err)
	}
	return &agentsafe.FileLog{Path: filepath.Join(dir, "run.jsonl")}, func() { _ = os.RemoveAll(dir) }
}

const payINV1 = `{"invoice_id":"INV-1","payee":"Ramesh","amount":"4200.50"}`

func main() {
	log, cleanup := tempLog()
	defer cleanup()
	gw := &gateway{payments: map[string]string{}}

	pay := agentsafe.Func("send_payout", "Pay an approved invoice", gw.pay,
		agentsafe.Idempotent("invoice_id"),
		agentsafe.NeedsApproval(func(in Invoice) any { return in }))
	model := &agentsafe.ScriptedModel{ // the model proposes the same payout twice
		Plan:  []agentsafe.FunctionCall{{Name: "send_payout", Arguments: payINV1}, {Name: "send_payout", Arguments: payINV1}},
		Final: "INV-1 is paid.",
	}
	r, err := agentsafe.New(model, log, agentsafe.WithTools(pay),
		agentsafe.WithAuthorizer(agentsafe.AllowList("ops@example.com")))
	if err != nil {
		panic(err)
	}

	ctx := context.Background()
	st, _ := r.Start(ctx, "You pay approved invoices.", "Pay INV-1.")
	fmt.Println(st.Status, "| approver sees:", st.Waiting.Summary)

	// Hours later, possibly in another process: the decision is addressed by the operation's key.
	st, _ = r.Approve(ctx, st.Waiting.Key, "ops@example.com")
	fmt.Println(st.Status, "|", st.Text, "| payments made:", len(gw.payments))
}
Output:
awaiting_approval | approver sees: {"invoice_id":"INV-1","payee":"Ramesh","amount":"4200.50"}
finished | INV-1 is paid. | payments made: 1

Index

Examples

Constants

View Source
const FormatVersion = 6

FormatVersion is the event format this library writes. Logs outlive the code that wrote them (a run paused for approval may be resumed weeks later by a newer version), so every event records the format it was written in, and readers upgrade older events before using them.

Compatibility rules (see FORMAT.md):

  • fields are only ever added, never renamed, removed, or given a new meaning;
  • an optional field whose absence is harmless may be added without a version bump;
  • any change that alters how an existing event must be read bumps FormatVersion and adds an upgrade step below, plus a golden log in testdata/ that must keep rebuilding to the same state.
View Source
const KeyBits = 128

KeyBits is the idempotency key length of new runs: 128 bits, 32 hex characters. A run keeps the length it started with (run_started.key_bits), so keys never change under a run that outlives a library upgrade. 128, not the full 256: the key is also the payment provider's Idempotency-Key, and some providers cap it near UUID length.

View Source
const KindProxy = "proxy"

KindProxy is a run whose calls arrive from outside (Gateway: an MCP proxy, a sidecar) instead of from a model. It has no model decisions, no budget and no final answer: it stays open, call after call.

View Source
const Masked = "[REDACTED]"

Masked replaces every redacted value.

Variables

View Source
var (
	// ErrInvalidTransition: an event isn't allowed in the run's current state. From Rebuild, the log holds an
	// impossible history (a result for a call never started, an approval skipped, ...). From a Runner method,
	// the request doesn't fit the run's state (Extend on a run that isn't paused); nothing was written.
	ErrInvalidTransition = errors.New("agentsafe: event not allowed in the run's current state")

	// ErrRunExists: Start on a log that already has events. Use Continue.
	ErrRunExists = errors.New("agentsafe: the log already holds a run; use Continue")

	// ErrNoRun: Continue on an empty log. Use Start.
	ErrNoRun = errors.New("agentsafe: the log is empty; use Start")

	// ErrAlreadyDecided: Approve or Reject of an operation that was already decided the other way. Deciding the
	// same way twice is a no-op, not an error.
	ErrAlreadyDecided = errors.New("agentsafe: operation already decided; a decision can't be reversed")

	// ErrNotWaiting: Approve or Reject of a key the run isn't waiting on.
	ErrNotWaiting = errors.New("agentsafe: the run isn't waiting for approval of this operation")

	// ErrConfig: the Runner is set up in a way that can't work (see New).
	ErrConfig = errors.New("agentsafe: invalid configuration")
)

Errors a caller can act on, matched with errors.Is. Errors that belong to one feature live next to it: ErrConflict (journal.go), ErrRunLocked and ErrNoLocker (lock.go), ErrNotAuthorized and ErrNoAuthorizer (authz.go), ErrOutcomeUnknown and ErrInDoubt (exec.go), ErrSealed and ErrCannotOpen (seal.go), ErrTampered (chain.go), ErrCorruptLog (eventlog.go), ErrNewerLogFormat (format.go).

View Source
var ErrCannotOpen = errors.New("agentsafe: sealed event can't be opened")

ErrCannotOpen is returned when a sealed event can't be opened: wrong or deleted key, or a modified payload.

View Source
var ErrConflict = errors.New("agentsafe: another writer appended to this run first")

ErrConflict is returned by Append when another writer appended to the run first: this writer's view of the run is stale. Nothing was written. Re-read the run (Continue) rather than retrying the append.

View Source
var ErrCorruptLog = errors.New("agentsafe: log is corrupt")

ErrCorruptLog is returned when acknowledged history is damaged: a line that can't be read with valid lines after it. That is never the result of a crash (a crash only cuts the end off), so it is refused rather than skipped: skipping it would silently delete something that really happened.

View Source
var ErrGatewayClosed = errors.New("agentsafe: gateway is closed")

ErrGatewayClosed is returned after Close.

View Source
var ErrInDoubt = errors.New("agentsafe: operation in doubt; left unresolved, Continue retries it with the same key")

ErrInDoubt is returned when an IdempotentTool's outcome is still unknown after its attempts. Nothing was logged for it: the run stays resumable, and Continue retries it with the same key.

View Source
var ErrNewerLogFormat = errors.New("agentsafe: event was written in a newer log format than this library understands")

ErrNewerLogFormat is returned for an event written by a newer version of this library. It is refused, not guessed at: Go's JSON decoder silently drops unknown fields, and an old reader that skipped a field which mattered would act on a history that never happened.

View Source
var ErrNoAuthorizer = errors.New("agentsafe: no Authorizer configured; set Runner.Authorizer, or Runner.AnyApprover to let anyone decide")

ErrNoAuthorizer is returned when a decision is made on a run with no Authorizer and without an explicit Runner.AnyApprover opt-out: by default, a gate that anyone can open is refused.

View Source
var ErrNoLocker = errors.New("agentsafe: log does not implement Locker; set Runner.Unlocked to run without a single-driver guarantee")

ErrNoLocker is returned when the Log can't guarantee a single driver per run and the Runner wasn't explicitly told to run without one (Runner.Unlocked).

View Source
var ErrNotAuthorized = errors.New("agentsafe: not authorized to decide this approval")

ErrNotAuthorized is returned when the Authorizer refuses a decision. The attempt is recorded in the log (approval_denied) and the run keeps waiting.

View Source
var ErrOutcomeUnknown = errors.New("agentsafe: outcome unknown: the call may or may not have taken effect")

ErrOutcomeUnknown means a call may or may not have taken effect. Tools return it (wrapped) when they can't tell, e.g. a gateway answered 504 after the request was sent. Timeouts and panics are reported with it.

View Source
var ErrRunLocked = errors.New("agentsafe: run is locked by another runner")

ErrRunLocked is returned when another runner is driving the same run. Nothing was done; it is safe to retry later.

View Source
var ErrSealed = errors.New("agentsafe: event is sealed; read the log with its Codec")

ErrSealed is returned when a sealed event reaches code that would need its content: a Log read without the Codec, or Rebuild of events that weren't opened. It is refused, never read as empty fields: an event whose result reads as "" is a different history.

View Source
var ErrStorePoisoned = errors.New("agentsafe: an earlier write to this log failed; open it again to continue from what is on disk")

ErrStorePoisoned is returned by a file log after one of its writes or fsyncs failed: the last line is in doubt, so nothing more is written through it. Open the log again (a new FileLog, typically a new process) to continue from what is on disk.

View Source
var ErrTampered = errors.New("agentsafe: log history was altered")

ErrTampered is returned when a log's history was changed after it was written: a line edited, inserted, deleted or reordered. A tampered run is refused, never resumed: resuming it could act on, say, a forged approval.

Functions

func CallMetaFrom added in v0.5.0

func CallMetaFrom(ctx context.Context) json.RawMessage

CallMetaFrom is the request metadata a call through a Gateway carries (Request.Meta): for an MCP proxy, the client's _meta minus what belongs to its own connection. nil if there is none. It's the same on a retry.

func Canonical

func Canonical(v any) (string, error)

Canonical is the JSON used for hashing: object keys sorted, numbers normalised (4200 == 4200.0).

func KeyFrom

func KeyFrom(ctx context.Context) string

KeyFrom returns the idempotency key of the operation being executed, inside a Func made Idempotent.

func Str

func Str(s string) *string

Str returns a pointer to s, for Message.Content.

Types

type AESGCM

type AESGCM struct {
	Keys    map[string][]byte // key id -> 32-byte key
	Current string            // the id new events are sealed with
}

AESGCM is a Codec using AES-256-GCM with a keyring, so keys can be rotated: new events are sealed with Current, and any key still in Keys opens what it sealed. Removing a key from Keys erases every event sealed with it. Keep keys off the log's host (a KMS, a secrets manager).

Example

A sealed log keeps arguments and results encrypted at rest; the audit trail (who approved what) stays readable, and resume works with the key.

package main

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"

	"github.com/Ashutosh2308Bhardwaj/agentsafe"
)

type Invoice struct {
	InvoiceID string            `json:"invoice_id" desc:"the invoice being paid"`
	Payee     string            `json:"payee"`
	Amount    agentsafe.Decimal `json:"amount" desc:"in rupees, e.g. 4200.50"`
}

// gateway is a stand-in payment system: one payment per idempotency key.
type gateway struct{ payments map[string]string }

func (g *gateway) pay(ctx context.Context, in Invoice) (string, error) {
	key := agentsafe.KeyFrom(ctx)
	if _, done := g.payments[key]; !done {
		g.payments[key] = fmt.Sprintf("%s %s", in.Payee, in.Amount)
	}
	return "paid", nil
}

func tempLog() (*agentsafe.FileLog, func()) {
	dir, err := os.MkdirTemp("", "agentsafe-example")
	if err != nil {
		panic(err)
	}
	return &agentsafe.FileLog{Path: filepath.Join(dir, "run.jsonl")}, func() { _ = os.RemoveAll(dir) }
}

const payINV1 = `{"invoice_id":"INV-1","payee":"Ramesh","amount":"4200.50"}`

func main() {
	key := bytes.Repeat([]byte{7}, 32) // in production: from a KMS, never next to the log
	log, cleanup := tempLog()
	defer cleanup()
	log.Codec = agentsafe.AESGCM{Keys: map[string][]byte{"2026-10": key}, Current: "2026-10"}

	gw := &gateway{payments: map[string]string{}}
	r, _ := agentsafe.New(&agentsafe.ScriptedModel{Plan: []agentsafe.FunctionCall{{Name: "send_payout", Arguments: payINV1}}, Final: "done"},
		log, agentsafe.WithTools(agentsafe.Func("send_payout", "Pay", gw.pay, agentsafe.Idempotent("invoice_id"))))
	_, _ = r.Start(context.Background(), "sys", "Pay INV-1.")

	raw, _ := os.ReadFile(log.Path)
	fmt.Println("amount on disk:", bytes.Contains(raw, []byte("4200.50")))
	_, err := (&agentsafe.FileLog{Path: log.Path}).Read(context.Background())
	fmt.Println("readable without the key:", !errors.Is(err, agentsafe.ErrSealed))
	events, _ := log.Read(context.Background())
	fmt.Println("events with the key:", len(events))
}
Output:
amount on disk: false
readable without the key: false
events with the key: 6

func (AESGCM) Open

func (a AESGCM) Open(_ context.Context, ciphertext, aad []byte) ([]byte, error)

Open implements Codec.

func (AESGCM) Seal

func (a AESGCM) Seal(_ context.Context, plaintext, aad []byte) ([]byte, error)

Seal implements Codec. Output: len(id) | id | 12-byte random nonce | ciphertext+tag.

type Approval

type Approval struct {
	By           string          // the verified identity making the decision
	Decision     string          // "approved" or "rejected"
	Reason       string          // the decider's reason (rejections)
	Tool         string          // the gated tool
	Key          string          // the operation's idempotency key
	Summary      json.RawMessage // what the approver was shown: the VALIDATED arguments (payee, amount, ...)
	RunStartedBy string          // who started the run (Runner.StartedBy), for separation of duties
}

Approval is what an Authorizer decides on.

Authentication is your system's job: By must be an identity you have already verified (SSO, API key, mTLS). agentsafe only authorizes: is this identity allowed to make this decision about this operation?

type Authorizer

type Authorizer interface {
	Authorize(ctx context.Context, a Approval) error
}

An Authorizer allows (nil) or refuses (an error saying why) one approval decision.

func All

func All(authorizers ...Authorizer) Authorizer

All allows a decision only if every Authorizer allows it; the first refusal is returned.

func AllowList

func AllowList(identities ...string) Authorizer

AllowList allows decisions only by the listed identities.

func NotRequester

func NotRequester() Authorizer

NotRequester enforces separation of duties (maker-checker): whoever started the run can't decide its approvals. A run started without Runner.StartedBy has no recorded requester, and is refused: the rule can't be checked, so it isn't silently skipped.

type AuthorizerFunc

type AuthorizerFunc func(ctx context.Context, a Approval) error

AuthorizerFunc adapts a function to an Authorizer, for custom policies such as amount thresholds.

func (AuthorizerFunc) Authorize

func (f AuthorizerFunc) Authorize(ctx context.Context, a Approval) error

Authorize implements Authorizer.

type Codec

type Codec interface {
	Seal(ctx context.Context, plaintext, aad []byte) ([]byte, error)
	Open(ctx context.Context, ciphertext, aad []byte) ([]byte, error)
}

A Codec encrypts and decrypts a sealed event's content. aad is the event's position (seq, type, call id): a Codec should authenticate it, so a sealed payload moved onto another line fails to open.

type Decimal

type Decimal string

Decimal is an exact decimal number written as text: "4200.50". Use it for money taken from a CSV, a DECIMAL column or a JSON string, so it is compared by value (Decimal("4200.50") equals 4200.5 and Decimal("4200.5")) rather than as text, and never through a binary float.

type Decision

type Decision struct {
	Message      Message
	FinishReason string // "tool_calls", "stop", "length" (truncated: week 1 F1), ...
	Usage        Usage
}

Decision is one model response: either tool calls to run, or a final answer.

type Describer

type Describer interface {
	Describe() (provider, model string)
}

Describer is optionally implemented by a Model so the run's log records which provider and model ran it.

type Event

type Event struct {
	V    int       `json:"v,omitempty"` // log format version (FormatVersion); absent = 0, written before versions existed
	Seq  int       `json:"seq"`
	Prev string    `json:"prev,omitempty"` // hash of the previous line as written (FileLog's chain); see chain.go
	Type EventType `json:"type"`
	Time time.Time `json:"time"`

	// run_started
	Kind     string `json:"kind,omitempty"` // "proxy" (v5): calls arrive from outside, not from a model; absent = an agent run
	System   string `json:"system,omitempty"`
	Task     string `json:"task,omitempty"`
	MaxSteps int    `json:"max_steps,omitempty"` // the budget lives in the log, not in the runner's config
	KeyBits  int    `json:"key_bits,omitempty"`  // idempotency key length for this run (v4+); fixed for the run's life
	Provider string `json:"provider,omitempty"`  // e.g. "groq": so a trace can be built from the log alone
	Model    string `json:"model,omitempty"`

	// run_paused / budget_extended / approvals
	Reason     string `json:"reason,omitempty"`
	ExtraSteps int    `json:"extra_steps,omitempty"`
	By         string `json:"by,omitempty"` // who extended, decided, or (run_started, v2) started the run

	// model_decided
	Step         int      `json:"step,omitempty"`
	Message      *Message `json:"message,omitempty"`
	FinishReason string   `json:"finish_reason,omitempty"`
	Usage        *Usage   `json:"usage,omitempty"`

	// call_received (v5): who sent it, as the client reports itself (unverified: for audit display only)
	Client string `json:"client,omitempty"`
	// call_received (v6): request metadata the gateway's tool receives with the call (CallMetaFrom), e.g. the
	// client's MCP _meta minus what belongs to its own connection. Recorded so a retry after a crash sends the
	// same request. Content: sealed with the rest.
	Meta string `json:"meta,omitempty"` // JSON text, like args

	// tool_started / tool_result
	CallID string `json:"call_id,omitempty"`
	Tool   string `json:"tool,omitempty"`
	Args   string `json:"args,omitempty"`
	Result string `json:"result,omitempty"` // JSON, exactly what the model receives

	// tool_started / tool_result for an IdempotentTool
	Key         string `json:"key,omitempty"`          // derived from the operation's identity, never from the call id
	PayloadHash string `json:"payload_hash,omitempty"` // compared on replay: same key + different payload = conflict
	Replayed    bool   `json:"replayed,omitempty"`     // the result came from an earlier tool_result, nothing ran

	// approval_requested / approval_decided / tool_refused
	Summary  string `json:"summary,omitempty"`  // what the approver is shown: VALIDATED values, as JSON
	Decision string `json:"decision,omitempty"` // "approved" | "rejected"

	// run_finished
	Stop string `json:"stop,omitempty"`
	Text string `json:"text,omitempty"`

	// Sealed (format v3) holds the content fields above, encrypted by a Codec; they are empty while it is
	// set. See seal.go.
	Sealed string `json:"sealed,omitempty"`
}

Event is one line of the log. Fields are used per type; the rest are omitted.

func OpenEvent added in v0.2.0

func OpenEvent(ctx context.Context, e Event, c Codec) (Event, error)

OpenEvent restores a sealed event's content. An unsealed event is returned as it is.

func SealEvent added in v0.2.0

func SealEvent(ctx context.Context, e Event, c Codec) (Event, error)

SealEvent moves e's content into e.Sealed, encrypted by c. Seq, Type and CallID must be final (they're authenticated). Storage backends call it before writing; an event with no content is left as it is.

func Upgrade

func Upgrade(e Event) (Event, error)

Upgrade brings one event to FormatVersion, or refuses it.

func UpgradeAll

func UpgradeAll(events []Event) ([]Event, error)

UpgradeAll brings every event to FormatVersion, or refuses the log at the first event it can't read.

type EventType

type EventType string

EventType is what happened. These eleven types are the whole vocabulary of a run.

const (
	EvRunStarted   EventType = "run_started"   // system prompt + task: the run's inputs
	EvModelDecided EventType = "model_decided" // the model's full response, logged BEFORE anything acts on it
	EvToolStarted  EventType = "tool_started"  // the runner is about to execute a call: "maybe executed" from here on
	EvToolResult   EventType = "tool_result"   // the call's outcome, as sent back to the model
	EvRunFinished  EventType = "run_finished"  // stop reason + final text. Terminal: nothing follows it.

	EvRunPaused      EventType = "run_paused"      // budget exhausted: stopped, NOT finished (week 3 S1 review, Q3)
	EvBudgetExtended EventType = "budget_extended" // someone decided to give a paused run more steps: logged, never silent

	EvToolRefused       EventType = "tool_refused"       // a pending call resolved WITHOUT being attempted: failed validation, or rejected
	EvApprovalRequested EventType = "approval_requested" // a gated call is waiting for a human: the run is paused, durably
	EvApprovalDecided   EventType = "approval_decided"   // approved or rejected, by whom, why
	EvApprovalDenied    EventType = "approval_denied"    // a decision the Authorizer refused (format v2): who tried, and why not
	EvCallReceived      EventType = "call_received"      // a proxy run (format v5): a call arrived from outside, e.g. an MCP client
)

The event types. A run's log is a sequence of these and nothing else.

type FileLog

type FileLog struct {
	Path string
	// Key, if set, makes the chain an HMAC-SHA256 chain: without the key, an attacker who rewrites the whole
	// file can't recompute the links. Use the same key for a log's whole life; keep it off the log's host.
	Key []byte
	// Codec, if set, seals each event's content before it is written and opens it on Read (seal.go). The
	// chain covers the sealed line, so tampering is still detected without the Codec's keys.
	Codec Codec
	// contains filtered or unexported fields
}

FileLog is a JSON-lines Log on disk: a Journal over a file. Every Append is fsync'd before it returns, so an event that Append reported as written survives kill -9 and power loss. A torn last line (a crash in the middle of a write) is ignored by Read and cut off by the next Append; see filestore.go.

func (*FileLog) Append

func (l *FileLog) Append(ctx context.Context, e Event) error

Append writes e as the next line. Seq and Prev are set here, and Time unless e has one.

func (*FileLog) Head

func (l *FileLog) Head(ctx context.Context) (string, error)

Head returns the hash of the last acknowledged line: anchor it outside the log to make an edit of the last event, or a truncation, detectable. An empty log's head is "genesis".

func (*FileLog) Lock

func (l *FileLog) Lock(ctx context.Context) (func() error, error)

Lock takes the run's lease: an exclusive OS lock on "<Path>.lock", released by the OS when the holder exits, however it exits (filestore.go).

func (*FileLog) Read

func (l *FileLog) Read(ctx context.Context) ([]Event, error)

Read returns every acknowledged event in order, ignoring a torn tail. A missing file is an empty log. Sealed events are opened with the Codec; without it, a sealed log is refused (ErrSealed).

type FuncOption

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

FuncOption configures a Func.

func ApprovalIf

func ApprovalIf[In any](when func(In) bool, summary func(In) any) FuncOption

ApprovalIf is NeedsApproval for some calls only, e.g. payouts above a threshold.

func Check

func Check[In any](check func(ctx context.Context, in In) error) FuncOption

Check validates the arguments against your source of truth before anything runs or anyone is asked to approve. Its error goes back to the model, which can correct itself; nothing is executed.

func Idempotent

func Idempotent(fields ...string) FuncOption

Idempotent makes the tool act at most once per operation. The operation is identified by these fields of the input (business identity, e.g. "invoice_id"); every other field is the payload, compared on replay: the same identity with a different payload is a conflict the model is told about, not a second action.

func NeedsApproval

func NeedsApproval[In any](summary func(In) any) FuncOption

NeedsApproval makes every call wait for a human decision (Runner.Approve / Reject). summary is what the approver is shown; it receives the arguments after Check has passed.

func Timeout

func Timeout(d time.Duration) FuncOption

Timeout bounds each call of this tool, overriding the Runner's default.

type FunctionCall

type FunctionCall struct {
	Name      string `json:"name"`
	Arguments string `json:"arguments"`
}

FunctionCall names the tool and carries its arguments as a JSON string, exactly as the model produced them. Parse, never trust (week 1 S2).

type Gated

type Gated interface {
	// NeedsApproval reports whether this particular call must be approved.
	NeedsApproval(args json.RawMessage) bool
	// Summary is what the approver is shown. It is built from VALIDATED arguments, so the human signs off on
	// checked values, never on the model's raw proposal.
	Summary(args json.RawMessage) (any, error)
}

Gated is a tool whose calls need a human decision before they run: the irreversible ones.

type Gateway added in v0.4.0

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

Gateway handles tool calls that arrive from outside an agent loop: an MCP proxy forwarding a client's calls, a sidecar in front of an API. Each call gets what a Runner gives a model's call: it's logged before it runs (write-ahead), a repeated operation is answered from the log instead of running again, the same operation with different values is a conflict, an IdempotentTool gets its key, and a Gated tool waits for a human. The log holds a proxy run (format v5) that stays open, call after call.

Every Call, Approve, Reject and Pending takes the log's lease, reads the run, settles a call a crash left unfinished, acts, and releases: so a decision can come from another process (a person approving from the command line), and gateways in several processes can share one log, their calls taking turns.

func OpenGateway added in v0.4.0

func OpenGateway(ctx context.Context, log Log, opts ...Option) (*Gateway, error)

OpenGateway checks the configuration, starts a proxy run if the log is empty, and settles any call a crash left unfinished. Options are the Runner's: WithTools, WithScope, WithStartedBy, WithAuthorizer (or WithAnyApprover), WithToolTimeout, WithHook, WithLogf, WithRedactor, WithoutLease. A model doesn't apply.

func (*Gateway) Approve added in v0.4.0

func (g *Gateway) Approve(ctx context.Context, key, by string) error

Approve records an approval for the operation key, by an identity your system verified, if the Authorizer allows it (a refused attempt is logged too). The operation runs when it's next called. Approving an approved operation again changes nothing.

func (*Gateway) Call added in v0.4.0

func (g *Gateway) Call(ctx context.Context, client, tool string, args json.RawMessage) (GatewayResult, error)

Call handles one call without metadata: Handle(ctx, Request{Client: client, Tool: tool, Args: args}).

func (*Gateway) Close added in v0.4.0

func (g *Gateway) Close() error

Close stops the gateway: calls after it are refused. It holds no lease between calls, so there's nothing else to release.

func (*Gateway) Handle added in v0.5.0

func (g *Gateway) Handle(ctx context.Context, req Request) (GatewayResult, error)

Handle handles one call. An error means the call's outcome is unknown, or the log couldn't be written: nothing more can be said about it, and the next call (or a restart) settles it first.

func (*Gateway) Pending added in v0.4.0

func (g *Gateway) Pending(ctx context.Context) ([]Waiting, error)

Pending lists the operations waiting for a decision, oldest first: what an approver is shown.

func (*Gateway) Reject added in v0.4.0

func (g *Gateway) Reject(ctx context.Context, key, by, reason string) error

Reject records a rejection: the operation is refused whenever it's called again.

func (*Gateway) Tools added in v0.4.0

func (g *Gateway) Tools() []ToolSpec

Tools are the tools the gateway serves.

type GatewayResult added in v0.4.0

type GatewayResult struct {
	Result   string // the tool's result as JSON, or {"error": ...} when it failed, was refused or is in doubt
	Replayed bool   // answered from the log: the operation had already happened, and nothing ran
	Refused  bool   // never attempted (refused before it ran, or waiting for approval)
	Pending  bool   // waiting for a human decision: call again with the same arguments for the outcome
}

GatewayResult is what became of a call.

type IdempotentTool

type IdempotentTool interface {
	Tool
	// Identity returns the operation's identity (hashed into the key) and its payload (compared on replay).
	Identity(args json.RawMessage) (identity, payload any, err error)
	// CallWithKey performs the effect at most once per key. On a repeated key it must return the original
	// outcome and must NOT perform the effect again. That includes CONCURRENT calls with one key: after a
	// timeout the runner retries while the first call may still be running (exec.go). Return an error
	// wrapping ErrOutcomeUnknown when you can't tell whether the effect happened (e.g. a 504).
	CallWithKey(ctx context.Context, key string, args json.RawMessage) (any, error)
}

IdempotentTool is a tool with a side effect that must happen at most once per operation.

The library does three things for it (week 2, now in one place, so no tool author can forget them):

  1. derives a key from Identity(), the fields that DEFINE the operation. Never the model's call id (it changes on retry, week 1 F9), never free text (a reworded note isn't a new operation);
  2. before calling the tool, looks the key up in the run's log: same key + same payload → replay the recorded result without calling anything; same key + different payload → conflict error, nothing runs;
  3. passes the key INTO the tool. That covers the one case the log can't: a crash after the effect but before its result was logged. The tool must make its effect idempotent on the key (a unique column in your own database, or an Idempotency-Key header on a gateway). That's option (b) from week 2, and it's what the transactional outbox needs for effects outside your database.

type Journal

type Journal struct {
	Store LineStore
	Key   []byte
	Codec Codec
	// contains filtered or unexported fields
}

Journal is a Log over any LineStore. Key makes the hash chain an HMAC chain (chain.go); Codec seals content at rest (seal.go).

func (*Journal) Append

func (j *Journal) Append(ctx context.Context, e Event) error

Append writes e as the next event. Seq and Prev are set here, and Time unless the caller set it (the Runner does, so its state and its log agree on when an outcome was recorded).

func (*Journal) CanLock added in v0.3.0

func (j *Journal) CanLock() bool

CanLock reports whether the store has a lease (implements Locker). Runner.Validate asks, so a Journal over a store without one is refused by New, not at the first Start.

func (*Journal) Head

func (j *Journal) Head(ctx context.Context) (string, error)

Head returns the chain link after the last event: anchor it outside the log (chain.go).

func (*Journal) Lock

func (j *Journal) Lock(ctx context.Context) (func() error, error)

Lock takes the store's lease when it has one (Locker). Afterwards the next append re-reads the run: another runner may have written while this one didn't hold the lease.

func (*Journal) Read

func (j *Journal) Read(ctx context.Context) ([]Event, error)

Read returns every acknowledged event in order, chain-verified, with sealed events opened.

type KeyHonouring added in v0.4.0

type KeyHonouring interface {
	HonoursKey() bool
}

KeyHonouring is implemented by an IdempotentTool that knows whether the system it calls deduplicates on the key. A key does two jobs: the log answers an operation it saw finish (always), and a retry after an outcome the log couldn't see is safe (only if the downstream honours the key). A tool that reports false keeps the first job and gives up the second: an unknown outcome is never retried. The call is recorded as "outcome unknown" under its key, so asking for the operation again gets that answer instead of a second attempt.

A tool that doesn't implement KeyHonouring honours its key: that's what IdempotentTool asks of CallWithKey. An MCP server with no way to receive a key is the case this exists for (agentsafe/mcp).

type LineStore

type LineStore interface {
	// ReadLines returns every acknowledged line, in order, without line endings. It must never return a line
	// whose AppendLine didn't succeed (a torn write), and must return every line whose AppendLine did.
	ReadLines(ctx context.Context) ([][]byte, error)
	// AppendLine durably stores line as number seq (1-based), only if exactly seq-1 lines exist. Otherwise it
	// returns an error wrapping ErrConflict and stores nothing. The check and the write must be atomic against
	// every other writer of the run, in any process: a unique (run, seq) constraint, or a lock around both.
	// line is one line of JSON, without a line ending.
	AppendLine(ctx context.Context, seq int, line []byte) error
}

LineStore is the storage contract a backend implements for ONE run.

func NewFileStore

func NewFileStore(path string) LineStore

NewFileStore returns the file LineStore FileLog uses, for building a Journal yourself. It is also a Locker.

type Locker

type Locker interface {
	Lock(ctx context.Context) (unlock func() error, err error)
}

Locker is implemented by a Log that can guarantee only one runner drives a run at a time.

Lock must not block: if the run is held, it returns an error wrapping ErrRunLocked. The returned function releases the lock. A lock MUST be released automatically if its holder dies (crash, kill -9), or a dead process would hold the run forever.

Why it matters: without it, two processes resuming the same run (a scheduler retry, two workers) both ask the model and both act. Idempotency keys stop most duplicate effects, but the two runners interleave events in one log and the run's history stops making sense.

type Log

type Log interface {
	Append(ctx context.Context, e Event) error
	Read(ctx context.Context) ([]Event, error)
}

Log is an append-only record of a run. It is the source of truth: state is derived from it (Rebuild), never kept only in memory (week 2 W2-5).

type Message

type Message struct {
	Role       Role       `json:"role"`
	Content    *string    `json:"content"`
	ToolCalls  []ToolCall `json:"tool_calls,omitempty"`
	ToolCallID string     `json:"tool_call_id,omitempty"`

	// Native is the provider's own form of an assistant message, kept so the adapter that produced it can send
	// it back byte for byte (e.g. Claude's thinking blocks, which are bound to the exact conversation before
	// them: a resumed run that rebuilt them differently would be rejected). Other adapters ignore it. It is
	// logged, and sealed with the rest of the message.
	Native json.RawMessage `json:"native,omitempty"`
}

Message is one entry in the conversation, in the OpenAI-compatible wire format. Content is a pointer because an assistant message that only carries tool calls has content null.

type Model

type Model interface {
	Decide(ctx context.Context, messages []Message, tools []ToolSpec) (Decision, error)
}

Model turns a conversation into the next Decision. It must be side-effect free: the runner may call it again after a crash that lost a response (week 2 S3, after_model_call), and that has to be harmless.

type Option

type Option func(*Runner)

An Option configures a Runner built by New.

func WithAnyApprover

func WithAnyApprover() Option

WithAnyApprover lets anyone decide gated calls: an explicit, visible choice.

func WithAuthorizer

func WithAuthorizer(a Authorizer) Option

WithAuthorizer sets who may approve or reject gated calls.

func WithHook

func WithHook(h func(point string)) Option

WithHook sets a chaos/test hook, called at named points of a run (e.g. to kill the process there).

func WithLogf

func WithLogf(f func(format string, args ...any)) Option

WithLogf sets progress output (nil = silent).

func WithMaxSteps

func WithMaxSteps(n int) Option

WithMaxSteps sets the step budget for new runs (default 8). Resumed runs keep the budget in their log.

func WithRedactor

func WithRedactor(rd Redactor) Option

WithRedactor masks arguments, results and summaries in progress output.

func WithScope

func WithScope(scope string) Option

WithScope sets the idempotency scope: runs with the same scope share operation keys (e.g. a hash of the input files, so re-running the same batch can't repeat its payouts).

func WithStartedBy

func WithStartedBy(identity string) Option

WithStartedBy records who starts runs (an identity your system verified), for NotRequester.

func WithToolRetries

func WithToolRetries(attempts int, backoff time.Duration) Option

WithToolRetries sets how an IdempotentTool whose outcome is unknown is retried with the same key: up to attempts tries, waiting backoff (doubling) between them.

func WithToolTimeout

func WithToolTimeout(d time.Duration) Option

WithToolTimeout bounds every tool call (a TimeoutTool sets its own).

func WithTools

func WithTools(tools ...Tool) Option

WithTools adds tools the model may call.

func WithoutLease

func WithoutLease() Option

WithoutLease runs on a Log that has no lease (Locker): a visible choice to rely on fencing (ErrConflict) alone, accepting that two runners may both call the model before one of them is stopped.

type Outcome

type Outcome struct {
	PayloadHash string
	Result      string
	At          time.Time // when it was recorded: a ReplayWindowTool's window runs from here
	Unknown     bool      // recorded as an unknown outcome (ErrOutcomeUnknown): it may have happened, so it never expires
}

Outcome is the recorded result of a completed idempotent call.

type PanicError

type PanicError struct {
	Tool  string
	Value any
	Stack []byte
}

PanicError is a tool panic, recovered. It matches ErrOutcomeUnknown: the tool may have acted before panicking.

func (*PanicError) Error

func (p *PanicError) Error() string

func (*PanicError) Is

func (p *PanicError) Is(target error) bool

Is makes errors.Is(p, ErrOutcomeUnknown) true.

type Redactor

type Redactor func(s string) string

A Redactor masks sensitive parts of one value: a tool's arguments, a result, an approval summary, or an error message. It returns the value to show. It must not fail: when unsure, mask more.

func RedactFields

func RedactFields(keys ...string) Redactor

RedactFields masks the values of the named JSON keys at any depth, keeping the structure readable: {"payee":"Ramesh","amount":100} becomes {"payee":"[REDACTED]","amount":100}. A value that isn't JSON (an error message, plain text) is returned unchanged: pair it with RedactPattern for free text.

func RedactPattern

func RedactPattern(patterns ...*regexp.Regexp) Redactor

RedactPattern masks every match of the patterns, in any text: e.g. account numbers in an error message, `\b\d{9,18}\b`.

func Redactors

func Redactors(rs ...Redactor) Redactor

Redactors applies each Redactor in turn.

Example

Redactors mask what leaves the process (console output, exported traces); the log keeps real values.

package main

import (
	"fmt"
	"regexp"

	"github.com/Ashutosh2308Bhardwaj/agentsafe"
)

func main() {
	r := agentsafe.Redactors(agentsafe.RedactFields("payee", "account"),
		agentsafe.RedactPattern(regexp.MustCompile(`\b\d{9,18}\b`)))
	fmt.Println(r(`{"payee":"Ramesh","amount":"4200.50"}`))
	fmt.Println(r(`account 9876543210 is frozen`))
}
Output:
{"amount":"4200.50","payee":"[REDACTED]"}
account [REDACTED] is frozen

type ReplayWindowTool added in v0.6.0

type ReplayWindowTool interface {
	ReplayWindow() time.Duration
}

ReplayWindowTool is implemented by an IdempotentTool whose operations are remembered only for a while. A repeat within ReplayWindow of the recorded outcome is answered from the log; after it, the same identity is a new operation, with a new key (so a downstream that deduplicates on the key acts on it too). Without a window, an identity that isn't unique to one operation (every argument, say) answers a deliberate repeat a week later with the first result, and nothing happens. An outcome recorded as unknown never expires: it may have happened, and expiring it would retry it. 0 means the operation is remembered for the life of the log.

type Request added in v0.5.0

type Request struct {
	Client string          // who sent it, as the client reports itself: recorded for audit, not trusted
	Tool   string          // the tool to call
	Args   json.RawMessage // its arguments
	// Meta is metadata the tool receives with the call (CallMetaFrom): a JSON object, or empty. It's recorded
	// (sealed, like the arguments) so a retry after a crash sends the same request, and it's not part of the
	// operation: two calls that differ only in Meta are the same operation.
	Meta json.RawMessage
}

Request is one call from outside.

type Role

type Role string

Role is who wrote a message in the conversation.

const (
	RoleSystem    Role = "system"
	RoleUser      Role = "user"
	RoleAssistant Role = "assistant"
	RoleTool      Role = "tool"
)

The roles in a conversation.

type Runner

type Runner struct {
	Model    Model
	Tools    []Tool
	Log      Log
	MaxSteps int                  // budget for a NEW run (logged in run_started); 0 = 8. Resumes use the logged budget.
	Scope    string               // idempotency scope, e.g. a hash of the input files; "" = per log
	Logf     func(string, ...any) // progress output; nil = silent
	Redact   Redactor             // masks arguments, results and summaries in Logf output (redact.go); nil = shown as is
	Hook     func(point string)   // test/chaos hook, called at named points; nil = none

	// Tool calls (exec.go). ToolTimeout bounds each call (a TimeoutTool sets its own); 0 = no limit. When an
	// IdempotentTool's outcome is unknown (timeout, ErrOutcomeUnknown), it's retried with the same key up to
	// ToolAttempts times (0 = 3), waiting ToolBackoff (0 = 200ms) and doubling between tries.
	ToolTimeout  time.Duration
	ToolAttempts int
	ToolBackoff  time.Duration

	// Authorizer decides who may approve or reject gated calls. Without one, decisions are refused
	// (ErrNoAuthorizer) unless AnyApprover is set: a gate anyone can open must be a visible choice.
	Authorizer  Authorizer
	AnyApprover bool
	// StartedBy records who started the run (an identity your system verified); NotRequester uses it.
	StartedBy string

	// Unlocked runs without a single-driver guarantee when the Log doesn't implement Locker. Off by default:
	// running a run from two processes at once must be a visible choice, not a silent gap.
	Unlocked bool
	// contains filtered or unexported fields
}

Runner drives a run from its log. It holds no run state of its own: everything it knows about the run comes from Rebuild(log), so a fresh Runner on the same log continues the same run.

func New

func New(model Model, log Log, opts ...Option) (*Runner, error)

New builds a Runner and checks its configuration, so mistakes surface at startup rather than in the middle of a run: a gated tool that isn't idempotent, approvals with no policy, a log with no lease, duplicate tool names. Every problem is reported at once, wrapped in ErrConfig.

r, err := agentsafe.New(model, &agentsafe.FileLog{Path: "run.jsonl"},
	agentsafe.WithTools(payout),
	agentsafe.WithAuthorizer(agentsafe.AllowList("ops@example.com")),
	agentsafe.WithStartedBy("scheduler"))

A Runner can also be written as a struct literal: it is validated the same way the first time it's used (every Start, Continue, Approve, Reject and Extend checks it), so both forms behave alike.

Example

New checks the whole configuration before any money can move, and reports every problem at once.

package main

import (
	"context"
	"errors"
	"fmt"
	"strings"

	"github.com/Ashutosh2308Bhardwaj/agentsafe"
)

type Invoice struct {
	InvoiceID string            `json:"invoice_id" desc:"the invoice being paid"`
	Payee     string            `json:"payee"`
	Amount    agentsafe.Decimal `json:"amount" desc:"in rupees, e.g. 4200.50"`
}

// gateway is a stand-in payment system: one payment per idempotency key.
type gateway struct{ payments map[string]string }

func (g *gateway) pay(ctx context.Context, in Invoice) (string, error) {
	key := agentsafe.KeyFrom(ctx)
	if _, done := g.payments[key]; !done {
		g.payments[key] = fmt.Sprintf("%s %s", in.Payee, in.Amount)
	}
	return "paid", nil
}

func main() {
	gw := &gateway{payments: map[string]string{}}
	unsafe := agentsafe.Func("send_payout", "Pay", gw.pay,
		agentsafe.NeedsApproval(func(in Invoice) any { return in })) // approval without Idempotent
	_, err := agentsafe.New(&agentsafe.ScriptedModel{}, &agentsafe.FileLog{Path: "unused.jsonl"}, agentsafe.WithTools(unsafe))
	fmt.Println(errors.Is(err, agentsafe.ErrConfig))
	fmt.Println(strings.Contains(err.Error(), "NeedsApproval requires Idempotent"))
	fmt.Println(strings.Contains(err.Error(), "nobody may give it")) // no Authorizer either
}
Output:
true
true
true

func (*Runner) Approve

func (r *Runner) Approve(ctx context.Context, key, by string) (State, error)

Approve records an approval for the operation `key` (the payout's idempotency key, not the model's call id) and continues the run. Approving an already-approved operation is a no-op.

Example

Approvals are authorized: who may decide, and maker-checker.

package main

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"

	"github.com/Ashutosh2308Bhardwaj/agentsafe"
)

type Invoice struct {
	InvoiceID string            `json:"invoice_id" desc:"the invoice being paid"`
	Payee     string            `json:"payee"`
	Amount    agentsafe.Decimal `json:"amount" desc:"in rupees, e.g. 4200.50"`
}

// gateway is a stand-in payment system: one payment per idempotency key.
type gateway struct{ payments map[string]string }

func (g *gateway) pay(ctx context.Context, in Invoice) (string, error) {
	key := agentsafe.KeyFrom(ctx)
	if _, done := g.payments[key]; !done {
		g.payments[key] = fmt.Sprintf("%s %s", in.Payee, in.Amount)
	}
	return "paid", nil
}

func tempLog() (*agentsafe.FileLog, func()) {
	dir, err := os.MkdirTemp("", "agentsafe-example")
	if err != nil {
		panic(err)
	}
	return &agentsafe.FileLog{Path: filepath.Join(dir, "run.jsonl")}, func() { _ = os.RemoveAll(dir) }
}

const payINV1 = `{"invoice_id":"INV-1","payee":"Ramesh","amount":"4200.50"}`

func main() {
	log, cleanup := tempLog()
	defer cleanup()
	gw := &gateway{payments: map[string]string{}}
	pay := agentsafe.Func("send_payout", "Pay", gw.pay, agentsafe.Idempotent("invoice_id"),
		agentsafe.NeedsApproval(func(in Invoice) any { return in }))
	r, _ := agentsafe.New(&agentsafe.ScriptedModel{Plan: []agentsafe.FunctionCall{{Name: "send_payout", Arguments: payINV1}}, Final: "done"},
		log, agentsafe.WithTools(pay), agentsafe.WithStartedBy("ops@example.com"),
		agentsafe.WithAuthorizer(agentsafe.All(agentsafe.AllowList("ops@example.com", "cfo@example.com"), agentsafe.NotRequester())))

	ctx := context.Background()
	st, _ := r.Start(ctx, "sys", "Pay INV-1.")
	_, err := r.Approve(ctx, st.Waiting.Key, "intern@example.com")
	fmt.Println(errors.Is(err, agentsafe.ErrNotAuthorized), "- not on the list")
	_, err = r.Approve(ctx, st.Waiting.Key, "ops@example.com")
	fmt.Println(errors.Is(err, agentsafe.ErrNotAuthorized), "- started the run, can't approve it")
	st, _ = r.Approve(ctx, st.Waiting.Key, "cfo@example.com")
	fmt.Println(st.Status, "| payments made:", len(gw.payments), "| refused attempts on record:", st.Denials)
}
Output:
true - not on the list
true - started the run, can't approve it
finished | payments made: 1 | refused attempts on record: 2

func (*Runner) Continue

func (r *Runner) Continue(ctx context.Context) (State, error)

Continue rebuilds the run from its log and drives it until it finishes, pauses, or errors.

Example

A run that crashes after the payment but before its result was logged pays once on resume: the same key goes back to the gateway, which returns the original payment.

package main

import (
	"context"
	"fmt"
	"os"
	"path/filepath"

	"github.com/Ashutosh2308Bhardwaj/agentsafe"
)

type Invoice struct {
	InvoiceID string            `json:"invoice_id" desc:"the invoice being paid"`
	Payee     string            `json:"payee"`
	Amount    agentsafe.Decimal `json:"amount" desc:"in rupees, e.g. 4200.50"`
}

// gateway is a stand-in payment system: one payment per idempotency key.
type gateway struct{ payments map[string]string }

func (g *gateway) pay(ctx context.Context, in Invoice) (string, error) {
	key := agentsafe.KeyFrom(ctx)
	if _, done := g.payments[key]; !done {
		g.payments[key] = fmt.Sprintf("%s %s", in.Payee, in.Amount)
	}
	return "paid", nil
}

func tempLog() (*agentsafe.FileLog, func()) {
	dir, err := os.MkdirTemp("", "agentsafe-example")
	if err != nil {
		panic(err)
	}
	return &agentsafe.FileLog{Path: filepath.Join(dir, "run.jsonl")}, func() { _ = os.RemoveAll(dir) }
}

const payINV1 = `{"invoice_id":"INV-1","payee":"Ramesh","amount":"4200.50"}`

func main() {
	log, cleanup := tempLog()
	defer cleanup()
	gw := &gateway{payments: map[string]string{}}
	pay := agentsafe.Func("send_payout", "Pay", gw.pay, agentsafe.Idempotent("invoice_id"))
	model := &agentsafe.ScriptedModel{Plan: []agentsafe.FunctionCall{{Name: "send_payout", Arguments: payINV1}}, Final: "done"}

	crashing, _ := agentsafe.New(model, log, agentsafe.WithTools(pay), agentsafe.WithHook(func(point string) {
		if point == "after_tool_executed" {
			panic("kill -9") // the payment happened; its result was never logged
		}
	}))
	func() {
		defer func() { _ = recover() }()
		_, _ = crashing.Start(context.Background(), "sys", "Pay INV-1.")
	}()

	// A new process, the same log.
	resumed, _ := agentsafe.New(model, &agentsafe.FileLog{Path: log.Path}, agentsafe.WithTools(pay))
	st, err := resumed.Continue(context.Background())
	fmt.Println(st.Status, err, "| payments made:", len(gw.payments))
}
Output:
finished <nil> | payments made: 1

func (*Runner) Extend

func (r *Runner) Extend(ctx context.Context, extraSteps int, by string) (State, error)

Extend gives a PAUSED run more model decisions and continues it. The decision is logged with who made it: budget is never raised silently (week 3 S1 review, Q3).

func (*Runner) Reject

func (r *Runner) Reject(ctx context.Context, key, by, reason string) (State, error)

Reject records a rejection; the call is resolved as tool_refused and the model is told why.

func (*Runner) Start

func (r *Runner) Start(ctx context.Context, system, task string) (State, error)

Start begins a new run. It refuses a log that already has events.

func (*Runner) Validate

func (r *Runner) Validate() error

Validate reports every configuration problem at once, wrapped in ErrConfig. New calls it.

type ScriptedModel

type ScriptedModel struct {
	Plan  []FunctionCall
	Final string
	Calls int // how many times Decide was called (to prove the runner never re-asks for a logged decision)
}

ScriptedModel is a deterministic stand-in for a real model: it proposes Plan[i] once it has received i tool results, then answers Final. It decides from the conversation STATE, so it behaves correctly after a resume, like a real model. For tests and free harness runs; it is not a mock of any provider's quirks.

func (*ScriptedModel) Decide

func (m *ScriptedModel) Decide(_ context.Context, messages []Message, _ []ToolSpec) (Decision, error)

Decide implements Model.

func (*ScriptedModel) Describe

func (m *ScriptedModel) Describe() (string, string)

Describe implements Describer.

type State

type State struct {
	Kind     string // "" for an agent run, KindProxy for a proxy run
	Status   Status
	Step     int        // model decisions so far
	Messages []Message  // the conversation to send on the next model call
	Pending  []ToolCall // proposed by the model, no tool_result yet, in the model's order
	// Started holds pending calls that have a tool_started event: they MAY have executed before a crash.
	// A pending call NOT in Started definitely never ran. That distinction is why tool_started exists.
	Started map[string]bool
	Stop    string
	Text    string // final answer
	Events  int
	Budget  int // max model decisions, from run_started + every budget_extended
	KeyBits int // idempotency key length, from run_started: a run keeps it across library upgrades

	// Approvals: decision per call id ("requested", "approved", "rejected"); ByKey: per operation key, so an
	// approval can be addressed by the payout's key and a repeated identical decision is a no-op.
	Approvals map[string]string
	ByKey     map[string]string
	Waiting   *Waiting // set while awaiting_approval
	// CallMeta is a proxy run's pending call's request metadata (call_received.meta), by call id: a retry after a
	// crash gives the tool the same metadata as the first attempt.
	CallMeta map[string]string
	// Requests are a proxy run's approvals still waiting, by operation key. A proxy run doesn't pause for an
	// approval: the call is answered "pending" and the run stays open; the decision is addressed by key.
	Requests  map[string]Waiting
	Denials   int    // refused approval attempts (approval_denied), for audit
	StartedBy string // who started the run (Runner.StartedBy), for separation of duties

	// Effects indexes every completed idempotent call by key: the log IS the idempotency store for
	// outcomes it has seen. (Outcomes it never saw, after a crash mid-call, are the tool's job: it gets the key.)
	Effects map[string]Outcome
}

State is the run as reconstructed from its events.

func NewState

func NewState() State

NewState is the state of a run with no events.

func Rebuild

func Rebuild(events []Event) (State, error)

Rebuild folds a log into a State, checking every transition. A log that breaks the rules is an error, not something to guess around.

func (*State) Apply

func (s *State) Apply(e Event) error

Apply is the transition function. It is the ONLY code that changes a State.

new            --run_started-->                 awaiting_model
awaiting_model --model_decided (with calls)-->  executing
awaiting_model --model_decided (no calls)-->    answered
executing      --tool_started-->                executing       (call marked "maybe executed")
executing      --tool_result-->                 executing | awaiting_model (when the last call resolves)
awaiting_model --run_paused-->                  paused          (budget exhausted: resumable)
paused         --budget_extended-->             awaiting_model  (an explicit, logged decision)
answered       --run_finished-->                finished        (terminal)
awaiting_model --run_finished-->                finished        (e.g. stopped by an operator)
executing      --tool_refused-->                executing | awaiting_model (call resolved, never attempted)
executing      --approval_requested-->          awaiting_approval
awaiting_appr. --approval_decided-->            executing       (approved: may now start; rejected: must be refused)

A proxy run (run_started with kind "proxy", format v5) has no model:

new            --run_started (proxy)-->         open
open           --call_received-->               executing       (one call, from outside)
executing      --tool_result | tool_refused-->  open
executing      --approval_requested-->          open            (the call is answered "pending"; the key waits)
open           --approval_decided | _denied-->  open            (addressed by key: the operation, not a call)

Not allowed, by design:

  • run_finished while executing: every proposed call must have an outcome first (week 1 F14)
  • tool_result without tool_started: an outcome for something that was never attempted
  • tool_started / tool_result for a call the model didn't propose, or that already has a result (F8)
  • model_decided while calls are pending, with a step out of order, or beyond the budget
  • anything at all after run_finished
  • tool_started for a call whose approval is pending or was rejected (the gate can't be skipped)
  • tool_refused for a call that was already started (it may have run: that needs a tool_result)
  • approval_requested for an operation already rejected in this run (a rejection can't be undone by asking again)
  • model decisions, budgets or a final answer in a proxy run; call_received in an agent run
  • in a proxy run: a second request for a key already requested or decided, a decision for a key that isn't waiting, or a decision while a call is in progress

type Status

type Status string

Status is where a run is. Every status is derived from the log; none is stored.

const (
	StatusNew              Status = "new"               // no events yet
	StatusAwaitingModel    Status = "awaiting_model"    // next thing to do: ask the model
	StatusExecuting        Status = "executing"         // the model proposed calls; some have no result yet
	StatusAwaitingApproval Status = "awaiting_approval" // a gated call waits for a human; nothing is held in memory
	StatusAnswered         Status = "answered"          // the model gave a final answer; run_finished not yet logged
	StatusPaused           Status = "paused"            // out of budget: resumable only via an explicit budget_extended
	StatusFinished         Status = "finished"          // done. Terminal: downstream may rely on "finished" meaning finished
	StatusOpen             Status = "open"              // a proxy run, waiting for its next call (format v5)
)

The statuses a run can be in. Each is derived from the log by Rebuild.

type TimeoutTool

type TimeoutTool interface {
	Timeout() time.Duration
}

TimeoutTool lets a tool set its own per-call timeout, overriding Runner.ToolTimeout. 0 = the Runner's.

type Tool

type Tool interface {
	Spec() ToolSpec
	// Call executes the tool. A returned error becomes an {"error": ...} result for the model, not a run
	// failure: the model decides what to do about a failed tool.
	Call(ctx context.Context, args json.RawMessage) (any, error)
}

Tool is something the model can ask the runner to do.

func Func

func Func[In, Out any](name, description string, fn func(ctx context.Context, in In) (Out, error), opts ...FuncOption) Tool

Func turns an ordinary Go function into a Tool. The model's arguments are decoded into In (strictly: an unknown field is an error the model sees), the JSON schema the model is shown is generated from In, and the safety features are options:

type Payout struct {
	InvoiceID string            `json:"invoice_id" desc:"the invoice being paid"`
	Payee     string            `json:"payee"`
	Amount    agentsafe.Decimal `json:"amount" desc:"in rupees, e.g. 4200.50"`
}
pay := agentsafe.Func("send_payout", "Pay an approved invoice", sendPayout,
	agentsafe.Idempotent("invoice_id"),                        // one payout per invoice, ever
	agentsafe.NeedsApproval(func(p Payout) any { return p }),  // a human approves first
	agentsafe.Check(checkAgainstLedger),                       // grounded before anyone is asked
	agentsafe.Timeout(10*time.Second))

With Idempotent, fn gets the operation's key from KeyFrom(ctx): pass it to the system you call (as an Idempotency-Key header, a unique column) so a retry after a crash or timeout can't act twice.

Schema: exported fields named by their json tag; a field is required unless it is a pointer or tagged omitempty; `desc:"..."` describes it; `enum:"a,b"` restricts a string. Decimal is a decimal string; time.Time an RFC 3339 string.

Configuration mistakes (an approval on a tool that isn't Idempotent, an identity field In doesn't have, an option written for another input type) are reported by New/Validate as ErrConfig.

Example

Func derives the JSON schema the model sees from the input struct.

package main

import (
	"context"
	"fmt"

	"github.com/Ashutosh2308Bhardwaj/agentsafe"
)

type Invoice struct {
	InvoiceID string            `json:"invoice_id" desc:"the invoice being paid"`
	Payee     string            `json:"payee"`
	Amount    agentsafe.Decimal `json:"amount" desc:"in rupees, e.g. 4200.50"`
}

// gateway is a stand-in payment system: one payment per idempotency key.
type gateway struct{ payments map[string]string }

func (g *gateway) pay(ctx context.Context, in Invoice) (string, error) {
	key := agentsafe.KeyFrom(ctx)
	if _, done := g.payments[key]; !done {
		g.payments[key] = fmt.Sprintf("%s %s", in.Payee, in.Amount)
	}
	return "paid", nil
}

func main() {
	gw := &gateway{payments: map[string]string{}}
	tool := agentsafe.Func("send_payout", "Pay an invoice", gw.pay)
	fmt.Println(string(tool.Spec().Parameters))
}
Output:
{"additionalProperties":false,"properties":{"amount":{"description":"in rupees, e.g. 4200.50","pattern":"^-?[0-9]+(\\.[0-9]+)?$","type":"string"},"invoice_id":{"description":"the invoice being paid","type":"string"},"payee":{"type":"string"}},"required":["invoice_id","payee","amount"],"type":"object"}

type ToolCall

type ToolCall struct {
	ID       string       `json:"id"`
	Type     string       `json:"type"`
	Function FunctionCall `json:"function"`
}

ToolCall is a tool invocation the model PROPOSED. Nothing has run yet: the runner decides. ID is minted by the model and changes on every retry (week 1 F9), so it is never used as an idempotency key, only to pair a result with its call.

type ToolSpec

type ToolSpec struct {
	Name        string          `json:"name"`
	Description string          `json:"description"`
	Parameters  json.RawMessage `json:"parameters"`
}

ToolSpec describes a tool to the model. Parameters is a JSON Schema.

type Usage

type Usage struct {
	PromptTokens     int `json:"prompt_tokens"`
	CompletionTokens int `json:"completion_tokens"`
}

Usage is the provider's token count for one model call. Useful, not authoritative (week 1 F1, F5).

type Validator

type Validator interface {
	Validate(ctx context.Context, args json.RawMessage) error
}

Validator is a tool that can check a call against the source of truth BEFORE anything runs or anyone is asked. A failed validation resolves the call as tool_refused: never attempted, never shown to an approver. This is where grounding lives (week 1 F7, F11; week 2 W2-3, W2-6; week 3 W3-5): every consequential value must match the source, because once an idempotent write is recorded, a wrong value is locked in.

type Waiting

type Waiting struct {
	CallID, Key, Tool, Summary string
}

Waiting is the gated call a run is paused on.

Directories

Path Synopsis
anthropic module
cmd
trace command
Command trace builds an OpenTelemetry trace from an agentsafe event log.
Command trace builds an OpenTelemetry trace from an agentsafe event log.
examples
quickstart command
Quickstart: an agent that can pay an invoice at most once, and only after a human approves.
Quickstart: an agent that can pay an invoice at most once, and only after a human approves.
reconcile command
Command reconcile is the week-1 reconciliation agent, ported to agentsafe: reconcile ledger.csv (payouts we sent) against bank.csv (the bank's settlement file), recording each discrepancy once.
Command reconcile is the week-1 reconciliation agent, ported to agentsafe: reconcile ledger.csv (payouts we sent) against bank.csv (the bank's settlement file), recording each discrepancy once.
refund command
Refund: a support agent refunds an overcharge through a Stripe-shaped payments API.
Refund: a support agent refunds an overcharge through a Stripe-shaped payments API.
subscription command
Subscription: an agent adds seats to a customer's subscription through an HTTP API, and every addition is billed.
Subscription: an agent adds seats to a customer's subscription through an HTTP API, and every addition is billed.
gemini module
internal
heartbeat
Package heartbeat keeps a database lease alive: the part the sqlite and postgres backends share.
Package heartbeat keeps a database lease alive: the part the sqlite and postgres backends share.
mcp module
Package openai is an agentsafe.Model for any OpenAI-compatible /chat/completions API: OpenAI, Groq, Ollama, vLLM, Together, Fireworks, ...
Package openai is an agentsafe.Model for any OpenAI-compatible /chat/completions API: OpenAI, Groq, Ollama, vLLM, Together, Fireworks, ...
postgres module
Package reconcile audits a finished agentsafe run against the systems it acted on: what should exist (computed by your code from source data), what does exist (your ledger, your payment gateway), what the run's log says, and what the agent claimed.
Package reconcile audits a finished agentsafe run against the systems it acted on: what should exist (computed by your code from source data), what does exist (your ledger, your payment gateway), what the run's log says, and what the agent claimed.
sqlite module
Package storetest checks a storage backend against agentsafe's LineStore contract.
Package storetest checks a storage backend against agentsafe's LineStore contract.
Package tooltest checks your own tools against the guarantees agentsafe relies on.
Package tooltest checks your own tools against the guarantees agentsafe relies on.
Package trace turns an agentsafe run's log into OpenTelemetry spans, following the GenAI semantic conventions: the run (invoke_agent), each model call (chat), each tool call (execute_tool), each approval.
Package trace turns an agentsafe run's log into OpenTelemetry spans, following the GenAI semantic conventions: the run (invoke_agent), each model call (chat), each tool call (execute_tool), each approval.

Jump to

Keyboard shortcuts

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