diff --git a/CHANGELOG.md b/CHANGELOG.md index 6edea87..9ec1e0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ surface is governed by [`COMPATIBILITY.md`](COMPATIBILITY.md). ## [Unreleased] +### Added +- **One-command docker-compose quickstart.** [`examples/quickstart-compose`](examples/quickstart-compose) + brings up the daemon, a stand-in mock LLM, and a tiny looping agent with a single + `docker compose up` — so a newcomer watches the deterministic loop budget hard-stop + a runaway agent (HTTP 402, `loop_budget_exceeded`) with **no API key** and no local + Go/Python setup. The daemon pulls fresh on each run so a stale cached image can't + skew the demo; a short README shows how to swap the mock for a real provider. + ## [0.7.0] - 2026-06-14 Reach and scale. RiskKernel now plugs into the whole Python agent ecosystem — diff --git a/README.md b/README.md index 22f622c..01b16ab 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ It is **not** another gateway (LiteLLM/Portkey own routing), **not** another obs ## Quickstart (60 seconds) +> **No key, one command?** [`examples/quickstart-compose`](examples/quickstart-compose) +> is a `docker compose up` demo that hard-stops a runaway agent with no API key and +> no local setup — the fastest way to see the loop-killer. + Run the daemon with your key (nothing leaves your machine except calls to the provider you choose). Unconfigured, every run gets a safe default budget — $5 / 100 loops / 1 hour — so nothing is ever unbounded; here we set an explicit diff --git a/examples/quickstart-compose/README.md b/examples/quickstart-compose/README.md new file mode 100644 index 0000000..8ea15d8 --- /dev/null +++ b/examples/quickstart-compose/README.md @@ -0,0 +1,64 @@ +# Quickstart (docker compose) — watch RiskKernel kill a runaway agent + +The fastest way to see RiskKernel work: **one command**, no API key, no local Go or +Python. `docker compose up` brings up the governance daemon and a tiny agent that +loops forever — and you watch the deterministic loop budget hard-stop it. + +```bash +cd examples/quickstart-compose +docker compose up +``` + +You'll see the agent's calls go through, then get cut off: + +``` + call 1 -> 200 OK tokens=1600 cost=$... + call 2 -> 200 OK tokens=1600 cost=$... + call 3 -> 200 OK tokens=1600 cost=$... + call 4 -> 200 OK tokens=1600 cost=$... + call 5 -> 200 OK tokens=1600 cost=$... + + call 6 -> HTTP 402 RiskKernel HALTED the run: + {"code":"loop_budget_exceeded","message":"..."} + + The kill came from RiskKernel's deterministic loop budget — the + agent script never chose to stop. +``` + +`docker compose up` exits when the agent is halted. Tear it down with: + +```bash +docker compose down +``` + +## What's running + +Three small services (see [`docker-compose.yml`](docker-compose.yml)): + +| Service | Image | Role | +|---|---|---| +| `mock-llm` | `nginx:alpine` | A stand-in OpenAI-compatible upstream that returns one canned completion with token usage — so the demo needs **no real provider and no API key**. | +| `riskkernel` | `ghcr.io/prashar32/riskkernel:latest` | The governance daemon. A hard **loop budget of 5** (`RISKKERNEL_DEFAULT_LOOPS=5`); the other dimensions are unlimited so the loop budget is the sole, unambiguous enforcer. Forwards model calls to the mock. | +| `sample-agent` | `curlimages/curl` | A minimal "agent" — [`agent.sh`](agent.sh) just loops through the proxy under one run id and never stops on its own. RiskKernel stops it. | + +The agent changes nothing about how it calls the model — it's a plain +OpenAI-style `POST /v1/chat/completions`. The budget enforcement, the metering, and +the 402 kill all come from RiskKernel sitting in front. + +## Use it for real + +Swap the mock for a real provider and govern your own app — still one env var on +the app side: + +1. Give the daemon a real key and drop the mock override. In `docker-compose.yml`, + set `ANTHROPIC_API_KEY` (or `OPENAI_API_KEY`) on the `riskkernel` service and + remove the `RISKKERNEL_OPENAI_BASE_URL` line (and the `mock-llm` service). +2. Pick the budget you want, e.g. `RISKKERNEL_DEFAULT_DOLLARS=5` for a hard $5 + per-run ceiling alongside (or instead of) the loop cap. +3. Point your existing app at the proxy — one env var, no code change: + `OPENAI_BASE_URL=http://localhost:7070/v1` (or `ANTHROPIC_BASE_URL=http://localhost:7070`). + +Your keys stay in the daemon's environment, never in app config, and nothing is +sent anywhere except the provider call you already make. See the repo +[README](../../README.md) for budgets-as-config, crash-resume, approvals, and the +OpenTelemetry integration. diff --git a/examples/quickstart-compose/agent.sh b/examples/quickstart-compose/agent.sh new file mode 100755 index 0000000..db6d1d3 --- /dev/null +++ b/examples/quickstart-compose/agent.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# A deliberately runaway "agent": it just keeps calling the model through the +# RiskKernel proxy, sharing one run id, and never decides to stop on its own. The +# proxy meters each call and — once the run's loop budget is spent — refuses the +# next one with HTTP 402. The kill comes from RiskKernel, not from this script. +set -eu + +PROXY="${PROXY:-http://riskkernel:7070}" +RUN_ID="quickstart-$(date +%s)" # a fresh run each time so re-runs start clean + +echo "----------------------------------------------------------------" +echo " RiskKernel quickstart: a runaway agent vs a hard loop budget" +echo " loop budget = 5 model calls (no API key — a mock LLM stands in)" +echo "----------------------------------------------------------------" +echo "" + +i=1 +while [ "$i" -le 20 ]; do + code="$(curl -s -o /tmp/body -D /tmp/hdr -w '%{http_code}' \ + -X POST "$PROXY/v1/chat/completions" \ + -H 'Content-Type: application/json' \ + -H "X-RiskKernel-Run-Id: $RUN_ID" \ + -d '{"model":"gpt-4o","messages":[{"role":"user","content":"keep going"}]}')" + + if [ "$code" = "200" ]; then + toks="$(awk 'tolower($1)=="x-riskkernel-tokens:"{gsub(/\r/,"");print $2}' /tmp/hdr)" + cost="$(awk 'tolower($1)=="x-riskkernel-cost-usd:"{gsub(/\r/,"");print $2}' /tmp/hdr)" + echo " call $i -> 200 OK tokens=${toks:-?} cost=\$${cost:-?}" + else + echo "" + echo " call $i -> HTTP $code RiskKernel HALTED the run:" + sed 's/^/ /' /tmp/body + echo "" + echo "" + echo " The kill came from RiskKernel's deterministic loop budget — the" + echo " agent script never chose to stop. Point your own app at the proxy" + echo " and the same budget protects it. (See README: Use it for real.)" + exit 0 + fi + i=$((i + 1)) +done + +echo "ERROR: expected RiskKernel to halt the loop within 20 calls, but it did not." >&2 +exit 1 diff --git a/examples/quickstart-compose/docker-compose.yml b/examples/quickstart-compose/docker-compose.yml new file mode 100644 index 0000000..cddc344 --- /dev/null +++ b/examples/quickstart-compose/docker-compose.yml @@ -0,0 +1,49 @@ +name: riskkernel-quickstart + +# One command — `docker compose up` — to watch RiskKernel hard-stop a runaway agent, +# with NO API key and no local Go/Python setup. Three tiny services: +# mock-llm a stand-in OpenAI-compatible upstream (one canned reply) so the +# demo needs no real provider or key +# riskkernel the governance daemon: a hard loop budget, forwarding to the mock +# sample-agent a minimal "agent" that just loops through the proxy until it's killed +# +# To govern a REAL app instead, see the "Use it for real" section in README.md. + +services: + mock-llm: + image: nginx:alpine + volumes: + - ./mock-llm.conf:/etc/nginx/conf.d/default.conf:ro + + riskkernel: + image: ghcr.io/prashar32/riskkernel:latest + # Always pull, so a stale locally-cached :latest can't make the demo behave + # like an older RiskKernel (the quickstart should "just work" on any machine). + pull_policy: always + depends_on: + - mock-llm + ports: + - "7070:7070" + environment: + # The star of the demo: a runaway loop is killed after 5 model calls. + RISKKERNEL_DEFAULT_LOOPS: "5" + # Unlimited on every other dimension so the LOOP budget is the sole enforcer + # and the halt reason is unambiguous. + RISKKERNEL_DEFAULT_DOLLARS: "0" + RISKKERNEL_DEFAULT_TOKENS: "0" + RISKKERNEL_DEFAULT_SECONDS: "0" + # A dummy key activates the OpenAI provider; the call is intercepted and + # forwarded to the mock above — no real provider, no real key leaves anywhere. + OPENAI_API_KEY: "sk-quickstart-mock" + RISKKERNEL_OPENAI_BASE_URL: "http://mock-llm" + + sample-agent: + image: curlimages/curl:8.11.1 + depends_on: + riskkernel: + condition: service_healthy + volumes: + - ./agent.sh:/agent.sh:ro + entrypoint: ["sh", "/agent.sh"] + environment: + PROXY: "http://riskkernel:7070" diff --git a/examples/quickstart-compose/mock-llm.conf b/examples/quickstart-compose/mock-llm.conf new file mode 100644 index 0000000..83d4168 --- /dev/null +++ b/examples/quickstart-compose/mock-llm.conf @@ -0,0 +1,11 @@ +# A stand-in OpenAI-compatible upstream for the quickstart: it answers any request +# with one canned chat completion (including token usage), so the demo needs no real +# provider and no API key. RiskKernel forwards to this instead of a real model. +server { + listen 80; + + location / { + default_type application/json; + return 200 '{"id":"chatcmpl-mock","object":"chat.completion","created":0,"model":"gpt-4o","choices":[{"index":0,"message":{"role":"assistant","content":"Canned reply from the mock LLM — the quickstart needs no API key."},"finish_reason":"stop"}],"usage":{"prompt_tokens":1200,"completion_tokens":400,"total_tokens":1600}}'; + } +}