Repository navigation
How It Works
The characteristic failure of an unattended agent is continuation rather than collapse. A process that crashes stops spending and announces the problem, whereas an agent that has lost the thread goes on reading files, repeating searches, and calling a model, and every one of those actions looks like work from the outside. The difficulty is that the agent is the worst witness to its own condition, because a model that has stopped advancing can still produce a confident account of what it will do next. Therefore the controls that matter sit outside the model, in ordinary code that reads counters, timestamps, and files. They act at four moments in a run.
| Moment | Control | Question it answers | Without it |
|---|---|---|---|
| Before work starts | Admission | Is this request complete enough to finish, and small enough to afford? | Work begins with no definition of done, so nothing can say when to stop. |
| While work runs | Budget | How much of the allowance has gone? | The limit arrives as a provider error, at a moment nobody chose. |
| While work runs | Watchdog | Has anything been proved since the last check? | Activity passes for progress. |
| After work stops | Checkpoint | Can another model continue from here? | The next attempt starts from nothing. |
Admission is the cheapest control because it acts before anything has been spent. admit() in governance/admission.py runs its checks in a fixed order: schema, acceptance criteria, stop condition, cache hit, fresh-token limit, total-token limit, and finally the choice of model. The order carries the reasoning. A request with no stop condition goes back as REQUIRE_REPAIR before any budget question arises, because an agent with no definition of done has no honest way to finish, and a budget cannot repair that; it can only end the run later, at greater cost. A request that is complete but too large receives REJECT, which is a different fact and calls for a different response from the caller. Bounded work requested of the primary model receives DOWNROUTE to the worker role, so the expensive model stays reserved for work that declares requires_deep_reasoning. Admission decides what may begin; however, it can say nothing about what happens next, and that is the job of the budget.
A budget with one hard limit fails at the worst possible moment, because the run reaches the wall with no allowance left to save its state. evaluate_budget() in governance/budget.py therefore compares five counters (fresh input tokens, output tokens, turns, tool calls, and wall seconds) against their limits and reports the worst ratio as a level. At 70 percent the level is SOFT, with the instruction to compact context and reduce work. At 88 percent it is HARD, with the instruction to checkpoint before additional expensive work. At the limit itself it is EMERGENCY, and allowed becomes false. The consequence of that staging is that a checkpoint happens while the run can still afford one. Even so, a budget measures spending and not result, and a run can stay comfortably inside its allowance while achieving nothing at all.
The watchdog exists because a busy agent and a productive agent look identical to a budget. evaluate_watchdog() in governance/watchdog.py counts time, actions, and tokens since the last proof of progress, not since the run began. With the default WatchdogPolicy, the run receives WARN after 60 seconds and CHECKPOINT_AND_STOP after 120 seconds, or as soon as it passes 5 actions or 4,000 tokens with no proof. Loop fingerprints cover the narrower case of exact repetition: fingerprint() hashes the tool name, the arguments, and the task state with SHA-256, and it ignores timestamp, request_id, and nonce, so a retry that changes only a timestamp still counts as the same call. What qualifies as proof remains the host's decision. The gateway contributes one such signal, a capability_completed event with a progress weight of 1, while a refusal records a weight of 0. Stopping a stalled run is only half the problem, because a stop without a record throws away everything the run has learned.
A stop is useful only when something survives it. write_checkpoint() writes a temporary file and then replaces the target, so a crash mid-write leaves the old checkpoint or the new one and never half of either. choose_orchestrator() then ties recovery to that file: a switch to the fallback model raises RuntimeError when no checkpoint exists, because the fallback has nothing else to continue from. The switch is also one-way. Once fallback is active, every later call returns the fallback with the reason one-way fallback lock, which prevents a run from bouncing between two models while each undoes the other. That is the whole chain: admission defines done, the budget warns early enough to save, the watchdog notices when nothing is happening, and the checkpoint makes the stop recoverable. Each control depends on the one before it, and the next section shows how they fit into one flow.
flowchart TD
A[Orchestrator] -->|context capsule| B{Admission}
B -->|needs repair or too large| A
B -->|allowed or downrouted| C[Agent works]
C --> D[Hooks: loop check and ledger]
D --> E{Budget and watchdog}
E -->|continue| C
E -->|stop| F[Checkpoint]
F --> G{Fallback router}
G -->|checkpoint exists| A
A -->|task packet| H{Gateway}
H -->|refused| I[Result packet with a reason]
H -->|schemas and outputs pass| J[Handler]
J --> K[Result packet with proofs]
A -.->|advisory task| L[Ollama pool]
L -.->|needs primary review| A
The package has no run loop of its own, and that absence is deliberate. These are plain functions and a command line that a host calls at the points in the diagram, so the same checks can serve an agent host's hooks, a shell script, or an orchestrator in another process.
| Part | Code | Role |
|---|---|---|
| Admission | governance/admission.py |
Sends back or refuses a request before work starts |
| Budget | governance/budget.py |
Reports NORMAL, SOFT, HARD, or EMERGENCY
|
| Watchdog | governance/watchdog.py |
Reports CONTINUE, WARN, or CHECKPOINT_AND_STOP
|
| Loop fingerprints | governance/loop_fingerprinter.py |
Flags an identical call past a limit |
| Fallback router | governance/router.py |
One-way switch, gated on a checkpoint |
| Checkpoints and ledger |
governance/checkpoint.py, governance/ledger.py
|
Atomic JSON state and an append-only JSONL log |
| Rule packs |
governance/rulepack.py, governance/rule_eval.py
|
YAML rules in a closed grammar, with three-valued logic |
| Capability gateway | gateway/dispatch.py |
Dispatches a task packet and answers with a result packet |
| Agent pool | pool/ |
Optional advisory work on a local Ollama model, off by default |
| Agent definitions | governance/agent_registry.py |
Refuses unsafe combinations in agent files |
The example is a tour rather than a recording of a live agent. Each stage receives fixed inputs and calls the real code, and the pool stage uses a stub in place of Ollama, so the whole script runs offline and writes only to a temporary folder. The order follows the life of a run, which makes the tour a fair picture of how the controls relate. The full output, from a run on 2026-11-06, is below; the rest of this section reads it line by line.
admission: DOWNROUTE to worker: primary model not required for bounded implementation
budget: HARD
loop: deny repeat call: True
watchdog: CHECKPOINT_AND_STOP
fallback: fallback, resume at ['rerun tests']
DECISION ACCEPT [MEDIUM] by RG-004 of RELEASE-GATE
MATCHED RG-004: change.tests_passed == true and change.review_confidence >= 0.9
change.review_confidence = 0.95
change.tests_passed = True
DECISION none: no rule matched in RELEASE-GATE
dispatch: passed [{'name': 'word_count', 'value': 4}]
dispatch: blocked capability_unbuilt
pool: completed, demo:latest, review required: True
1. Admission. The request asks for the primary model, estimates 1,000 fresh tokens against a limit of 5,000, and carries acceptance criteria and a stop condition. It passes every check, but it does not set requires_deep_reasoning, so admission moves the work to the worker role. The decision itself has spent nothing.
2. Budget. The budget allows 1,000 fresh tokens and the run has used 900. A ratio of 0.90 is above the 0.88 line, so the level is HARD while allowed stays true. The run may continue, but the instruction is to checkpoint first, because this is the last moment when a checkpoint is cheap.
3. Loop check. The script records the same grep call three times with a limit of 2. The tracker reports True on the third, since the count now exceeds the limit. The call carries a timestamp argument, and normalization removes it, which is why a real retry with a fresh timestamp would still match.
4. Watchdog. The state says the last proof of progress came 130 seconds ago, and the default policy intervenes at 120. The answer is CHECKPOINT_AND_STOP, regardless of how much budget remains. This is the point of keeping the two controls apart: the budget above would have let the run go on.
5. Fallback. The script writes a checkpoint containing {"next_actions": ["rerun tests"]} and then reports primary_rate_limit. Because that reason is eligible and the checkpoint exists, the router moves the run to the fallback role, and the script reads rerun tests back from the checkpoint as the next action. The same call with no checkpoint raises Fallback blocked because no checkpoint exists.
6. Release rules. The rule pack examples/rules/release-gate.yaml decides whether the finished change may pass. Given tests that passed and a review confidence of 0.95, rule RG-004 accepts it, and the trace shows the value behind each operand. The second decision is the more instructive one. Given an empty context, no rule fires, and the command line shows why:
DECISION none: no rule matched in RELEASE-GATE
UNKNOWN (4): context did not supply:
RG-001: change.touches_credentials
RG-002: change.tests_passed
RG-003: change.review_confidence
RG-004: change.review_confidence, change.tests_passed
A check that did not happen is reported as unknown, never as false, so a wiring fault cannot pass for a clean result.
7. Dispatch. run_task_packet() sends examples/task_packet.json to the capability text.word_count. The handler counts "four words of text" and reports the required artifact, so the gateway returns passed. The same packet aimed at text.summarise comes back blocked with capability_unbuilt, because that capability is declared in examples/capabilities.yaml and has no handler yet. Nothing raised; the caller received a valid result packet in both cases.
8. Advisory pool. The last stage delegates a review to a local role, with a stub standing in for the model. The stub reports a confidence of 0.6 and no opinion about review. The envelope still says review required: True, because normalize_result() sets that flag itself and the model cannot clear it.
The tour therefore runs from a request that was narrowed before it started, through a run that was stopped and handed over, to a change that was accepted on stated evidence. Evidence shows how those claims are tested, and Limits and Next Steps sets out what a host must still supply.
Designed and authored by Daniel Boles; implemented with coding agents. Apache-2.0.