The Gate - joeylking/agent-runtime GitHub Wiki
The Gate
Until v0.4.0 the runtime's controls were available only to a loop the runtime ran. A consumer wrote an Agent, and the Driver called it. A team that already had a loop, hand-written or inside another framework, could not use the controls without replacing the loop. A Gate, added in v0.4.0, offers the same controls to a loop the runtime does not own. The caller decides what to do next. The gate decides whether each action runs, runs it, and records it.
This is the change of identity recorded in decision 7: the project is to own the effect, not the loop. The loop, durable state, a pause for approval, budgets, and tracing are standard in agent frameworks and durable execution engines. What the project was built around is about the moment a tool is called and something outside changes: an approval bound by hash to the exact request, an interrupted side effect that is not run again without an operator, an audit event committed with the state change it explains, and MCP tools pinned and classified by an operator.
What a gate does
A gate takes each action a caller's loop proposes and does what the Driver does with its agent's decision. It records the decision verbatim, checks the arguments against the tool's schema, evaluates policy, pauses on a hash-bound approval when the policy asks, enforces the limits, takes the run's lease, executes the tool, and writes the observation and the audit events in the same transactions.
The gate executes the tool itself. There is no path where the caller runs the tool and reports the outcome. If there were, at-most-once would be the caller's promise and the audit log would say what a program said happened. A tool is therefore registered with the gate, not left in the caller's own code. A caller that runs its own tools outside the gate is outside every control, as an agent's tool code is.
The Driver stays, as the reference loop and the one both consumers use, and it now runs on the same code: Start and Resume are a session and the same sequence a gate runs, so there is one implementation and not two that could drift. Nothing a Driver consumer sees changes. TestGate_ExternalLoopMatchesTheDriver holds a loop written outside the runtime to the same rows, events, payloads, transaction boundaries, and clock readings as the Driver running the same decisions.
The API
| Type or method | What it does |
|---|---|
GateConfig |
Config without an agent: a store, a policy, tools, and the same observer, model configuration, reconciliation, clocks, and lease settings |
NewGate(cfg) |
validates the configuration and compiles every tool schema, as NewDriver does |
Gate.Begin(ctx, runID, goal, limits) |
creates a run with a caller-chosen id and opens a Session on it, as Driver.StartWithID does before its first step |
Gate.Attach(ctx, runID) |
opens a session on an existing run, doing what Resume does before its loop, and returns a Verdict for the recorded request when the run was waiting on an approval |
Session.Step(ctx) |
checks the limits and starts a step, as the driver does before asking its agent. A limit ends the run there: Step returns no step and no error, and Run reports the run |
OpenStep.Propose(ctx, decision) |
records the decision, validates it, evaluates policy, and answers with a Verdict. It does not run a tool |
OpenStep.Execute(ctx) |
runs exactly what an allowed verdict let through, and returns the observation |
OpenStep.Fail(ctx, err) |
ends a step whose decision could not be made, as the driver ends a run whose agent returned an error |
OpenStep.Model() |
the step's accounting ModelCaller, held to the run's limits, for a loop that calls a model through the runtime. It is nil when the gate has no GateConfig.Model |
Session.Current(), Done(), Run(), Close() |
the open step, whether the session is over, the run as the session last read or wrote it, and the release of the lease |
Session.Input(ctx) |
what an agent deciding at that point would be handed |
A session is one hold on a run with its lease, renewed by a heartbeat for as long as the session is open. It has at most one open step, and its calls are serialised. A gate is safe to reuse for many runs, and refuses a second session on a run it holds.
An example
This is ExampleGate from example_test.go, which go test runs and checks against its output. The loop's decisions are a fixed list here. In real use they come from the caller's own loop. must, newExampleTool, and exampleIDs are small helpers in that file.
ctx := context.Background()
store := must(agentrt.OpenStore(":memory:"))
defer store.Close()
g := must(agentrt.NewGate(agentrt.GateConfig{
Store: store, Policy: agentrt.DefaultPolicy(),
Tools: []agentrt.Tool{newExampleTool("read", agentrt.ReadOnly), newExampleTool("push", agentrt.RemoteMutation)},
NewID: exampleIDs(),
}))
// The external loop's decisions, by step index.
plan := []agentrt.Decision{
scripted.ToolCall("read", `{"n":1}`, "look first"),
scripted.ToolCall("push", `{"n":7}`, "publish the counter"),
scripted.Complete(`{"pushed":7}`),
}
s := must(g.Begin(ctx, "run-1", "publish the counter", agentrt.DefaultLimits()))
var v agentrt.Verdict
for v.Outcome != agentrt.VerdictPending {
step := must(s.Step(ctx))
v = must(step.Propose(ctx, plan[step.Index()]))
fmt.Println(step.Index(), v.Outcome)
if v.Outcome == agentrt.VerdictAllowed {
fmt.Println(must(step.Execute(ctx)).Summary)
}
}
fmt.Println(v.Run.Status, v.Approval.Kind)
if err := agentrt.Approve(ctx, store, nil, "run-1", v.Approval.ID, "joey", "looks right"); err != nil {
panic(err)
}
s, v, err := g.Attach(ctx, "run-1")
if err != nil {
panic(err)
}
defer s.Close()
fmt.Println(v.Outcome, v.Request.Spec.Name, string(v.Request.Args))
fmt.Println(must(s.Current().Execute(ctx)).Summary)
step := must(s.Step(ctx))
v = must(step.Propose(ctx, plan[step.Index()]))
fmt.Println(step.Index(), v.Outcome, v.Run.Status, string(v.Run.Result))
It prints:
0 allowed
echoed read
1 pending
WAITING_FOR_APPROVAL remote_mutation
allowed push {"n":7}
echoed push
2 ended COMPLETED {"pushed":7}
The read is allowed and runs. The push is a remote mutation, so the default policy pauses the run and the first session is over. After the approval is decided, Attach hands back exactly the approved request, Execute runs it, and the loop goes on to its last decision. The README has a shorter sketch of the same loop under "Using the gate from your own loop".
The verdict
Propose answers with a Verdict, whose Outcome is one of four values:
VerdictAllowed: the request may run.Executeruns exactly it. The verdict'sRequestis a copy, so changing it changes nothing about what runs.VerdictDenied: the policy denied the request, or the decision was invalid. The step ended with a failure observation, which counts toward the consecutive failure limit, and the session continues with the next step.VerdictPending: the policy required approval. The run isWAITING_FOR_APPROVALon a hash-bound approval, and the session is over. Approve it, thenAttach.VerdictEnded: the run reached a terminal status, by a complete or fail decision or a policy abort. A terminal tool ends it atExecute, after whichDonereports the session over andRunthe run.
A session that is over refuses every later call: with ErrRunState when the run paused or ended, ErrLeaseLost when the lease was lost, and ErrSessionClosed after Close or a failed call. A call that misuses a session, such as a second open step or an Execute without an allowed verdict, fails with ErrRunState and changes nothing (TestGate_MisuseIsRefused).
Attach after an approval returns the verdict for the recorded request, with policy re-evaluated as Resume does, so a policy that now asks for a different approval pauses the run again (TestGate_ChangedPolicyRepausesAndRejectCancels). An allowed verdict leaves that step open as Session.Current, for Execute. The run stays WAITING until Execute moves it to RUNNING, taking the lease, as it starts the tool.
A caller's Close releases the lease, so another process need not wait for it to expire. A session left open holds a heartbeat until it is closed, so close one that is no longer used. While one session holds a run, another Begin or Attach of it is refused and changes nothing (TestGate_SecondSessionIsRefused).
An abandoned session
A caller's loop can stop anywhere: it panics, its process is killed, or it simply stops calling. What an abandoned session leaves is what a crash would, and Attach treats it that way:
- After
Step, beforePropose. The step is deciding.Attachmarks it interrupted and the loop goes on. - After an
allowedverdict, beforeExecute. Nothing about the request is written but its decision, so the step is still deciding.Attachmarks it interrupted and runs nothing, and the abandoned step can no longer execute once another session holds the run (TestGate_AbandonedAfterAllowedRunsNothing). - During
Execute. The step is executing, and what follows is the contract of decision 6. With noGateConfig.Reconcile,Attachpauses the run asinterrupted_side_effectfor a tool that is notReadOnly, and the tool runs again only if an operator approves. The tool already running is not recalled, and its outcome is recorded only if its session still holds the lease (TestGate_AbandonedDuringExecutePausesForAnOperator). See Interruption and Reconciliation. - After
Attachreturnedallowedfor an approved step, beforeExecute. Nothing was written. The approval is still approved and the run stillWAITING, and the nextAttachfinds the grant and runs it once (TestGate_AbandonedApprovedStepStaysApproved). - After
pendingorended. The session is already over and there is nothing to abandon.
The grant TTL at Execute
An approved step holds no lease while it waits, so Limits.GrantTTL is its bound. A Driver acts on a grant in the call that judged it. A gate's caller can hold an approved step and wait, so Attach judges the grant's age when it allows it, and Execute judges it again on a fresh reading of Now. A grant that expired since cancels the run as approval_expired, executes nothing, and ends the session with an error that matches ErrApprovalExpired. Within the TTL, or with none set, it executes however long the wait (TestGate_GrantTTLHoldsUntilExecute).
This was a gap in the first form of the gate, closed before v0.4.0 was released: the TTL was judged only at Attach. It was never in a release, and the Driver is unaffected.
Decision.Origin
Decision.Origin records who proposed a step. The known values are OriginModel (model), OriginPlan (plan), and OriginOperator (operator). The runtime records it and attaches no behaviour to it, and policy does not see it. An empty origin is not stored, so a decision without one is recorded exactly as before, and the approval hash is the same with or without it (TestDecision_OriginIsRecordedAndChangesNothingElse). An unknown value makes the decision invalid, recorded verbatim with an invalid_decision observation, through a gate and a Driver alike (TestDecision_UnknownOriginIsInvalid). export/otel carries it as the span attribute agentrt.decision.origin when the payload names one (TestExporter_DecisionOrigin). Nothing sets plan yet; it exists for roadmap item 12, and the MCP proxy records its own closing decision with operator.
Session.Input
A loop that decides from the run's record, as an agent does, can ask the session what an agent would be handed. Session.Input returns a StepInput of copies. With a step open it is exactly what Decide receives at that step (TestGate_InputMatchesTheAgentsStepInput). With none open it is the run as the session last wrote it and every recorded step (TestGate_InputAtEveryPointOfASession). Writing into it changes neither the session's state nor the record (TestGate_InputIsACopy). It reads no clock, and after its first call reads nothing from the store.
testkit.Scenario.Loop
testkit.Scenario.Loop runs the crash and resume harness over a caller's own loop on a gate, in place of a Driver and its agent. The harness hands Loop a session, crashes it at a chosen point, resumes with Attach, and asserts the same invariants of the run's record as for a Driver (TestRun_GateLoopKeepsTheContractAtEveryCrash). It adds two crash points:
AfterAllowedabandons a loop holding an allowed verdict beforeExecute. ADriverreaches it too, between its policy and the tool start (TestRun_DriverStopsAfterAllowed).AfterAttachAllowedabandons an approved step afterAttachallowed it. Only aLoopreaches it, and the nextAttachruns the grant once (TestRun_GateLoopAbandonedAfterAttachRunsTheGrantOnce).
Result.Inputs is empty for a Loop, and SameRenders still needs an agent. A loop that ignores the approved step, stops mid-step, or returns an error is reported (TestRun_BrokenGateLoopsAreReported). The rest of the harness is on Testing a Consumer.
What it does not do
- It is Go only. A loop in another language cannot call it. The MCP proxy is the first form of the gate outside Go, for MCP hosts.
- It trusts the caller's loop as it trusts an
Agent. The loop is the consumer's own code in the consumer's own process. The gate bounds what it can make happen, not what it proposes, and a loop steered by untrusted content is bounded as a steered model is. - A fresh step's gap between
ProposeandExecuteis covered by the lease and no other limit, as an agent's slowDecideis. The time limits are checked when the step starts, atStep. - A step's
ModelCallerremains usable after its step ends, as an agent's does under theDriver. It is held to the run's limits and refuses to send once the lease is lost. - It is not required. Consumers that run on the
Driverneed nothing from it. No consumer asked for it: repo-steward and casework run on theDriverand are unchanged. It is the one addition justified by outside research and not by a consumer's demonstrated need, and decision 7 says it sets no precedent for the next one.
Next
Design Decisions has decision 7 with its alternatives and costs. Security Model says what a gate's caller is trusted with. Approvals and Resume is the approval mechanism a gate shares with the Driver. Internals lists where the code lives. Status and Roadmap has every capability with the test that verifies it.