diff --git a/CHANGELOG.md b/CHANGELOG.md index 4612bb2..12c2722 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/examples/vercel-ai-sdk/.gitignore b/examples/vercel-ai-sdk/.gitignore new file mode 100644 index 0000000..87807d9 --- /dev/null +++ b/examples/vercel-ai-sdk/.gitignore @@ -0,0 +1,3 @@ +node_modules +package-lock.json +dist diff --git a/examples/vercel-ai-sdk/README.md b/examples/vercel-ai-sdk/README.md new file mode 100644 index 0000000..adf4bdf --- /dev/null +++ b/examples/vercel-ai-sdk/README.md @@ -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. diff --git a/examples/vercel-ai-sdk/index.ts b/examples/vercel-ai-sdk/index.ts new file mode 100644 index 0000000..4961d5e --- /dev/null +++ b/examples/vercel-ai-sdk/index.ts @@ -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 { + 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); +}); diff --git a/examples/vercel-ai-sdk/package.json b/examples/vercel-ai-sdk/package.json new file mode 100644 index 0000000..8b34c28 --- /dev/null +++ b/examples/vercel-ai-sdk/package.json @@ -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" + } +} diff --git a/examples/vercel-ai-sdk/tsconfig.json b/examples/vercel-ai-sdk/tsconfig.json new file mode 100644 index 0000000..0c34f82 --- /dev/null +++ b/examples/vercel-ai-sdk/tsconfig.json @@ -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"] +} diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index 192bbfd..926f266 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -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 @@ -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 diff --git a/sdks/typescript/package-lock.json b/sdks/typescript/package-lock.json index b086449..449cda5 100644 --- a/sdks/typescript/package-lock.json +++ b/sdks/typescript/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "Apache-2.0", "devDependencies": { + "@ai-sdk/provider": "^2.0.3", "@types/node": "^22", "tsup": "^8", "typescript": "^5", @@ -18,6 +19,19 @@ "node": ">=20" } }, + "node_modules/@ai-sdk/provider": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.3.tgz", + "integrity": "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1323,6 +1337,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 1f11eee..ee01aa5 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -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"], @@ -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" } diff --git a/sdks/typescript/src/vercel.ts b/sdks/typescript/src/vercel.ts new file mode 100644 index 0000000..2927577 --- /dev/null +++ b/sdks/typescript/src/vercel.ts @@ -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(); + }, + }; +} diff --git a/sdks/typescript/test/sdk.test.ts b/sdks/typescript/test/sdk.test.ts index f9071d6..a9f4d45 100644 --- a/sdks/typescript/test/sdk.test.ts +++ b/sdks/typescript/test/sdk.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createServer, type Server, type ServerResponse } from "node:http"; import { Runtime } from "../src/index"; +import { governMiddleware } from "../src/vercel"; // A tiny in-process mock of the daemon's /v1 API, so the SDK is exercised over // real HTTP with no daemon, no keys. @@ -166,3 +167,53 @@ describe("Runtime", () => { ).rejects.toMatchObject({ name: "APIError", status: 404 }); }); }); + +describe("governMiddleware (Vercel AI SDK)", () => { + it("ticks one governed step per model call and forwards the result", async () => { + reset(); + const rt = new Runtime({ baseUrl }); + await rt.governedRun({ budget: { loops: 5 } }, async (run) => { + const mw = governMiddleware(run); + // Call the middleware hooks the way wrapLanguageModel would (params/model + // are unused by the adapter; stub them). + const gen = await (mw.wrapGenerate as any)({ doGenerate: async () => "GENERATED" }); + expect(gen).toBe("GENERATED"); + const str = await (mw.wrapStream as any)({ doStream: async () => "STREAMED" }); + expect(str).toBe("STREAMED"); + }); + expect(state.steps).toBe(2); // two model calls == two steps + }); + + it("surfaces BudgetExceeded out of a generate call when the loop budget is spent", async () => { + reset(); + state.haltAfter = 1; // the daemon halts the 2nd step + const rt = new Runtime({ baseUrl }); + await expect( + rt.governedRun({ budget: { loops: 1 }, cancelOnError: false }, async (run) => { + const mw = governMiddleware(run); + const doGenerate = async () => "GENERATED"; + await (mw.wrapGenerate as any)({ doGenerate }); // 1st: ok + await (mw.wrapGenerate as any)({ doGenerate }); // 2nd: halted before the model runs + }), + ).rejects.toMatchObject({ name: "BudgetExceeded", reason: "dollar_budget_exceeded" }); + }); + + it("does not call the model when the step is rejected", async () => { + reset(); + state.haltAfter = 0; // halt immediately + const rt = new Runtime({ baseUrl }); + let modelCalled = false; + await rt + .governedRun({ cancelOnError: false }, async (run) => { + const mw = governMiddleware(run); + await (mw.wrapGenerate as any)({ + doGenerate: async () => { + modelCalled = true; + return "GENERATED"; + }, + }); + }) + .catch(() => {}); + expect(modelCalled).toBe(false); // step() throws first — the model never runs, so no spend + }); +}); diff --git a/sdks/typescript/tsup.config.ts b/sdks/typescript/tsup.config.ts index f8eb62b..1a57b9b 100644 --- a/sdks/typescript/tsup.config.ts +++ b/sdks/typescript/tsup.config.ts @@ -1,10 +1,10 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts"], + entry: ["src/index.ts", "src/vercel.ts"], format: ["esm", "cjs"], dts: true, clean: true, sourcemap: true, - target: "node18", + target: "node20", });