Repository navigation
Approvals and Resume
A pause for a human can last minutes or days, and the process that paused may not be the one that continues. So an approval is a row, not a callback: the runtime writes it, returns, and any later process with the same store can decide and resume it.
When the policy answers require_approval, the runtime writes an approval with the kind, capability, presentation, the exact tool request, and a SHA-256 over all of them. The run moves to WAITING_FOR_APPROVAL and the step to awaiting_approval, and the run's lease is released in the same transaction. Start returns the waiting run.
The hash is computed by a canonical encoder written out in hash.go and pinned byte for byte to the hashes already stored. If a request cannot be encoded, the hash is an error and the run fails as internal_error; the runtime never hashes a stand-in. A stored approval carrying such a hash is refused by Approve and Resume with ErrApprovalHash.
err := driver.Approve(ctx, runID, approvalID, "reviewer", "note")
err := driver.Reject(ctx, runID, approvalID, "reviewer", "note")Approve recomputes the hash from the stored fields and refuses a row that does not match, so a modified approval cannot be granted. Reject cancels the run with reason approval_rejected. Neither continues the run. Both re-check, inside the transaction that records the decision, that the run is still waiting and the approval still pending, so two operators deciding at once cannot both win.
A front end that shows the approval first should bind the decision to what it showed:
err := agentrt.ApproveShown(ctx, store, observer, runID, a.ID, a.Hash, "reviewer", "note")
err := agentrt.RejectShown(ctx, store, observer, runID, a.ID, a.Hash, "reviewer", "note")shownHash is the hash of the approval the operator read. It is compared with the stored hash inside the deciding transaction, and the update is conditional on it, so a writer cannot change the row between the check and the decision. A difference is refused with ErrApprovalChanged. cmd/agentrt approve and reject use these, and view.ApproveShown and view.RejectShown wrap them with matchable errors. Without them, a command that printed an approval and then decided decided against whatever the row held by then, which may not be what was printed.
The package-level Approve, Reject, ApproveShown, RejectShown, and Cancel take a store and an observer rather than a driver, for an operator who holds the database but not the consumer's agent, tools, or policy. They judge expiry and stamp decisions with the wall clock; the driver methods use the driver's Config.Now.
decided_by is a string the caller supplies, recorded exactly as given and never verified.
Since v0.3.1 the same driverless decisions are also methods on agentrt.Operator:
op := agentrt.Operator{Store: store, Observer: observer, Now: clock}
err := op.ApproveShown(ctx, runID, a.ID, a.Hash, "reviewer", "note")Operator has Approve, Reject, ApproveShown, RejectShown, and Cancel, each the package-level function of the same name stamping decisions and judging expiry by Now. The zero Now is the wall clock, so Operator{Store: s, Observer: o}.Approve is Approve(ctx, s, o, ...), and the package-level functions are unchanged. A channel's tests set Now to put an approval before or past its expiry. The portable approval channel is built on it: approver.Local decides through an Operator, and Approval Channels is the page for deciding from a chat bot or a browser.
A decision on an approval whose ApprovalTTL has passed by the deciding clock expires it and cancels the run as approval_expired, and an approval that was expired before is refused the same way. Since v0.3.1 that error is ErrApprovalExpired, which matches ErrNotPending under errors.Is, as an expired approval has always returned, and keeps its message, which names the approval and, for one the decision expired, the expiry time. A caller that only checks ErrNotPending is unchanged; one that wants to say "expired" rather than "already decided" matches ErrApprovalExpired first. approver.ErrExpired wraps it for a channel's own callers.
An approval row has no reason of its own. The reason a policy gave for asking is in the approval.requested event that paused the run, and Store.ApprovalReason(ctx, runID, approvalID) returns it, capped at MaxPageText, so a channel can show why the policy asked. It is the pausing decision's reason even after a resume-time decision has replaced the step's recorded policy, which holds the decision at resume once an approval is granted and resumed. ErrNotFound when the run has no such approval; empty when the approval has no approval.requested event. The reason may carry text the model chose.
A decided approval cannot be decided again. A run that was approved but never resumed, or that the operator no longer wants for any reason, is closed with Cancel, which ends any non-terminal run as operator_cancelled, fails the in-flight step with an observation naming the cancellation, and leaves approvals as they are. This came from the first real publication run, where a resume failed after approval and nothing could close the run.
Cancel needs no lease. It is the operator's authority: it releases the run's lease along with the run, a loop still executing the run finds it cancelled at its next write and stops there, and a status transition is compare-and-set, so a live loop can never overwrite the cancel. A tool already running when the run is cancelled is not interrupted; its outcome is appended to the cancelled run as a step.tool_finished event marked late.
| Limit | Bounds | Measured from | Judged at | Effect |
|---|---|---|---|---|
ApprovalTTL |
how long an approval may stay pending | when it was requested | the next approve or resume | the approval expires and the run is cancelled as approval_expired
|
GrantTTL |
how long an approved grant may wait for Resume
|
when it was decided | Resume |
the grant expires, the run is cancelled as approval_expired, and nothing executes |
They answer different questions. ApprovalTTL is "how long will we wait for a human"; it bounds only the wait for a decision and never a grant. GrantTTL is "how stale may a yes be when it is acted on": an approval granted on Friday and resumed on Monday may describe a world that has moved. Both are zero, no expiry, by default; a run stored before GrantTTL existed reads it as zero. A consumer that refuses a stale grant inside its own tool, as casework does, leaves GrantTTL unset.
An expired grant keeps who decided it and when; its expiry is the event.
run, err := driver.Resume(ctx, runID)For a waiting run, Resume:
- finds the step awaiting approval and that step's latest approval, in insertion order. Only the latest can be a grant. A step paused again, by a policy that now wants something else or by a reconciliation, has a newer approval, and until that one is approved
ResumereturnsErrRunStateand changes nothing, however often it is called. An older grant is spent; - expires a pending approval past its
ApprovalTTL, or a grant past itsGrantTTL; - requires the grant to be approved and to still match its hash;
-
checks the recorded arguments against the tool's schema as registered now, as the loop checks a fresh decision. If they fail, the step ends as
invalid_decisionand the agent decides again; the stale request never reaches the policy; - evaluates the policy again on the recorded request with the current run view, while the run is still waiting:
-
allow: the recorded request executes and the loop continues; -
require_approvalthat hashes to the granted approval: the recorded request executes and the loop continues. The hash is taken over the kind, capability, and presentation the policy returns now and the request with the tool's spec as registered now, so all of them must match what was granted; -
require_approvalwith anything different, including a tool whose spec changed since the pause: a new approval is written and the run pauses again; -
denyorabort: as for any request; - an outcome the runtime does not know: the run fails as
internal_error.
-
The move to RUNNING, the resume-time step.policy event, and the tool start commit together, in one transaction that also takes the run's lease. A crash between them cannot drop the approved request or execute it twice, and of two processes resuming one approved run, one executes it and the other gets ErrRunState.
Only the recorded request ever executes. The agent is not asked to decide again for that step, so it cannot swap the action after the human said yes.
For a run found RUNNING, Resume is the interruption path instead. It returns ErrRunLeased while another process's lease on the run is live, and otherwise takes the run over; see Interruption and Reconciliation and Run Lease. For a terminal run it returns ErrRunState.
One approval kind is the runtime's own: interrupted_side_effect (agentrt.InterruptedSideEffect). When Resume takes over a crashed run with no Config.Reconcile, and the step in flight was executing a tool that is not ReadOnly, the run pauses on that step's recorded request. Its presentation names the tool, the arguments, and the side-effect class, and says the call started and its outcome is unknown. It is decided like any other approval, and resumed through the same steps above, policy included. Interruption and Reconciliation has the rest.
Time spent waiting is excluded from the active-time limit and included in the elapsed-time limit. A resumed step's active clock starts from the resume.
The presentation JSON is for the human. It should contain exactly what a reviewer needs to decide and nothing that would differ between two runs of the same action, so that the hash is stable and reviewers see the same thing for the same request. cmd/agentrt prints it one field per line and labels it as supplied by the consumer's policy, because a presentation built from the request carries model-chosen text.
Writing a Policy is where approvals are asked for. The Operator Command is where they are decided at a terminal, and Approval Channels is where they are decided from anywhere else.
Using it
- Getting Started
- Upgrading to v0.5
- Upgrading to v0.4
- Upgrading to v0.3
- Concepts
- Writing an Agent
- The Gate
- Writing Tools
- MCP Tools
- MCP Proxy
- Writing a Policy
- Models and Replay
- Provider Adapters
- Rendering Model Context
- Approvals and Resume
- Interruption and Reconciliation
- Run Lease
- Limits
- Testing a Consumer
- Re-checking a Run
- Measuring Agents
Inside it
- Internals
- Persistence and Inspection
- Performance
- The Operator Command
- Approval Channels
- Exporting Events
- Testing
Deciding on it
- Safety Properties
- Security Model
- Comparison with Frameworks
- Status and Roadmap
- Roadmap
- Design Decisions
- Essays
- Contributing
- Releasing
Reference