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 @@ -16,6 +16,13 @@ surface is governed by [`COMPATIBILITY.md`](COMPATIBILITY.md).
continue enforcing without re-spending. Mirrors the Python SDK's `resume_run`; read
`run.latestCheckpoint()` to resume from where the agent left off. See
[`docs/RESUME.md`](docs/RESUME.md) and [`sdks/typescript`](sdks/typescript).
- **TypeScript SDK: Vercel AI SDK adapter.** `governMiddleware(run)` (from
`@riskkernel/sdk/vercel`) is an AI SDK language-model middleware that ticks one
governed step per model call, so a run's loop/time budget is enforced and a halt
surfaces as `BudgetExceeded` out of `generateText` / `streamText` — the JS analog
of the Python LangChain / OpenAI-Agents adapters. `@ai-sdk/provider` is an optional
peer used at compile time only, so the core stays dependency-free. Pinned to AI SDK
v5; runnable example at [`examples/vercel-ai-sdk`](examples/vercel-ai-sdk).
- **Point a provider at a custom upstream.** Set `RISKKERNEL_OPENAI_BASE_URL` or
`RISKKERNEL_ANTHROPIC_BASE_URL` to route that provider through an OpenAI-compatible
gateway, a corporate proxy, or a local mock (e.g. for benchmarking) instead of its
Expand Down
3 changes: 3 additions & 0 deletions examples/vercel-ai-sdk/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
package-lock.json
dist
84 changes: 84 additions & 0 deletions examples/vercel-ai-sdk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# vercel-ai-sdk — stop a runaway Vercel AI SDK agent

