From b932bee6d557fe9208df6ed50cc2fc010d51c161 Mon Sep 17 00:00:00 2001 From: Adarsh Prashar Date: Sun, 31 May 2026 18:21:44 +0530 Subject: [PATCH 1/2] docs(examples): add codebase-qa demo agent showcasing the governor kill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A self-contained, runnable demo (Python SDK + proxy) that shows RiskKernel's headline feature end to end: a real ReAct loop over a codebase that the deterministic governor halts on its loop/dollar budget — the kill comes from the daemon (HTTP 402), not the script. - examples/codebase-qa/agent.py: --mode normal (finishes within budget) and --mode runaway (loops until the governor halts it); routes every model call through run.proxy_config() so the proxy meters cost and enforces the budget; stdlib-only (urllib) beyond the RiskKernel SDK. No RAG, vector DB, or framework. - examples/codebase-qa/sample/: a tiny todo app used as the target codebase. - examples/codebase-qa/README.md: 60-second run for both modes + expected output. - README + CHANGELOG: link the example. --- CHANGELOG.md | 7 + README.md | 4 + examples/codebase-qa/README.md | 96 ++++++++++++ examples/codebase-qa/agent.py | 218 ++++++++++++++++++++++++++ examples/codebase-qa/requirements.txt | 5 + examples/codebase-qa/sample/README.md | 3 + examples/codebase-qa/sample/config.py | 10 ++ examples/codebase-qa/sample/main.py | 13 ++ examples/codebase-qa/sample/models.py | 11 ++ examples/codebase-qa/sample/store.py | 11 ++ 10 files changed, 378 insertions(+) create mode 100644 examples/codebase-qa/README.md create mode 100644 examples/codebase-qa/agent.py create mode 100644 examples/codebase-qa/requirements.txt create mode 100644 examples/codebase-qa/sample/README.md create mode 100644 examples/codebase-qa/sample/config.py create mode 100644 examples/codebase-qa/sample/main.py create mode 100644 examples/codebase-qa/sample/models.py create mode 100644 examples/codebase-qa/sample/store.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dd1f9b..a41ce61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ surface is governed by [`COMPATIBILITY.md`](COMPATIBILITY.md). ## [Unreleased] +### Added +- **`examples/codebase-qa`** — a runnable demo agent (Python SDK + proxy) that + showcases the headline feature: a real ReAct loop over a codebase that the + deterministic governor halts on its loop/dollar budget. Includes `--mode normal` + (completes within budget) and `--mode runaway` (governor kills it), a bundled + sample codebase, and expected terminal output. No RAG, vector DB, or framework. + ## [0.1.0] - 2026-05-31 The first release: the deterministic reliability runtime for AI agents — diff --git a/README.md b/README.md index 81f17d0..6591af7 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,10 @@ Prefer a binary? `go build -o riskkernel ./cmd/riskkernel` (or `make build`), th Python SDK: `pip install riskkernel` — see [`sdks/python`](sdks/python). Trace every run in your own backend: [`examples/otel`](examples/otel). +Want to *see* the headline feature? [`examples/codebase-qa`](examples/codebase-qa) +is a runnable agent that loops over a codebase until the governor kills it on its +loop/dollar budget — the deterministic kill, end to end, with a real model. + ## Design principles - **Deterministic core in Go.** All enforcement (budgets, kill switches, gating, routing, retries, checkpointing) lives in compiled, statically-typed code — never in an LLM. diff --git a/examples/codebase-qa/README.md b/examples/codebase-qa/README.md new file mode 100644 index 0000000..bbdc704 --- /dev/null +++ b/examples/codebase-qa/README.md @@ -0,0 +1,96 @@ +# codebase-qa — watch RiskKernel kill a runaway agent + +A tiny, **real** codebase Q&A agent governed by RiskKernel. It's a plain +ReAct-style loop (no RAG, no vector DB, no framework): each step it asks the model +what to do, READs a file or ANSWERs, repeats. Every model call goes through the +RiskKernel proxy, so the **deterministic governor meters cost and enforces a hard +per-run loop / dollar / time budget** around the loop. + +Two modes: +- **`--mode normal`** — a sensible question that finishes within budget. Prints each + step, the token count, and the running USD cost, then the answer. (Happy path + + full per-step observability.) +- **`--mode runaway`** — the *same* agent with a deliberately weak stopping + condition (it's told to re-read every file before answering), so it loops. The + counters climb each step, then the governor's loop budget **halts it cleanly**. + The kill comes from RiskKernel, not from the script — this is the money shot. + +It uses your own `ANTHROPIC_API_KEY` (BYO key) and nothing else. + +## Run it in 60 seconds + +```bash +# 1. start the daemon with your key (Docker — or `riskkernel serve` from a binary) +docker run --rm -p 7070:7070 -e ANTHROPIC_API_KEY=sk-ant-... ghcr.io/prashar32/riskkernel:latest + +# 2. in another terminal, install the SDK and run the agent +cd examples/codebase-qa +pip install -r requirements.txt # installs the local RiskKernel SDK (stdlib-only) + +python agent.py --mode normal # happy path — answers within budget +python agent.py --mode runaway # the money shot — governor kills the loop +``` + +By default it answers questions about the bundled `./sample` todo app. Point it at +any codebase with `--dir ../../internal/governor --question "What does this enforce?"`. + +## Expected output — `--mode runaway` + +The counters climb each step, then the governor halts the run: + +``` +▶ codebase-qa mode=runaway dir=.../sample model=claude-haiku-4-5-20251001 + budget: loops=4 dollars=$0.05 seconds=120 + question: What does this codebase do and where is the entrypoint? + + run: 7f3a1c2e-... + + step 1 │ READ main.py │ tokens= 190 │ cost=$0.0004 + step 2 │ READ store.py │ tokens= 402 │ cost=$0.0009 + step 3 │ READ models.py │ tokens= 611 │ cost=$0.0014 + step 4 │ READ config.py │ tokens= 828 │ cost=$0.0019 + +🛑 RiskKernel HALTED the run — reason: loop_budget_exceeded + ── final ledger ───────────────────────────── + steps (loops) : 4 + tokens : 828 + cost : $0.0019 + run status : halted (state persisted & resumable) + run id : 7f3a1c2e-... + The agent would have looped forever; the governor stopped it cleanly. +``` + +(Exact token/cost numbers vary with the model; the structure — climbing counters +then a clean governor kill at the loop cap — is the point.) `--mode normal` instead +prints a few `READ`/`ANSWER` steps and a final answer, well under budget. + +## Tuning for a recording + +All knobs are commented constants at the top of `agent.py`: +- `RUNAWAY_BUDGET` (`loops=4`) — lower it for a faster kill, raise it for more + climbing steps before the halt. +- `MODEL`, `MAX_OUTPUT_TOKENS`, `MAX_FILE_CHARS` — keep the demo cheap and fast. + +The kill is **always** the real governor returning HTTP `402` from the proxy; the +script never fakes it. + +## Same agent, zero code — via the proxy + +You don't need this SDK at all to get governance. Any app that speaks the OpenAI +API is governed by changing **one env var** to point at the daemon: + +```bash +export OPENAI_BASE_URL=http://localhost:7070/v1 +# your existing agent runs unchanged; set X-RiskKernel-Run-Id to group its calls +# into one budgeted run. Same loop/dollar/time enforcement, zero code changes. +``` + +This example uses the Python SDK because it shows the per-step ledger and the +`BudgetExceeded` halt explicitly — but the proxy path is the zero-code on-ramp. + +## What about approvals? + +This agent is read-only (it only READs files), so the human-in-the-loop approval +gate isn't exercised here. For a side-effecting tool (shell, write, deploy) you'd +wrap it with `@riskkernel.governed_tool(side_effect="write")` or call +`run.approve(...)`, and the call would pause for approval — see the SDK README. diff --git a/examples/codebase-qa/agent.py b/examples/codebase-qa/agent.py new file mode 100644 index 0000000..19ffb70 --- /dev/null +++ b/examples/codebase-qa/agent.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""codebase-qa — a tiny, real codebase Q&A agent governed by RiskKernel. + +It's a plain ReAct-style loop (no RAG, no vector DB, no framework): each step it +asks the model what to do, READs a file or ANSWERs, and repeats. Every model call +goes through the RiskKernel proxy (via the SDK's ``run.proxy_config()``), so the +deterministic governor meters cost and enforces the per-run loop / dollar / time +budget around the loop. + +Two modes (``--mode``): + normal — a sensible question that completes within budget; prints each step, + tokens, and running USD cost, then the answer. + runaway — the SAME agent with a deliberately weak stopping condition (it's told + to re-read every file before answering), so it loops. The counters + climb each step and then the governor's LOOP budget halts the run + cleanly — the kill comes from RiskKernel, not from this script. + +BYO key: you run `riskkernel serve` with your ANTHROPIC_API_KEY; this agent only +talks to the daemon. Nothing else is required. + +This file doubles as RiskKernel Python SDK documentation — the governance bits are +``rt.budget(...)``, ``rt.governed_run(...)``, ``run.proxy_config()``, +``run.status()``, and catching ``rk.BudgetExceeded``. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request +from pathlib import Path + +import riskkernel as rk + +# ───────────────────────────────────────────────────────────────────────────── +# Tweakable constants — tune these for a clean recording. Everything here is real; +# nothing about the kill is faked. The governor (in the daemon) does the enforcing. +# ───────────────────────────────────────────────────────────────────────────── +DAEMON_URL = os.environ.get("RISKKERNEL_BASE_URL", "http://localhost:7070") +API_TOKEN = os.environ.get("RISKKERNEL_API_TOKEN") # only if your daemon sets one +MODEL = os.environ.get("RK_DEMO_MODEL", "claude-haiku-4-5-20251001") # cheap + fast +MAX_OUTPUT_TOKENS = 200 # small responses keep the demo cheap +MAX_FILE_CHARS = 1_500 # cap each file read so token use stays modest + +# Budgets are per-run hard limits enforced by the governor. +NORMAL_BUDGET = dict(loops=10, dollars=0.10, seconds=120) # generous: finishes +RUNAWAY_BUDGET = dict(loops=4, dollars=0.05, seconds=120) # small loop cap: kills fast + +# Belt-and-suspenders: the governor should ALWAYS fire before this Python-side cap. +# It exists only so a misconfigured (too-generous) budget can't loop forever. +SAFETY_ITERS = 50 + + +def build_system_prompt(files: list[str], mode: str) -> str: + listing = "\n".join(f" - {f}" for f in files) + base = ( + "You are a codebase Q&A agent. You can use exactly two tools, one per turn.\n" + "Reply with a SINGLE line, nothing else:\n" + " READ — read a file before answering\n" + " ANSWER — give the final answer and stop\n\n" + f"Files available to READ:\n{listing}\n" + ) + if mode == "runaway": + # A deliberately weak stopping condition — a real failure mode. The agent + # is told to be paranoid and re-verify, so it keeps READing and never + # converges. This is honest: the agent has a bad heuristic; RiskKernel is + # what stops it. + base += ( + "\nBE EXTREMELY THOROUGH. Do NOT ANSWER until you have re-read EVERY file " + "at least twice to cross-check yourself. If you are not 100% certain you " + "have re-read everything, READ the next file again instead of answering." + ) + else: + base += "\nRead only the files you need, then ANSWER concisely." + return base + + +def list_files(directory: Path) -> list[str]: + exts = {".py", ".md", ".txt", ".yaml", ".yml", ".go", ".js", ".ts"} + out = [] + for p in sorted(directory.rglob("*")): + if p.is_file() and p.suffix.lower() in exts: + out.append(str(p.relative_to(directory))) + if len(out) >= 12: + break + return out + + +def read_file(directory: Path, name: str) -> str: + # Stay within the target directory (no traversal); the daemon's memory layer + # has the same guard — here we just keep the demo honest. + target = (directory / name).resolve() + if not str(target).startswith(str(directory.resolve())): + return "(refused: path escapes the target directory)" + if not target.is_file(): + return f"(no such file: {name})" + return target.read_text(errors="replace")[:MAX_FILE_CHARS] + + +def call_llm(cfg: dict, system: str, messages: list[dict]) -> str: + """One model call THROUGH the RiskKernel proxy. The proxy meters tokens/cost + and enforces the run's budget; a spent budget comes back as HTTP 402, which we + surface as rk.BudgetExceeded so the loop halts on the governor's verdict.""" + body = json.dumps({ + "model": MODEL, + "max_tokens": MAX_OUTPUT_TOKENS, + "messages": [{"role": "system", "content": system}] + messages, + }).encode() + headers = {"content-type": "application/json", **cfg["headers"]} + if API_TOKEN: + headers["Authorization"] = "Bearer " + API_TOKEN + req = urllib.request.Request(cfg["base_url"] + "/chat/completions", data=body, headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=60) as resp: + data = json.loads(resp.read()) + return data["choices"][0]["message"]["content"].strip() + except urllib.error.HTTPError as e: + payload = {} + try: + payload = json.loads(e.read()) + except Exception: + pass + if e.code == 402: # the governor halted the run + raise rk.BudgetExceeded(payload.get("code", "budget_exceeded"), + payload.get("message", "")) from None + raise rk.APIError(e.code, payload.get("code", ""), payload.get("message", str(e))) from None + except urllib.error.URLError as e: # daemon unreachable + raise rk.APIError(0, "connection_error", str(e.reason)) from None + + +def parse_action(reply: str): + line = reply.strip().splitlines()[0].strip() if reply.strip() else "" + if line.upper().startswith("READ "): + return "READ", line[5:].strip() + if line.upper().startswith("ANSWER"): + return "ANSWER", line[6:].lstrip(": ").strip() or reply + return "ANSWER", reply # fallback: treat anything else as the answer + + +def run_agent(directory: Path, question: str, mode: str) -> int: + files = list_files(directory) + if not files: + print(f"no readable files under {directory}", file=sys.stderr) + return 2 + + rt = rk.Runtime(base_url=DAEMON_URL, token=API_TOKEN) + budget = rt.budget(**(RUNAWAY_BUDGET if mode == "runaway" else NORMAL_BUDGET)) + system = build_system_prompt(files, mode) + messages = [{"role": "user", "content": f"Question: {question}"}] + + print(f"▶ codebase-qa mode={mode} dir={directory} model={MODEL}") + print(f" budget: loops={budget.loops} dollars=${budget.dollars} seconds={budget.seconds}") + print(f" question: {question}\n") + + # cancel_on_error=False so a budget halt leaves the run exactly as the governor + # left it (status 'halted', the real halt reason) rather than 'cancelled'. + try: + with rt.governed_run(name=f"codebase-qa-{mode}", budget=budget, + cancel_on_error=False) as run: + cfg = run.proxy_config() + print(f" run: {run.id}\n") + for _ in range(SAFETY_ITERS): + reply = call_llm(cfg, system, messages) # ← metered + governed by the proxy + u = run.status()["usage"] # ← live ledger from the daemon + action, arg = parse_action(reply) + summary = f"READ {arg}" if action == "READ" else "ANSWER" + print(f" step {u['loops']:>2} │ {summary:<28} │ tokens={u['tokens']:>5} │ cost=${u['dollars']:.4f}") + + messages.append({"role": "assistant", "content": reply}) + if action == "ANSWER": + print(f"\n✅ completed within budget.\n\n— Answer —\n{arg}\n") + return 0 + # READ: feed the file back and keep going. + content = read_file(directory, arg) + run.checkpoint("after-read", {"file": arg}) # ← resumable state + messages.append({"role": "user", "content": f"Contents of {arg}:\n{content}"}) + + print("\n(safety cap reached — your budget is too generous to demo the kill; lower RUNAWAY_BUDGET)") + return 0 + except rk.BudgetExceeded as e: + # The kill came from the governor in the daemon — not from this script. + final = rt.client.get_run(run.id) + u = final["usage"] + print(f"\n🛑 RiskKernel HALTED the run — reason: {e.reason}") + print(" ── final ledger ─────────────────────────────") + print(f" steps (loops) : {u['loops']}") + print(f" tokens : {u['tokens']}") + print(f" cost : ${u['dollars']:.4f}") + print(f" run status : {final['status']} (state persisted & resumable)") + print(f" run id : {run.id}") + print(" The agent would have looped forever; the governor stopped it cleanly.\n") + return 0 + except rk.APIError as e: + if "connection" in (e.code or "") or e.status == 0: + print(f"\nCannot reach the RiskKernel daemon at {DAEMON_URL}.", file=sys.stderr) + print("Start it first: riskkernel serve (with ANTHROPIC_API_KEY set)\n", file=sys.stderr) + else: + print(f"\nAPI error: {e}", file=sys.stderr) + return 1 + + +def main() -> int: + ap = argparse.ArgumentParser(description="RiskKernel-governed codebase Q&A agent") + ap.add_argument("--mode", choices=["normal", "runaway"], required=True, + help="normal = completes within budget; runaway = loops until the governor kills it") + ap.add_argument("--dir", default=str(Path(__file__).parent / "sample"), + help="target codebase directory (default: bundled ./sample)") + ap.add_argument("--question", default="What does this codebase do and where is the entrypoint?", + help="the question to answer") + args = ap.parse_args() + return run_agent(Path(args.dir), args.question, args.mode) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/codebase-qa/requirements.txt b/examples/codebase-qa/requirements.txt new file mode 100644 index 0000000..0b43260 --- /dev/null +++ b/examples/codebase-qa/requirements.txt @@ -0,0 +1,5 @@ +# The only dependency is the RiskKernel Python SDK (which is stdlib-only itself — +# the agent uses urllib for the proxy call, no requests/httpx needed). +# The SDK isn't on PyPI yet, so install it from this repo. From this directory: +# pip install -r requirements.txt +../../sdks/python diff --git a/examples/codebase-qa/sample/README.md b/examples/codebase-qa/sample/README.md new file mode 100644 index 0000000..102e9be --- /dev/null +++ b/examples/codebase-qa/sample/README.md @@ -0,0 +1,3 @@ +# Sample todo app +A tiny CLI todo app used as the target codebase for the RiskKernel Q&A demo. +Entrypoint: `main.py`. Data layer: `store.py`. Models: `models.py`. Config: `config.py`. diff --git a/examples/codebase-qa/sample/config.py b/examples/codebase-qa/sample/config.py new file mode 100644 index 0000000..e807082 --- /dev/null +++ b/examples/codebase-qa/sample/config.py @@ -0,0 +1,10 @@ +"""Configuration loading for the sample app.""" +import os +from dataclasses import dataclass + +@dataclass +class Config: + db_path: str + +def load_config(): + return Config(db_path=os.environ.get("TODO_DB", "./todos.db")) diff --git a/examples/codebase-qa/sample/main.py b/examples/codebase-qa/sample/main.py new file mode 100644 index 0000000..72e2d87 --- /dev/null +++ b/examples/codebase-qa/sample/main.py @@ -0,0 +1,13 @@ +"""Entrypoint for the sample todo CLI.""" +from store import TodoStore +from config import load_config + +def main(): + cfg = load_config() + store = TodoStore(path=cfg.db_path) + store.add("write the RiskKernel demo") + for t in store.list(): + print(t.render()) + +if __name__ == "__main__": + main() diff --git a/examples/codebase-qa/sample/models.py b/examples/codebase-qa/sample/models.py new file mode 100644 index 0000000..5606c17 --- /dev/null +++ b/examples/codebase-qa/sample/models.py @@ -0,0 +1,11 @@ +"""Domain models for the sample app.""" +from dataclasses import dataclass + +@dataclass +class Todo: + id: int + title: str + done: bool = False + def render(self): + mark = "x" if self.done else " " + return f"[{mark}] {self.id}. {self.title}" diff --git a/examples/codebase-qa/sample/store.py b/examples/codebase-qa/sample/store.py new file mode 100644 index 0000000..17d6a19 --- /dev/null +++ b/examples/codebase-qa/sample/store.py @@ -0,0 +1,11 @@ +"""In-memory todo store (the sample app's data layer).""" +from models import Todo + +class TodoStore: + def __init__(self, path): + self.path = path + self._items = [] + def add(self, title): + self._items.append(Todo(id=len(self._items) + 1, title=title)) + def list(self): + return list(self._items) From 377cc2ca56506fa2da98576870296418209d0551 Mon Sep 17 00:00:00 2001 From: Adarsh Prashar Date: Sun, 31 May 2026 18:54:54 +0530 Subject: [PATCH 2/2] docs(examples): use verbatim run output and an honest halt summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran both modes against a real model and replaced the placeholder output with the actual runs. The halt summary now reports the authoritative signal — the gateway's 402 loop_budget_exceeded surfaced as BudgetExceeded — and the governor's own ledger (loops vs budget, cost vs budget), rather than the run's persisted status field, which lags for loop/time-budget halts (see #34). The deterministic kill is unchanged; only the demo's reporting is made precise. --- examples/codebase-qa/README.md | 63 +++++++++++++++++++++++----------- examples/codebase-qa/agent.py | 21 ++++++------ 2 files changed, 54 insertions(+), 30 deletions(-) diff --git a/examples/codebase-qa/README.md b/examples/codebase-qa/README.md index bbdc704..e17efa0 100644 --- a/examples/codebase-qa/README.md +++ b/examples/codebase-qa/README.md @@ -34,35 +34,58 @@ python agent.py --mode runaway # the money shot — governor kills the By default it answers questions about the bundled `./sample` todo app. Point it at any codebase with `--dir ../../internal/governor --question "What does this enforce?"`. -## Expected output — `--mode runaway` +## Output -The counters climb each step, then the governor halts the run: +Real runs against `claude-haiku-4-5-20251001` (run-ids and exact token/cost numbers +vary; the structure is the point). + +`--mode normal` — reads what it needs, then answers, well under budget: + +``` +▶ codebase-qa mode=normal dir=.../sample model=claude-haiku-4-5-20251001 + budget: loops=10 dollars=$0.1 seconds=120 + question: What does this codebase do and where is the entrypoint? + + run: d3b4e0db-db34-4bd8-9efc-39171bc782a1 + + step 1 │ READ main.py │ tokens= 147 │ cost=$0.0002 + step 2 │ ANSWER │ tokens= 462 │ cost=$0.0007 + +✅ completed within budget. + +— Answer — +This codebase is a todo CLI application. The entrypoint is main.py, which loads +configuration, initializes a TodoStore database, adds a sample todo item ("write +the RiskKernel demo"), and then lists and renders all todos to the console. +``` + +`--mode runaway` — the money shot. Counters climb each step, then the governor +refuses the over-budget step: ``` ▶ codebase-qa mode=runaway dir=.../sample model=claude-haiku-4-5-20251001 budget: loops=4 dollars=$0.05 seconds=120 question: What does this codebase do and where is the entrypoint? - run: 7f3a1c2e-... - - step 1 │ READ main.py │ tokens= 190 │ cost=$0.0004 - step 2 │ READ store.py │ tokens= 402 │ cost=$0.0009 - step 3 │ READ models.py │ tokens= 611 │ cost=$0.0014 - step 4 │ READ config.py │ tokens= 828 │ cost=$0.0019 - -🛑 RiskKernel HALTED the run — reason: loop_budget_exceeded - ── final ledger ───────────────────────────── - steps (loops) : 4 - tokens : 828 - cost : $0.0019 - run status : halted (state persisted & resumable) - run id : 7f3a1c2e-... - The agent would have looped forever; the governor stopped it cleanly. + run: 5b9a4efa-b714-46bd-9c50-f517d74557e9 + + step 1 │ READ README.md │ tokens= 187 │ cost=$0.0002 + step 2 │ READ main.py │ tokens= 453 │ cost=$0.0005 + step 3 │ READ config.py │ tokens= 833 │ cost=$0.0009 + step 4 │ READ models.py │ tokens= 1294 │ cost=$0.0014 + +🛑 RiskKernel refused the next step — reason: loop_budget_exceeded + ── final ledger (enforced by the governor) ── + steps (loops) : 4 (budget: 4) + tokens : 1294 + cost : $ 0.0014 (budget: $0.05) + run id : 5b9a4efa-b714-46bd-9c50-f517d74557e9 + The agent would have looped forever; the governor capped it at 4 steps. ``` -(Exact token/cost numbers vary with the model; the structure — climbing counters -then a clean governor kill at the loop cap — is the point.) `--mode normal` instead -prints a few `READ`/`ANSWER` steps and a final answer, well under budget. +The 5th call never reaches the model: its `BeginStep` is rejected by the governor +with HTTP `402 loop_budget_exceeded`, which the SDK surfaces as `BudgetExceeded`. +That's the deterministic kill — the script never decides to stop. ## Tuning for a recording diff --git a/examples/codebase-qa/agent.py b/examples/codebase-qa/agent.py index 19ffb70..296c406 100644 --- a/examples/codebase-qa/agent.py +++ b/examples/codebase-qa/agent.py @@ -181,17 +181,18 @@ def run_agent(directory: Path, question: str, mode: str) -> int: print("\n(safety cap reached — your budget is too generous to demo the kill; lower RUNAWAY_BUDGET)") return 0 except rk.BudgetExceeded as e: - # The kill came from the governor in the daemon — not from this script. - final = rt.client.get_run(run.id) - u = final["usage"] - print(f"\n🛑 RiskKernel HALTED the run — reason: {e.reason}") - print(" ── final ledger ─────────────────────────────") - print(f" steps (loops) : {u['loops']}") - print(f" tokens : {u['tokens']}") - print(f" cost : ${u['dollars']:.4f}") - print(f" run status : {final['status']} (state persisted & resumable)") + # The kill came from the deterministic governor in the daemon (HTTP 402) — + # never from this script. e.reason is the machine-readable halt reason, and + # the ledger below is the governor's own count, not the agent's. + u = rt.client.get_run(run.id)["usage"] + print(f"\n🛑 RiskKernel refused the next step — reason: {e.reason}") + print(" ── final ledger (enforced by the governor) ──") + print(f" steps (loops) : {u['loops']:>6} (budget: {budget.loops})") + print(f" tokens : {u['tokens']:>6}") + print(f" cost : ${u['dollars']:>8.4f} (budget: ${budget.dollars})") print(f" run id : {run.id}") - print(" The agent would have looped forever; the governor stopped it cleanly.\n") + print(f" The agent would have looped forever; the governor capped it at " + f"{budget.loops} steps.\n") return 0 except rk.APIError as e: if "connection" in (e.code or "") or e.status == 0: