TAI BUI
Back to curriculum
CapstonePython (stdlib)

Capstone Lesson 25: Verification Gates and the Observation Budget

An agent harness with no verification layer is a wish in a trenchcoat. This lesson builds the deterministic gate chain that decides whether a tool call may fire, how much of its output the agent gets to see, and when the loop stops because the agent has read too much. The chain is a set of small, named gates plus an observation ledger that tracks every token the model has been shown.

TIME: ~90 minutesPREREQUISITES: Phase 19 · 20-24 (Track A1: agent loop, tool registry, message store, prompt builder, model router), Phase 14 · 33 (instructions as constraints), Phase 14 · 36 (scope contracts), Phase 14 · 38 (verification gates)

Learning Objectives

  • Build a `VerificationGate` protocol with a deterministic `evaluate(call)` method.
  • Compose budget, recency, whitelist, and regex gates into a chain with short-circuit semantics.
  • Track every observation through an `ObservationLedger` keyed by tool and turn.
  • Refuse a tool call when the cumulative observation budget would be exceeded.
  • Surface a structured `GateDecision` record that downstream observability can ingest.

The Problem

A verification gate is the harness component that says no. It is a deterministic function of (call, history, ledger) that returns either ALLOW or DENY with a reason. It is not a model and not a judge. The reason is logged, the model is told, and the loop continues or aborts.

You need one because a harness that lets the model call tools freely runs into three classes of bug within the first hour of real use.

The first is unbounded observation. A grep across a 200K-line repo dumps half a million tokens into the next turn. The model gets one match per kilobyte, the rest of the context is wasted, the token bill is large, and the agent ends up worse at the task, not better.

The second is stale recency. A long-running task piles up fifty tool calls, and the model rereads the first read_file from turn three as if it were live state. Edits made on turn forty-seven never show up, because the prompt builder serialized the earliest observations first.

The third is privilege creep. A research task starts with web_search and somehow ends up running shell, because the model invented a tool name and the harness defaulted to permissive. By the time anyone reads the trace, a junk file is sitting in /tmp and a curl has hit a private API.

Warm-up0/3
1. A coding agent can run shell commands on your machine. Which control better stops it from running `rm -rf /`?Warm-up
2. A program must check, many times per second, whether a requested name is one of 50 permitted names. Which structure makes each check cheapest?Warm-up
3. A chat model is billed per token it reads, and every request resends the earlier conversation. What happens to cost as a long session keeps appending large text blocks?Warm-up
Check: The Problem0/4
1. What is a verification gate in an agent harness?Define
2. Why is a verification gate built as deterministic code instead of another model that approves each tool call?Explain
3. What problem does RecencyGate address?Explain
4. A research agent invents a tool called shell, and the harness runs it. The chain has regex, recency and budget gates but no whitelist. Which fix addresses the root cause?Analyze

The Concept

A gate is anything with an evaluate(call, ctx) -> GateDecision method. The chain is an ordered list of gates, and evaluation short-circuits on the first deny. Order matters: cheap structural gates run before expensive token-counting ones.

Mermaid diagram

This lesson ships four gates:

  • WhitelistGate holds an explicit set of allowed tool names and denies anything outside it. It is the cheapest gate, so it runs first.
  • RegexGate matches tool arguments against a regex. Use it to refuse shell calls containing rm -rf, or HTTP calls to internal IPs. It is pure on the call payload.
  • RecencyGate refuses a call when the last recorded observation is more than N turns old, which forces a fresh read instead of relying on stale state. The first call in a session always passes.
  • BudgetGate enforces a ceiling on the cumulative tokens the model has read across the session. Once the ledger says the ceiling is reached, every further tool call is denied.

The observation ledger is the bookkeeping behind them. Every successful tool call writes one row: turn, tool name, the text shown, and its token count. The ledger answers two questions, how much the model has seen in total and how much it has seen of tool X. The budget gate reads the first. A per-tool budget gate, which you will write as an exercise, reads the second.

Check: The Concept0/17
1. What is the ObservationLedger?Define
2. What is a gate chain?Define
3. What does BudgetGate enforce?Define
4. RegexGate matches a tool call's arguments against a pattern. What does the lesson say it is useful for?Define
5. The ObservationLedger's cumulative() answers 'how much has the model seen in total.' What does per_tool(name) answer?Define
6. A model invents a tool name that appears on no list of dangerous commands. Why does a default-deny whitelist handle this better than a blocklist of bad names?Explain
7. An ObservationLedger has recorded a 60-token observation from read_file. The harness then refuses a subsequent call. What does the ledger record about the refusal?Explain
8. BudgetGate checks the ledger before a call runs. Why can the session's total still end above the ceiling?Explain
9. A ledger holds a 9-token list_dir result and a 156-token read_file result, and BudgetGate has max_tokens=200. What does the gate return for the next call?Apply
10. RecencyGate is configured with window=3, and the ledger's latest observation is from turn 1. A call arrives at turn 6. What does the gate do?Apply
11. RecencyGate has window=3. The ledger's latest observation is from turn 2, and a call arrives at turn 5. What does the gate return?Apply
12. BudgetGate has max_tokens=200, and the ledger's cumulative total is exactly 200. A whitelisted call arrives. What does the gate return?Apply
13. Your budget is 200 tokens and the ledger already holds 322. A whitelisted run_tests call with clean arguments arrives within the recency window. What happens, and why?Apply
14. The first call of a session is a grep that returns half a million tokens, and BudgetGate has max_tokens=200. All four gates are in the chain. What happens?Analyze
15. A developer expects RecencyGate(window=3) to deny the first call of a session, since nothing was observed recently. The test fails. Which assumption was wrong?Analyze
16. You want to add a per-call output cap (no single observation may exceed 8000 tokens). Where does this gate logically belong in the chain?Design
17. One tool, grep, tends to flood the context. The total ceiling is 50,000 tokens and the other tools are small, and you want to cap grep alone at 2,000. Which design fits?Design

