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 ¶
- Constants
- Variables
- func CallMetaFrom(ctx context.Context) json.RawMessage
- func Canonical(v any) (string, error)
- func KeyFrom(ctx context.Context) string
- func Str(s string) *string
- type AESGCM
- type Approval
- type Authorizer
- type AuthorizerFunc
- type Codec
- type Decimal
- type Decision
- type Describer
- type Event
- type EventType
- type FileLog
- type FuncOption
- type FunctionCall
- type Gated
- type Gateway
- func (g *Gateway) Approve(ctx context.Context, key, by string) error
- func (g *Gateway) Call(ctx context.Context, client, tool string, args json.RawMessage) (GatewayResult, error)
- func (g *Gateway) Close() error
- func (g *Gateway) Handle(ctx context.Context, req Request) (GatewayResult, error)
- func (g *Gateway) Pending(ctx context.Context) ([]Waiting, error)
- func (g *Gateway) Reject(ctx context.Context, key, by, reason string) error
- func (g *Gateway) Tools() []ToolSpec
- type GatewayResult
- type IdempotentTool
- type Journal
- type KeyHonouring
- type LineStore
- type Locker
- type Log
- type Message
- type Model
- type Option
- func WithAnyApprover() Option
- func WithAuthorizer(a Authorizer) Option
- func WithHook(h func(point string)) Option
- func WithLogf(f func(format string, args ...any)) Option
- func WithMaxSteps(n int) Option
- func WithRedactor(rd Redactor) Option
- func WithScope(scope string) Option
- func WithStartedBy(identity string) Option
- func WithToolRetries(attempts int, backoff time.Duration) Option
- func WithToolTimeout(d time.Duration) Option
- func WithTools(tools ...Tool) Option
- func WithoutLease() Option
- type Outcome
- type PanicError
- type Redactor
- type ReplayWindowTool
- type Request
- type Role
- type Runner
- func (r *Runner) Approve(ctx context.Context, key, by string) (State, error)
- func (r *Runner) Continue(ctx context.Context) (State, error)
- func (r *Runner) Extend(ctx context.Context, extraSteps int, by string) (State, error)
- func (r *Runner) Reject(ctx context.Context, key, by, reason string) (State, error)
- func (r *Runner) Start(ctx context.Context, system, task string) (State, error)
- func (r *Runner) Validate() error
- type ScriptedModel
- type State
- type Status
- type TimeoutTool
- type Tool
- type ToolCall
- type ToolSpec
- type Usage
- type Validator
- type Waiting
Examples ¶
Constants ¶
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.
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.
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.
const Masked = "[REDACTED]"
Masked replaces every redacted value.
Variables ¶
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).
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.
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.
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.
var ErrGatewayClosed = errors.New("agentsafe: gateway is closed")
ErrGatewayClosed is returned after Close.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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 ¶
Canonical is the JSON used for hashing: object keys sorted, numbers normalised (4200 == 4200.0).
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
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 ¶
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 ¶
AuthorizerFunc adapts a function to an Authorizer, for custom policies such as amount thresholds.
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
OpenEvent restores a sealed event's content. An unsealed event is returned as it is.
func SealEvent ¶ added in v0.2.0
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 UpgradeAll ¶
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 ¶
Append writes e as the next line. Seq and Prev are set here, and Time unless e has one.
func (*FileLog) Head ¶
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".
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 ¶
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
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
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
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
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
Pending lists the operations waiting for a decision, oldest first: what an approver is shown.
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):
- 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);
- 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;
- 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 ¶
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
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 ¶
Head returns the chain link after the last event: anchor it outside the log (chain.go).
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 ¶
NewFileStore returns the file LineStore FileLog uses, for building a Journal yourself. It is also a Locker.
type Locker ¶
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 ¶
WithHook sets a chaos/test hook, called at named points of a run (e.g. to kill the process there).
func WithMaxSteps ¶
WithMaxSteps sets the step budget for new runs (default 8). Resumed runs keep the budget in their log.
func WithRedactor ¶
WithRedactor masks arguments, results and summaries in progress output.
func WithScope ¶
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 ¶
WithStartedBy records who starts runs (an identity your system verified), for NotRequester.
func WithToolRetries ¶
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 ¶
WithToolTimeout bounds every tool call (a TimeoutTool sets its own).
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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
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 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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
Reject records a rejection; the call is resolved as tool_refused and the model is told why.
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 Rebuild ¶
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 ¶
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 ¶
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.
Source Files
¶
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. |