Wrap a [Vercel AI SDK](https://ai-sdk.dev) model with RiskKernel's middleware and
the **deterministic governor caps the loop** — one governed step per model call,
hard-stopped at the loop budget. The halt propagates out of `generateText()` and
ends the loop. The kill comes from RiskKernel, not from the script.

**No API key, no model call.** This uses a tiny fake model so the loop enforcement
runs with nothing but `riskkernel serve`. (Add a real model — and the dollar/token
ceiling — by pointing a provider at the governing proxy; 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 and run it
cd examples/vercel-ai-sdk
npm install
npm start
```

## What you'll see

```
▶ vercel-ai-sdk loop budget = 6 (enforced by the Go governor)
run id: 31ab03d4-e7e4-47ad-bb74-6402999ca4fd

step 1 │ model call allowed by the governor
step 2 │ model call allowed by the governor
step 3 │ model call allowed by the governor
step 4 │ model call allowed by the governor
step 5 │ model call allowed by the governor
step 6 │ model call allowed by the governor

🛑 RiskKernel halted the agent — reason: loop_budget_exceeded
the loop never ran past its budget; the kill was deterministic.
```

## How it works

```ts
import { generateText, wrapLanguageModel } from "ai";
import { Runtime } from "@riskkernel/sdk";
import { governMiddleware } from "@riskkernel/sdk/vercel";

await rt.governedRun({ budget: { loops: 6 } }, async (run) => {
const model = wrapLanguageModel({ model: yourModel, middleware: governMiddleware(run) });
// every generateText/streamText on `model` now ticks one governed step
await generateText({ model, prompt }); // throws BudgetExceeded when the budget is spent
});
```

`governMiddleware(run)` is an AI SDK
[language-model middleware](https://ai-sdk.dev/docs/ai-sdk-core/middleware): its
`wrapGenerate` / `wrapStream` hooks call `run.step()` before each model call, so the
governor's loop and time budgets are enforced and a halt surfaces as
`BudgetExceeded` instead of being swallowed. The daemon decides; the adapter carries
no governance logic.

## Add the dollar / token ceiling (real model)

The middleware enforces the *loop* and *time* budgets — the outer-loop count the
provider can't see. For the *dollar* and *token* budgets, route the model through
the governing proxy so every call is metered and priced. One config change:

```ts
import { createOpenAI } from "@ai-sdk/openai";

const { baseUrl, headers } = run.proxyConfig();
const openai = createOpenAI({ baseURL: baseUrl, headers }); // BYO key at the daemon
const model = wrapLanguageModel({
model: openai("gpt-4o-mini"),
middleware: governMiddleware(run), // loop/time
});
// dollars/tokens metered by the proxy · loops/time by the middleware
```

## Supported versions

Pinned and tested against **Vercel AI SDK v5** (`ai@^5`, `@ai-sdk/provider@^2`). The
adapter ships in the SDK as `@riskkernel/sdk/vercel`; `@ai-sdk/provider` is an
optional peer, needed only if you import the adapter.
81 changes: 81 additions & 0 deletions examples/vercel-ai-sdk/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* vercel-ai-sdk — stop a runaway Vercel AI SDK agent.
*
* Wrap a model with RiskKernel's middleware and the deterministic governor caps
* the loop: one governed step per model call, hard-stopped at the loop budget.
* The halt propagates out of `generateText()` and ends the loop — the kill comes
* from RiskKernel, not from this script.
*
* No API key, no model call: this uses a tiny fake model so the loop enforcement
* runs with nothing but `riskkernel serve`. Add a real model — and the dollar/token
* ceiling — by pointing a provider at the governing proxy (see the README).
*/
import { generateText, wrapLanguageModel } from "ai";
import type { LanguageModelV2 } from "@ai-sdk/provider";
import { Runtime, BudgetExceeded } from "@riskkernel/sdk";
import { governMiddleware } from "@riskkernel/sdk/vercel";

// A key-free fake model (the AI SDK analog of LangChain's FakeListLLM). Only
// doGenerate is exercised here; doStream is present to satisfy the interface.
const fakeModel: LanguageModelV2 = {
specificationVersion: "v2",
provider: "fake",
modelId: "fake-echo",
supportedUrls: {},
async doGenerate() {
return {
content: [{ type: "text", text: "ok" }],
finishReason: "stop",
usage: { inputTokens: 5, outputTokens: 1, totalTokens: 6 },
warnings: [],
};
},
async doStream() {
throw new Error("streaming is not used in this demo");
},
};

const LOOP_BUDGET = 6;

// BudgetExceeded is thrown inside the middleware; surface it even if a wrapper
// (e.g. the AI SDK) nests it under `.cause`.
function asBudgetExceeded(err: unknown): BudgetExceeded | null {
let e: unknown = err;
for (let i = 0; i < 5 && e; i++) {
if (e instanceof BudgetExceeded) return e;
e = (e as { cause?: unknown }).cause;
}
return null;
}

async function main(): Promise<void> {
const rt = new Runtime({ baseUrl: process.env.RISKKERNEL_BASE_URL ?? "http://localhost:7070" });

await rt.governedRun(
{ name: "vercel-ai-sdk", budget: { loops: LOOP_BUDGET }, cancelOnError: false },
async (run) => {
const model = wrapLanguageModel({ model: fakeModel, middleware: governMiddleware(run) });

console.log(`▶ vercel-ai-sdk loop budget = ${LOOP_BUDGET} (enforced by the Go governor)`);
console.log(` run id: ${run.id}\n`);

try {
for (let i = 1; ; i++) {
// Each call ticks one governed step; the governor halts the (LOOP_BUDGET+1)th.
await generateText({ model, prompt: "say ok", maxRetries: 0 });
console.log(` step ${String(i).padStart(2)} │ model call allowed by the governor`);
}
} catch (err) {
const halt = asBudgetExceeded(err);
if (!halt) throw err;
console.log(`\n🛑 RiskKernel halted the agent — reason: ${halt.reason}`);
console.log(" the loop never ran past its budget; the kill was deterministic.");
}
},
);
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
20 changes: 20 additions & 0 deletions examples/vercel-ai-sdk/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "riskkernel-example-vercel-ai-sdk",
"private": true,
"type": "module",
"description": "Govern a Vercel AI SDK agent with RiskKernel — a loop budget hard-stops it.",
"scripts": {
"start": "tsx index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@riskkernel/sdk": "file:../../sdks/typescript",
"ai": "^5.0.0"
},
"devDependencies": {
"@ai-sdk/provider": "^2.0.0",
"@types/node": "^22",
"tsx": "^4",
"typescript": "^5"
}
}
14 changes: 14 additions & 0 deletions examples/vercel-ai-sdk/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["index.ts"]
}
30 changes: 28 additions & 2 deletions sdks/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ governed runs ergonomic from Node/TypeScript. **No runtime dependencies** — it
the global `fetch` (Node 20+), the same stdlib-only ethos as the Python SDK.

> **Status:** core client — run control, budgets, crash-resume (`resumeRun`), the
> governing proxy, and approval gates. Framework adapters (Vercel AI SDK) and npm
> publishing are tracked in the repo issues (**#81–#82**) — contributions welcome.
> governing proxy, approval gates, and the Vercel AI SDK adapter. npm publishing is
> tracked in the repo issues (**#82**) — contributions welcome.

> **No runtime dependencies:** the core client uses only the global `fetch`. The
> Vercel adapter (`@riskkernel/sdk/vercel`) takes `@ai-sdk/provider` as an *optional*
> peer, used at compile time only — importing the core never pulls it in.

## Use

Expand Down Expand Up @@ -74,6 +78,28 @@ The `/v1` contract is [`api/v1/openapi.yaml`](../../api/v1/openapi.yaml); the
governance principle is the same as every surface — **the LLM proposes, the
deterministic Go core disposes.**

## Vercel AI SDK adapter

Govern a [Vercel AI SDK](https://ai-sdk.dev) agent with ~no code change: wrap any
model with `governMiddleware(run)` and every `generateText` / `streamText` ticks one
governed step, so the loop/time budget is enforced and a halt surfaces as
`BudgetExceeded` (not swallowed).

```ts
import { generateText, wrapLanguageModel } from "ai";
import { governMiddleware } from "@riskkernel/sdk/vercel";

await rt.governedRun({ budget: { loops: 20, dollars: 1 } }, async (run) => {
const { baseUrl, headers } = run.proxyConfig();
const openai = createOpenAI({ baseURL: baseUrl, headers }); // cost metered by the proxy
const model = wrapLanguageModel({ model: openai("gpt-4o-mini"), middleware: governMiddleware(run) });
await generateText({ model, prompt }); // loops/time enforced by the middleware
});
```

Pinned and tested against AI SDK v5 (`ai@^5` / `@ai-sdk/provider@^2`, an optional
peer). Runnable example: [`examples/vercel-ai-sdk`](../../examples/vercel-ai-sdk).

## Develop

```bash
Expand Down
21 changes: 21 additions & 0 deletions sdks/typescript/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions sdks/typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./vercel": {
"types": "./dist/vercel.d.ts",
"import": "./dist/vercel.js",
"require": "./dist/vercel.cjs"
}
},
"files": ["dist"],
Expand All @@ -30,11 +35,18 @@
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@ai-sdk/provider": "^2.0.3",
"@types/node": "^22",
"tsup": "^8",
"typescript": "^5",
"vitest": "^3.2.6"
},
"peerDependencies": {
"@ai-sdk/provider": ">=2"
},
"peerDependenciesMeta": {
"@ai-sdk/provider": { "optional": true }
},
"overrides": {
"esbuild": "^0.25.0"
}
Expand Down
58 changes: 58 additions & 0 deletions sdks/typescript/src/vercel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Vercel AI SDK adapter — govern a Node agent with ~no code change.
*
* The JS analog of the Python LangChain / OpenAI-Agents adapters: a
* [language-model middleware](https://ai-sdk.dev/docs/ai-sdk-core/middleware)
* that ticks one governed step per model call, so a run's loop/time budget is
* enforced and a halt surfaces as {@link BudgetExceeded} — propagating out of
* `generateText` / `streamText` instead of being swallowed. The daemon decides;
* this adapter carries no governance logic.
*
* Pair it with the governing proxy for token/cost metering the middleware can't
* see — point your provider's `baseURL` at `run.proxyConfig()`:
*
* ```ts
* import { createOpenAI } from "@ai-sdk/openai";
* import { generateText, wrapLanguageModel } from "ai";
* import { Runtime } from "@riskkernel/sdk";
* import { governMiddleware } from "@riskkernel/sdk/vercel";
*
* const rt = new Runtime();
* await rt.governedRun({ name: "research", budget: { loops: 20, dollars: 1 } }, async (run) => {
* const { baseUrl, headers } = run.proxyConfig();
* const openai = createOpenAI({ baseURL: baseUrl, headers }); // cost metered by the proxy
* const model = wrapLanguageModel({ model: openai("gpt-4o-mini"), middleware: governMiddleware(run) });
* for (;;) {
* const { text } = await generateText({ model, prompt }); // throws BudgetExceeded when budget runs out
* // ... your agent's work ...
* }
* });
* ```
*
* Tested against `@ai-sdk/provider` v2 (Vercel AI SDK v5). `@ai-sdk/provider` is
* an optional peer — only needed if you import this adapter. The type is used at
* compile time only (`import type`), so the built adapter has no runtime deps.
*/
import type { LanguageModelV2Middleware } from "@ai-sdk/provider";
import type { Run } from "./runtime";

/**
* Build an AI SDK middleware that binds model calls to a governed run. Each
* `generateText` / `streamText` ticks one {@link Run.step} (loop/time budget);
* when the budget is spent the daemon halts the run and the call throws
* {@link BudgetExceeded}. Wrap any model with it via `wrapLanguageModel`.
*/
export function governMiddleware(run: Run): LanguageModelV2Middleware {
return {
middlewareVersion: "v2",
// One model call == one governed step (mirrors the Python on_llm_start hook).
async wrapGenerate({ doGenerate }) {
await run.step();
return doGenerate();
},
async wrapStream({ doStream }) {
await run.step();
return doStream();
},
};
}
Loading
Loading