Architecture

Mermaid diagram

The harness asks the chain, and the chain either nods or refuses. On a nod, the tool runs, the ledger ticks, and the result is appended to the message store. On a refusal, the model gets the refusal as a system message and the loop decides whether to retry or abort.

Check: Architecture0/2
1. When the chain refuses a call, what does the harness do next?Explain
2. A reviewer asks you to prove that the agent never fired a denied tool call during a session. Which artifact do you point them at?Design

What you will build

The implementation is a single main.py plus tests.

  1. Observation and ToolCall dataclasses define the wire shapes.
  2. ObservationLedger records (turn, tool, tokens) rows and answers cumulative() and per_tool(name).
  3. GateDecision carries (allow, gate, reason).
  4. VerificationGate is the protocol. Each gate implements evaluate(call, ctx).
  5. GateChain wraps an ordered list. It calls each gate and returns the first deny, or allow if every gate passes.
  6. The demo runs five synthetic calls through a 200-token budget: three are allowed and two are refused. The budget gate refuses the fourth call (322/200 tokens), and the whitelist refuses the fifth (shell), so the loop reports a clean refusal count of 2.

The token counter is deliberately crude: len(text) // 4. This lesson is about the gate plumbing, not the tokenizer, so swap in a real tokenizer in production.

Check: What you will build0/4
1. What fields does a GateDecision record carry?Define
2. The lesson counts tokens with len(text) // 4 and calls it crude on purpose. Why is that acceptable for the gate chain?Explain
3. What does the lesson's token estimator, built on len(text) // 4 with a floor of 1 for non-empty text, return for the 3-character string 'abc'?Apply
4. The chain is Whitelist, Regex, Recency, Budget, and the whitelist allows only read_file, list_dir and run_tests. A call to shell with argv rm -rf / arrives while the budget is also exhausted. How many decisions does the ChainOutcome hold?Apply

Why the chain order matters

A deny is cheaper than an allow. WhitelistGate is an O(1) hash lookup. RegexGate costs O(pattern * argv). RecencyGate reads one value from the ledger (latest_turn), and BudgetGate reads the entire ledger. Ordering the gates by ascending cost lets a denied call short-circuit before any expensive work happens.

Blast radius points the same way. The whitelist makes the strongest claim: this tool is not in the contract. The regex gate comes next: this argument is not in the contract. Recency follows, because the harness still cares but the call is structurally legal. Budget goes last since, by definition, it only fires when everything else has passed.

Check: Why the chain order matters0/2
1. Why does the lesson run WhitelistGate before BudgetGate in the chain?Explain
2. A teammate moves BudgetGate to the front of the chain because budget is the most important limit. What is the cost of that ordering?Analyze

How this composes with the rest of Track A

The previous lessons gave you the loop, the tool registry, the message store, the prompt builder, and the model router. This lesson adds the layer between the model and the tools.

Lesson 26 ships the sandbox the dispatcher hands the tool call to once the chain says ALLOW. Lesson 27 ships the eval harness, which records refusal counts as a quality signal. Lesson 28 wires the gate decisions into OpenTelemetry spans, and Lesson 29 stitches everything into a working coding agent.

Author's Note

I'd keep the whitelist default-deny from the very first commit. The Problem section describes a model inventing a tool name and the harness defaulting to permissive, and WhitelistGate is the gate built to refuse exactly that.

I'd also treat the len(text) // 4 counter as a placeholder to replace early. The budget gate only enforces the ceiling you give it, and the lesson calls that counter crude on purpose, so my bet is the ceiling should be set against a real tokenizer before it means anything.

And I'd write the per-tool budget gate from the exercises. The ledger already answers per_tool(name), and the grep example in the Problem section is exactly one tool flooding the context, which is the case that gate can cap.

Running it

bash
cd phases/19-capstone-projects/25-verification-gates-observation-budget
python3 code/main.py
python3 -m pytest code/tests/ -v

The demo prints a turn-by-turn trace that includes every gate decision, and exits zero. The tests cover the ledger, each gate in isolation, the chain short-circuit, and the synthetic loop end to end.

Code