diff --git a/README.md b/README.md index 0cb3e0c..32408fa 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,9 @@ Brand new to the SDK? [`examples/wrap-your-agent`](examples/wrap-your-agent) is no-key, two-minute version — a generic Python loop the governor caps at a loop budget, the deterministic kill with nothing running but the daemon. +On LangChain? [`examples/langchain`](examples/langchain) wraps a LangChain loop +with the callback handler and caps it at a loop budget — also key-free. + ## 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/langchain/README.md b/examples/langchain/README.md new file mode 100644 index 0000000..a47c007 --- /dev/null +++ b/examples/langchain/README.md @@ -0,0 +1,105 @@ +# langchain — stop a runaway LangChain agent + +Wrap a LangChain LLM loop with RiskKernel's callback handler and the +**deterministic governor caps it** — one governed step per model call, hard-stopped +at the loop budget. The halt propagates out of `llm.invoke()` and ends the chain. +The kill comes from RiskKernel, not from the script. + +**No API key, no model call.** This uses LangChain's `FakeListLLM` so the loop +enforcement runs with nothing but `riskkernel serve`. (Add a real model — and the +dollar/token ceiling — with a few lines; see [below](#add-the-dollar--token-ceiling-real-model).) + +## Run it in 60 seconds + +```bash +# 1. start the daemon — no key needed for this demo +docker run --rm -p 7070:7070 ghcr.io/prashar32/riskkernel:latest + +# 2. in another terminal, install langchain-core + the SDK, and run it +cd examples/langchain +pip install -r requirements.txt +python agent.py +``` + +## What you'll see + +A real run (the run-id varies; the structure is the point): + +``` +▶ langchain loop budget = 6 (enforced by the Go governor) + run id: 31ab03d4-e7e4-47ad-bb74-6402999ca4fd + + step 1 │ LLM call allowed by the governor + step 2 │ LLM call allowed by the governor + step 3 │ LLM call allowed by the governor + step 4 │ LLM call allowed by the governor + step 5 │ LLM call allowed by the governor + step 6 │ LLM call allowed by the governor + +🛑 RiskKernel halted the LangChain run — reason: loop_budget_exceeded + ── final ledger (enforced by the governor) ── + LLM calls (loops) : 6 (budget: 6) + run id : 31ab03d4-e7e4-47ad-bb74-6402999ca4fd + The agent would have looped forever; the governor capped it — and the + halt propagated out of llm.invoke(), ending the chain. +``` + +The 7th `llm.invoke()` is refused: the handler's `on_llm_start` ticks a governed +step, the daemon returns HTTP `402 loop_budget_exceeded`, and that surfaces as +`rk.BudgetExceeded` — which propagates out of LangChain and stops the loop. + +## Wrapping your own LangChain agent + +Two objects: the handler, and passing it as a callback. + +```python +import riskkernel as rk +from riskkernel.adapters.langchain import RiskKernelCallbackHandler + +rt = rk.Runtime() # http://localhost:7070 +with rt.governed_run(budget=rt.budget(loops=50, seconds=600)) as run: + handler = RiskKernelCallbackHandler(run) # one governed step per LLM call + while not done: + llm.invoke(prompt, config={"callbacks": [handler]}) # raises rk.BudgetExceeded at the cap + ... # your existing chain / tools +``` + +Works the same with `AgentExecutor`, `Runnable` chains, or LangGraph — anywhere you +can pass `callbacks`. The handler also gates tools on human approval when you +construct it with `RiskKernelCallbackHandler(run, gate_tools=True)`. + +> The handler sets `raise_error = True` so the budget halt actually propagates — +> LangChain otherwise swallows exceptions raised inside a callback and the chain +> would keep spending past budget. + +## Add the dollar / token ceiling (real model) + +The handler caps **loops and time**. To also cap **dollars and tokens**, route the +model through the run's proxy so every call is priced from real provider usage and +the dollar budget halts the run: + +```python +from langchain_openai import ChatOpenAI # speaks the OpenAI API to the proxy + +cfg = run.proxy_config() +llm = ChatOpenAI( + base_url=cfg["base_url"], # http://localhost:7070/v1 + default_headers=cfg["headers"], # groups calls into this governed run + api_key="sk-unused", # the real provider key lives in the daemon + model="claude-sonnet-4-5", +) +# budget=rt.budget(loops=50, dollars=1.00, seconds=600) +# the dollar ceiling trips at the proxy with HTTP 402; the loop/time ceiling +# trips in the handler. Start the daemon with your ANTHROPIC_API_KEY. +``` + +For the real-model dollar kill end to end — the cost ledger climbing each step +until the governor stops it — see [`examples/codebase-qa`](../codebase-qa). + +## Tuning for a recording + +- `LOOP_BUDGET` (default `6`) — lower it for a faster kill, raise it for more + steps before the halt. + +Nothing about the kill is faked: it's the daemon returning HTTP `402` before the +over-budget call, surfaced through LangChain's own callback path. diff --git a/examples/langchain/agent.py b/examples/langchain/agent.py new file mode 100644 index 0000000..395c954 --- /dev/null +++ b/examples/langchain/agent.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""langchain — stop a runaway LangChain agent at its budget. + +Wraps a LangChain LLM loop with RiskKernel's callback handler: one governed step +per model call. A deliberately runaway loop is hard-stopped by the deterministic +governor at its loop budget — and the halt propagates out of ``llm.invoke()``, +ending the chain. The kill comes from RiskKernel, not from this script. + +Key-free: this uses LangChain's ``FakeListLLM`` so the loop enforcement runs with +nothing but ``riskkernel serve`` — no model call, no spend. To add the dollar / +token ceiling with a *real* model, route the LLM through the run's proxy; the +README shows the few extra lines. + +Run it: + riskkernel serve # in another terminal — no key needed for this demo + pip install -r requirements.txt + python agent.py + +The integration is two objects: ``RiskKernelCallbackHandler(run)`` and passing it +as a LangChain callback. The handler ticks one governed step per LLM call, so the +governor caps the loop the same way it would cap a real agent. +""" + +from __future__ import annotations + +import logging +import os + +import riskkernel as rk +from riskkernel.adapters.langchain import RiskKernelCallbackHandler + +# LangChain logs any exception raised inside a callback as an "error" — even the +# BudgetExceeded we raise on purpose to stop the run. Quiet just that logger so the +# demo output stays clean; the halt is intentional and handled below. +logging.getLogger("langchain_core.callbacks.manager").setLevel(logging.CRITICAL) + +try: + from langchain_core.language_models.fake import FakeListLLM +except ImportError: + raise SystemExit( + "this example needs langchain-core — install it with:\n" + " pip install -r requirements.txt" + ) + +# ───────────────────────────────────────────────────────────────────────────── +# Knobs. The kill is never faked: the governor (in the daemon) enforces the loop +# budget before each LLM call and the handler propagates the halt into LangChain. +# ───────────────────────────────────────────────────────────────────────────── +DAEMON_URL = os.environ.get("RISKKERNEL_BASE_URL", "http://localhost:7070") +LOOP_BUDGET = 6 # max LLM calls (loop iterations) the governor allows + + +def main() -> int: + rt = rk.Runtime(base_url=DAEMON_URL) + print(f"▶ langchain loop budget = {LOOP_BUDGET} (enforced by the Go governor)") + + # A stand-in model so the demo needs no key. Swap FakeListLLM for + # ChatAnthropic / ChatOpenAI pointed at run.proxy_config() to add the + # dollar/token ceiling on real calls — see the README. + llm = FakeListLLM(responses=["…thinking; I'll keep going…"] * 1000) + + try: + with rt.governed_run(name="langchain-demo", + budget=rt.budget(loops=LOOP_BUDGET)) as run: + print(f" run id: {run.id}\n") + handler = RiskKernelCallbackHandler(run) + step = 0 + while True: # a deliberately runaway agent loop + try: + llm.invoke(f"step {step + 1}: decide the next action", + config={"callbacks": [handler]}) + except rk.BudgetExceeded as halt: + _report_halt(run, halt) + return 0 + step += 1 + print(f" step {step:>2} │ LLM call allowed by the governor") + except rk.APIError as e: + if e.code == "connection_error": + print(f"\n✗ can't reach the daemon at {DAEMON_URL} — start it first:") + print(" riskkernel serve") + print(" # or: docker run --rm -p 7070:7070 ghcr.io/prashar32/riskkernel:latest") + return 1 + raise + + +def _report_halt(run: rk.Run, halt: rk.BudgetExceeded) -> None: + """Print the final, governor-enforced ledger for the halted LangChain run.""" + usage = run.status().get("usage", {}) + print(f"\n🛑 RiskKernel halted the LangChain run — reason: {halt.reason}") + print(" ── final ledger (enforced by the governor) ──") + print(f" LLM calls (loops) : {usage.get('loops', 0):>4} (budget: {LOOP_BUDGET})") + print(f" run id : {run.id}") + print(" The agent would have looped forever; the governor capped it — and the") + print(" halt propagated out of llm.invoke(), ending the chain.") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/langchain/requirements.txt b/examples/langchain/requirements.txt new file mode 100644 index 0000000..6347a25 --- /dev/null +++ b/examples/langchain/requirements.txt @@ -0,0 +1,12 @@ +# This LangChain example needs two things: langchain-core (the callback machinery +# the adapter hooks into) and the RiskKernel Python SDK. The SDK isn't on PyPI yet, +# so it's installed from source; its core is stdlib-only. The git+https reference +# works from ANY directory (a bare relative path would resolve against your CWD). +# +# Tested with langchain-core 1.4.0; the adapter also supports the older +# langchain.callbacks.base import path. +langchain-core>=0.2 +riskkernel @ git+https://github.com/prashar32/riskkernel.git#subdirectory=sdks/python + +# Working inside a clone and want your local SDK instead? From THIS directory: +# pip install ../../sdks/python