Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
119 changes: 119 additions & 0 deletions examples/codebase-qa/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# 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?"`.

## Output

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: 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.
```

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

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.
219 changes: 219 additions & 0 deletions examples/codebase-qa/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
#!/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 <relative/path> — read a file before answering\n"
" ANSWER <your 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 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(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:
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())
5 changes: 5 additions & 0 deletions examples/codebase-qa/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions examples/codebase-qa/sample/README.md
Original file line number Diff line number Diff line change
@@ -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`.
10 changes: 10 additions & 0 deletions examples/codebase-qa/sample/config.py
Original file line number Diff line number Diff line change
@@ -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"))
13 changes: 13 additions & 0 deletions examples/codebase-qa/sample/main.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading