From 018a583a00d6adb5d02eeb61dbdc305b52a63355 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 25 Jun 2026 17:41:49 +0000 Subject: [PATCH] =?UTF-8?q?feat(agentswarm):=20M1+M3=20=E2=80=94=20embedda?= =?UTF-8?q?ble=20swarm=20runtime=20wrapping=20deepagents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @logicsrc/agentswarm: a self-hosted, framework-agnostic multi-agent runtime that each app mounts on its own route (e.g. tronbrowser.dev/swarm). M1 — core: - createSwarmHandler(): Web (Request)=>Response handler, CORS + validation - SwarmRunner: injectable engine interface - createDeepAgentRunner(): deepagents (createDeepAgent) adapter; deepagents + @langchain/langgraph are optional peers loaded via dynamic import so the core builds/tests with zero heavy deps - SwarmError + onRequest gate: seam for x402 metering / auth (402/403) M3 — rubric self-check (deepagents RubricMiddleware is Python-only, so ported at the runner layer): - createRubricRunner(): grades output via an injectable judge and revises until it passes or maxIterations; passes through untouched when no rubric - createLLMJudge(): cheap-model grader (lazy langchain initChatModel) 13/13 vitest pass; tsc clean; examples/server.mjs demo verified. Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 24 ++++ package.json | 2 +- packages/agentswarm/README.md | 97 ++++++++++++++++ packages/agentswarm/examples/server.mjs | 34 ++++++ packages/agentswarm/package.json | 25 ++++ packages/agentswarm/src/handler.ts | 81 +++++++++++++ packages/agentswarm/src/index.test.ts | 118 +++++++++++++++++++ packages/agentswarm/src/index.ts | 19 +++ packages/agentswarm/src/rubric.test.ts | 107 +++++++++++++++++ packages/agentswarm/src/rubric.ts | 146 ++++++++++++++++++++++++ packages/agentswarm/src/runner.ts | 87 ++++++++++++++ packages/agentswarm/src/types.ts | 58 ++++++++++ packages/agentswarm/tsconfig.json | 8 ++ 13 files changed, 805 insertions(+), 1 deletion(-) create mode 100644 packages/agentswarm/README.md create mode 100644 packages/agentswarm/examples/server.mjs create mode 100644 packages/agentswarm/package.json create mode 100644 packages/agentswarm/src/handler.ts create mode 100644 packages/agentswarm/src/index.test.ts create mode 100644 packages/agentswarm/src/index.ts create mode 100644 packages/agentswarm/src/rubric.test.ts create mode 100644 packages/agentswarm/src/rubric.ts create mode 100644 packages/agentswarm/src/runner.ts create mode 100644 packages/agentswarm/src/types.ts create mode 100644 packages/agentswarm/tsconfig.json diff --git a/package-lock.json b/package-lock.json index 976f600..c04d8d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1388,6 +1388,10 @@ "resolved": "packages/agentstack", "link": true }, + "node_modules/@logicsrc/agentswarm": { + "resolved": "packages/agentswarm", + "link": true + }, "node_modules/@logicsrc/ans": { "resolved": "packages/ans", "link": true @@ -6038,6 +6042,26 @@ "vitest": "^4.0.8" } }, + "packages/agentswarm": { + "name": "@logicsrc/agentswarm", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "vitest": "^4.0.8" + }, + "peerDependencies": { + "@langchain/langgraph": "*", + "deepagents": "^1.10.0" + }, + "peerDependenciesMeta": { + "@langchain/langgraph": { + "optional": true + }, + "deepagents": { + "optional": true + } + } + }, "packages/ans": { "name": "@logicsrc/ans", "version": "0.1.0", diff --git a/package.json b/package.json index d9d71c4..2781603 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "apps/*" ], "scripts": { - "build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/ans run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/plugin-agentmail run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build", + "build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/ans run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/agentswarm run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/plugin-agentmail run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build", "start": "npm --workspace @logicsrc/web run start", "test": "npm run test --workspaces --if-present", "check": "npm run build && npm run test", diff --git a/packages/agentswarm/README.md b/packages/agentswarm/README.md new file mode 100644 index 0000000..dd49ed3 --- /dev/null +++ b/packages/agentswarm/README.md @@ -0,0 +1,97 @@ +# @logicsrc/agentswarm + +Embeddable multi-agent **swarm** runtime for LogicSRC, wrapping +[`deepagents`](https://www.npmjs.com/package/deepagents). It ships a +framework-agnostic Web handler that each host app **mounts on its own route** +(e.g. `tronbrowser.dev/swarm`) — the host owns hosting, model keys, storage, and +billing; agentswarm turns an HTTP request into an agent turn. + +MIT licensed. Open-source package + self-hosted by each consumer. + +## Install + +```sh +npm i @logicsrc/agentswarm +# the real runner needs these optional peers in the host app: +npm i deepagents @langchain/langgraph @langchain/anthropic +``` + +## Mount it + +The core is a `(Request) => Response` handler, so it drops into any modern stack. + +```ts +import { createSwarmHandler, createDeepAgentRunner } from "@logicsrc/agentswarm"; + +const runner = await createDeepAgentRunner({ model: "anthropic:claude-sonnet-4-6" }); + +// Next.js route handler — app/swarm/route.ts +export const POST = createSwarmHandler({ runner }); +``` + +```ts +// Hono / any Web-standard server +app.post("/swarm", (c) => createSwarmHandler({ runner })(c.req.raw)); +``` + +Request body: `{ "messages": [{ "role": "user", "content": "…" }], "rubric"?: string, "threadId"?: string }`. + +## Meter / gate each call (x402, auth) + +`onRequest` runs before the agent. Throw a `SwarmError` with a status to reject — +this is where a host enforces an x402 payment or a scoped agent token. + +```ts +import { SwarmError } from "@logicsrc/agentswarm"; + +createSwarmHandler({ + runner, + onRequest: async (input, request) => { + if (!(await paid(request))) throw new SwarmError("payment required", 402); + } +}); +``` + +## Bring your own runner + +`SwarmRunner` is a one-method interface, so you can stub it in tests or back it +with something other than deepagents: + +```ts +const runner = { async run(input) { /* … */ } }; +``` + +## Demo + +```sh +npm run build && npm run demo +curl -s localhost:8787/swarm -d '{"messages":[{"role":"user","content":"hi"}]}' +``` + +## Self-checking with a rubric + +Wrap any runner so the agent grades its own answer against "done" criteria and +revises until it passes (deepagents' RubricMiddleware, reimplemented at the +runner layer since it is Python-only in the JS package today): + +```ts +import { createRubricRunner, createLLMJudge, createDeepAgentRunner } from "@logicsrc/agentswarm"; + +const runner = createRubricRunner({ + runner: await createDeepAgentRunner({ model: "anthropic:claude-sonnet-4-6" }), + judge: await createLLMJudge({ model: "anthropic:claude-haiku-4-5" }), // cheap grader + maxIterations: 3, + onEvaluation: (e) => console.log(`iteration ${e.iteration}: ${e.passed ? "pass" : "fail"} — ${e.explanation}`) +}); + +// then: createSwarmHandler({ runner }) +``` + +Pass the rubric per request: `{ "messages": [...], "rubric": "- one sentence\n- mentions chlorophyll" }`. + +## Status + +- **M1** ✓ core handler + injectable runner + deepagents adapter + x402 gate hook. +- **M3** ✓ rubric self-check loop (`createRubricRunner` / `createLLMJudge`). +- Next: peer coordination (the swarm), Turso checkpointer, agentgit identity + + budget/ledger adapters, x402 billing middleware, and the tronbrowser mount. diff --git a/packages/agentswarm/examples/server.mjs b/packages/agentswarm/examples/server.mjs new file mode 100644 index 0000000..e63092a --- /dev/null +++ b/packages/agentswarm/examples/server.mjs @@ -0,0 +1,34 @@ +// Minimal self-host demo: proves the embeddable handler mounts on a plain +// Node server at /swarm. Uses a mock runner so it runs with zero deps and no +// API key — swap `runner` for `await createDeepAgentRunner({ model })` for real. +// +// npm run build && npm run demo +// curl -s localhost:8787/swarm -d '{"messages":[{"role":"user","content":"hi"}]}' +import { createServer } from "node:http"; +import { createSwarmHandler } from "../dist/index.js"; + +const runner = { + async run({ messages, threadId }) { + const last = messages[messages.length - 1]?.content ?? ""; + return { + threadId: threadId ?? `thread_${Date.now()}`, + messages: [...messages, { role: "assistant", content: `echo: ${last}` }], + output: `echo: ${last}` + }; + } +}; + +const handle = createSwarmHandler({ runner }); + +createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const request = new Request(`http://localhost${req.url}`, { + method: req.method, + headers: req.headers, + body: chunks.length ? Buffer.concat(chunks) : undefined + }); + const response = await handle(request); + res.writeHead(response.status, Object.fromEntries(response.headers)); + res.end(Buffer.from(await response.arrayBuffer())); +}).listen(8787, () => console.log("agentswarm demo on http://localhost:8787/swarm")); diff --git a/packages/agentswarm/package.json b/packages/agentswarm/package.json new file mode 100644 index 0000000..2b09438 --- /dev/null +++ b/packages/agentswarm/package.json @@ -0,0 +1,25 @@ +{ + "name": "@logicsrc/agentswarm", + "version": "0.1.0", + "description": "AgentSwarm: embeddable multi-agent swarm runtime for LogicSRC, wrapping deepagents. Mount it on your own route (e.g. /swarm).", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "license": "MIT", + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run src --passWithNoTests", + "demo": "node examples/server.mjs" + }, + "peerDependencies": { + "@langchain/langgraph": "*", + "deepagents": "^1.10.0" + }, + "peerDependenciesMeta": { + "@langchain/langgraph": { "optional": true }, + "deepagents": { "optional": true } + }, + "devDependencies": { + "vitest": "^4.0.8" + } +} diff --git a/packages/agentswarm/src/handler.ts b/packages/agentswarm/src/handler.ts new file mode 100644 index 0000000..0be5530 --- /dev/null +++ b/packages/agentswarm/src/handler.ts @@ -0,0 +1,81 @@ +import type { SwarmRunInput, SwarmRunner } from "./types.js"; + +const CORS_HEADERS: Record = { + "access-control-allow-origin": "*", + "access-control-allow-methods": "POST, OPTIONS", + "access-control-allow-headers": "content-type, authorization" +}; + +export interface SwarmHandlerOptions { + /** Engine that runs an agent turn (e.g. the deepagents-backed runner). */ + runner: SwarmRunner; + /** + * Optional per-request gate, run before the agent. Use it to authenticate, + * authorize, or meter a call (e.g. enforce an x402 payment). Throw to reject; + * attach a numeric `status` to the error (see {@link SwarmError}) to control + * the HTTP status returned. + */ + onRequest?: (input: SwarmRunInput, request: Request) => void | Promise; +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...CORS_HEADERS } + }); +} + +function statusOf(error: unknown, fallback: number): number { + const status = (error as { status?: unknown })?.status; + return typeof status === "number" ? status : fallback; +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Build a framework-agnostic Web handler `(Request) => Response` that runs a + * swarm turn. Mount it on any route in the host app — for example a Next route + * handler (`export const POST = createSwarmHandler({ runner })`), a Hono route, + * or `tronbrowser.dev/swarm`. The host owns hosting, model keys, storage, and + * billing; this just turns an HTTP request into a {@link SwarmRunner} call. + */ +export function createSwarmHandler(options: SwarmHandlerOptions): (request: Request) => Promise { + const { runner, onRequest } = options; + + return async function handle(request: Request): Promise { + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + if (request.method !== "POST") { + return json({ error: "method_not_allowed" }, 405); + } + + let input: SwarmRunInput; + try { + const body = (await request.json()) as Partial; + if (!Array.isArray(body.messages) || body.messages.length === 0) { + return json({ error: "messages must be a non-empty array" }, 400); + } + input = { messages: body.messages, rubric: body.rubric, threadId: body.threadId }; + } catch { + return json({ error: "invalid_json" }, 400); + } + + if (onRequest) { + try { + await onRequest(input, request); + } catch (error) { + return json({ error: messageOf(error) }, statusOf(error, 403)); + } + } + + try { + const result = await runner.run(input); + return json(result, 200); + } catch (error) { + return json({ error: messageOf(error) }, statusOf(error, 500)); + } + }; +} diff --git a/packages/agentswarm/src/index.test.ts b/packages/agentswarm/src/index.test.ts new file mode 100644 index 0000000..e7c2d55 --- /dev/null +++ b/packages/agentswarm/src/index.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from "vitest"; +import { SwarmError, createSwarmHandler } from "./index.js"; +import type { SwarmRunInput, SwarmRunResult, SwarmRunner } from "./index.js"; + +function mockRunner(impl?: (input: SwarmRunInput) => SwarmRunResult): SwarmRunner { + return { + run: vi.fn(async (input: SwarmRunInput): Promise => + impl + ? impl(input) + : { + threadId: input.threadId ?? "thread_test", + messages: [...input.messages, { role: "assistant", content: "ok" }], + output: "ok" + } + ) + }; +} + +function post(body: unknown): Request { + return new Request("http://localhost/swarm", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body) + }); +} + +describe("createSwarmHandler", () => { + it("runs a turn and returns the result", async () => { + const handle = createSwarmHandler({ runner: mockRunner() }); + const res = await handle(post({ messages: [{ role: "user", content: "hi" }] })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.output).toBe("ok"); + expect(body.threadId).toBe("thread_test"); + }); + + it("passes rubric and threadId through to the runner", async () => { + const runner = mockRunner((input) => ({ + threadId: input.threadId!, + messages: input.messages, + output: input.rubric ?? "" + })); + const handle = createSwarmHandler({ runner }); + const res = await handle( + post({ messages: [{ role: "user", content: "hi" }], rubric: "be terse", threadId: "t1" }) + ); + const body = await res.json(); + expect(body.threadId).toBe("t1"); + expect(body.output).toBe("be terse"); + }); + + it("rejects non-POST with 405", async () => { + const handle = createSwarmHandler({ runner: mockRunner() }); + const res = await handle(new Request("http://localhost/swarm", { method: "GET" })); + expect(res.status).toBe(405); + }); + + it("answers a CORS preflight with 204", async () => { + const handle = createSwarmHandler({ runner: mockRunner() }); + const res = await handle(new Request("http://localhost/swarm", { method: "OPTIONS" })); + expect(res.status).toBe(204); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + }); + + it("400s on empty messages", async () => { + const handle = createSwarmHandler({ runner: mockRunner() }); + const res = await handle(post({ messages: [] })); + expect(res.status).toBe(400); + }); + + it("400s on invalid JSON", async () => { + const handle = createSwarmHandler({ runner: mockRunner() }); + const res = await handle( + new Request("http://localhost/swarm", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{not json" + }) + ); + expect(res.status).toBe(400); + }); + + it("maps an onRequest SwarmError to its status (e.g. 402 payment required)", async () => { + const handle = createSwarmHandler({ + runner: mockRunner(), + onRequest: () => { + throw new SwarmError("payment required", 402); + } + }); + const res = await handle(post({ messages: [{ role: "user", content: "hi" }] })); + expect(res.status).toBe(402); + expect((await res.json()).error).toBe("payment required"); + }); + + it("does not call the runner when onRequest rejects", async () => { + const runner = mockRunner(); + const handle = createSwarmHandler({ + runner, + onRequest: () => { + throw new SwarmError("nope", 403); + } + }); + await handle(post({ messages: [{ role: "user", content: "hi" }] })); + expect(runner.run).not.toHaveBeenCalled(); + }); + + it("maps a runner failure to 500", async () => { + const runner: SwarmRunner = { + run: vi.fn(async () => { + throw new Error("boom"); + }) + }; + const handle = createSwarmHandler({ runner }); + const res = await handle(post({ messages: [{ role: "user", content: "hi" }] })); + expect(res.status).toBe(500); + expect((await res.json()).error).toBe("boom"); + }); +}); diff --git a/packages/agentswarm/src/index.ts b/packages/agentswarm/src/index.ts new file mode 100644 index 0000000..44a6ae3 --- /dev/null +++ b/packages/agentswarm/src/index.ts @@ -0,0 +1,19 @@ +/** + * @logicsrc/agentswarm — embeddable multi-agent swarm runtime, wrapping deepagents. + * + * Mount {@link createSwarmHandler} on a route in your own app (e.g. `/swarm`); + * back it with {@link createDeepAgentRunner} or any custom {@link SwarmRunner}. + */ +export { createSwarmHandler } from "./handler.js"; +export type { SwarmHandlerOptions } from "./handler.js"; +export { createDeepAgentRunner } from "./runner.js"; +export type { DeepAgentRunnerOptions } from "./runner.js"; +export { createRubricRunner, createLLMJudge } from "./rubric.js"; +export type { + RubricEvaluation, + RubricJudge, + RubricJudgeInput, + RubricRunnerOptions, + LLMJudgeOptions +} from "./rubric.js"; +export * from "./types.js"; diff --git a/packages/agentswarm/src/rubric.test.ts b/packages/agentswarm/src/rubric.test.ts new file mode 100644 index 0000000..3d0a904 --- /dev/null +++ b/packages/agentswarm/src/rubric.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from "vitest"; +import { createRubricRunner } from "./index.js"; +import type { RubricEvaluation, RubricJudge, SwarmRunInput, SwarmRunner } from "./index.js"; + +/** A runner that labels each answer with its attempt number, recording its calls. */ +function scriptedRunner(): { runner: SwarmRunner; calls: SwarmRunInput[] } { + const calls: SwarmRunInput[] = []; + let attempt = 0; + const runner: SwarmRunner = { + run: vi.fn(async (input: SwarmRunInput) => { + calls.push(input); + attempt += 1; + const output = `attempt ${attempt}`; + return { + threadId: input.threadId ?? "t", + messages: [...input.messages, { role: "assistant" as const, content: output }], + output + }; + }) + }; + return { runner, calls }; +} + +/** A judge that fails until the Nth evaluation, then passes. */ +function judgePassingOn(n: number): RubricJudge { + let count = 0; + return { + evaluate: async () => { + count += 1; + return count >= n + ? { passed: true, explanation: "ok" } + : { passed: false, explanation: "missing criterion X" }; + } + }; +} + +describe("createRubricRunner", () => { + it("passes through untouched when there is no rubric", async () => { + const { runner, calls } = scriptedRunner(); + const judge = { evaluate: vi.fn() }; + const rr = createRubricRunner({ runner, judge }); + + const res = await rr.run({ messages: [{ role: "user", content: "hi" }] }); + + expect(judge.evaluate).not.toHaveBeenCalled(); + expect(calls).toHaveLength(1); + expect(res.output).toBe("attempt 1"); + }); + + it("returns the first answer when it passes immediately", async () => { + const { runner, calls } = scriptedRunner(); + const evals: RubricEvaluation[] = []; + const rr = createRubricRunner({ + runner, + judge: judgePassingOn(1), + onEvaluation: (e) => evals.push(e) + }); + + const res = await rr.run({ messages: [{ role: "user", content: "hi" }], rubric: "r" }); + + expect(calls).toHaveLength(1); + expect(evals).toEqual([{ iteration: 1, passed: true, explanation: "ok" }]); + expect(res.output).toBe("attempt 1"); + }); + + it("revises until the answer passes", async () => { + const { runner, calls } = scriptedRunner(); + const evals: RubricEvaluation[] = []; + const rr = createRubricRunner({ + runner, + judge: judgePassingOn(2), + onEvaluation: (e) => evals.push(e) + }); + + const res = await rr.run({ messages: [{ role: "user", content: "hi" }], rubric: "meets X" }); + + expect(calls).toHaveLength(2); + expect(evals.map((e) => e.passed)).toEqual([false, true]); + expect(res.output).toBe("attempt 2"); + + // The retry replays history plus a revision message citing the rubric. + const lastUserMsg = calls[1].messages.filter((m) => m.role === "user").at(-1); + expect(lastUserMsg?.content).toContain("rubric"); + expect(lastUserMsg?.content).toContain("missing criterion X"); + expect(calls[1].rubric).toBe("meets X"); + expect(calls[1].threadId).toBe("t"); + }); + + it("returns best effort after maxIterations without passing", async () => { + const { runner, calls } = scriptedRunner(); + const evals: RubricEvaluation[] = []; + const rr = createRubricRunner({ + runner, + judge: judgePassingOn(99), + maxIterations: 2, + onEvaluation: (e) => evals.push(e) + }); + + const res = await rr.run({ messages: [{ role: "user", content: "hi" }], rubric: "r" }); + + expect(calls).toHaveLength(2); + expect(evals).toHaveLength(2); + expect(evals.every((e) => !e.passed)).toBe(true); + expect(evals.map((e) => e.iteration)).toEqual([1, 2]); + expect(res.output).toBe("attempt 2"); + }); +}); diff --git a/packages/agentswarm/src/rubric.ts b/packages/agentswarm/src/rubric.ts new file mode 100644 index 0000000..f8b75c1 --- /dev/null +++ b/packages/agentswarm/src/rubric.ts @@ -0,0 +1,146 @@ +import type { SwarmRunInput, SwarmRunResult, SwarmRunner } from "./types.js"; + +/** + * One judge verdict on an agent's answer. + * Mirrors the semantics of deepagents' Python `RubricEvaluation` (which is not + * yet available in the JS package), implemented here at the runner layer so it + * works with any {@link SwarmRunner}. + */ +export interface RubricEvaluation { + /** 1-based attempt number this verdict applies to. */ + iteration: number; + /** Whether the answer satisfied every rubric criterion. */ + passed: boolean; + /** Reviewer feedback; names unmet criteria when failed. */ + explanation: string; +} + +export interface RubricJudgeInput { + rubric: string; + output: string; + messages: SwarmRunResult["messages"]; +} + +/** Evaluates an answer against a rubric. Injectable so it can be a real LLM or a stub. */ +export interface RubricJudge { + evaluate(input: RubricJudgeInput): Promise<{ passed: boolean; explanation: string }>; +} + +export interface RubricRunnerOptions { + /** The agent runner whose output gets graded and revised. */ + runner: SwarmRunner; + /** The grader. Use {@link createLLMJudge} or supply your own. */ + judge: RubricJudge; + /** Max agent attempts before returning best effort. Default 3. */ + maxIterations?: number; + /** Called after each judge verdict — wire it to logging/telemetry. */ + onEvaluation?: (evaluation: RubricEvaluation) => void; +} + +/** + * Wrap a {@link SwarmRunner} so that, when a run carries a `rubric`, the answer + * is graded by `judge` and the agent is asked to revise until it passes (or + * `maxIterations` is hit, after which the best effort is returned). Runs without + * a rubric pass straight through untouched. + * + * This is the LLM-as-judge / self-checking loop — deepagents' RubricMiddleware, + * reimplemented at the runner layer so it is model-agnostic and testable. + */ +export function createRubricRunner(options: RubricRunnerOptions): SwarmRunner { + const { runner, judge, onEvaluation } = options; + const maxIterations = Math.max(1, options.maxIterations ?? 3); + + return { + async run(input: SwarmRunInput): Promise { + // No rubric → behave exactly like the wrapped runner. + if (!input.rubric) return runner.run(input); + + const rubric = input.rubric; + let result = await runner.run(input); + + for (let iteration = 1; ; iteration++) { + const verdict = await judge.evaluate({ + rubric, + output: result.output, + messages: result.messages + }); + onEvaluation?.({ iteration, passed: verdict.passed, explanation: verdict.explanation }); + + if (verdict.passed || iteration >= maxIterations) { + return result; + } + + // Ask the agent to revise against the reviewer's feedback. The full + // transcript is replayed so stateless runners stay correct; stateful + // (checkpointer-backed) runners may dedupe by threadId. + const messages = [ + ...result.messages, + { + role: "user" as const, + content: + `Your previous answer did not satisfy the rubric.\n\nRubric:\n${rubric}\n\n` + + `Reviewer feedback:\n${verdict.explanation}\n\n` + + `Revise your answer so it fully satisfies every rubric criterion.` + } + ]; + result = await runner.run({ messages, rubric, threadId: result.threadId }); + } + } + }; +} + +export interface LLMJudgeOptions { + /** Provider-prefixed model id for the grader. Default a small, fast judge. */ + model?: string; + /** Pre-constructed LangChain chat model; bypasses `initChatModel`. */ + chatModel?: any; +} + +/** Indirection so TS does not statically resolve the optional `langchain` peer. */ +async function loadOptionalModule(specifier: string): Promise { + return import(specifier); +} + +function parseJudgeOutput(text: string): { passed: boolean; explanation: string } { + const match = text.match(/\{[\s\S]*\}/); + if (match) { + try { + const obj = JSON.parse(match[0]); + return { passed: Boolean(obj.passed), explanation: String(obj.explanation ?? "") }; + } catch { + // fall through to keyword heuristic + } + } + const passed = /\bpass(ed)?\b/i.test(text) && !/\bfail/i.test(text); + return { passed, explanation: text.slice(0, 500) }; +} + +/** + * Build a {@link RubricJudge} backed by an LLM (a small, cheap model by default — + * the Haiku-judge / Sonnet-worker split). Requires the host to have `langchain` + * (and the model provider) installed, or pass a pre-built `chatModel`. + */ +export async function createLLMJudge(options: LLMJudgeOptions = {}): Promise { + let llm = options.chatModel; + if (!llm) { + const modelId = options.model ?? "anthropic:claude-haiku-4-5"; + const universal = await loadOptionalModule("langchain/chat_models/universal"); + llm = await universal.initChatModel(modelId); + } + + return { + async evaluate({ rubric, output }) { + const system = + "You are a strict evaluator. Given a rubric and an answer, decide whether the answer " + + "satisfies EVERY rubric criterion. Respond ONLY with JSON of the form " + + '{"passed": boolean, "explanation": string}. In the explanation, name any unmet criteria.'; + const user = `Rubric:\n${rubric}\n\nAnswer:\n${output}`; + const res = await llm.invoke([ + { role: "system", content: system }, + { role: "user", content: user } + ]); + const text = typeof res?.content === "string" ? res.content : JSON.stringify(res?.content ?? ""); + return parseJudgeOutput(text); + } + }; +} diff --git a/packages/agentswarm/src/runner.ts b/packages/agentswarm/src/runner.ts new file mode 100644 index 0000000..7851a40 --- /dev/null +++ b/packages/agentswarm/src/runner.ts @@ -0,0 +1,87 @@ +import type { SwarmMessage, SwarmRunInput, SwarmRunResult, SwarmRunner } from "./types.js"; + +export interface DeepAgentRunnerOptions { + /** Provider-prefixed model id, e.g. "anthropic:claude-sonnet-4-6". */ + model: string; + /** System instructions for the lead agent. */ + instructions?: string; + /** + * LangGraph checkpointer for thread persistence. Defaults to an in-memory + * `MemorySaver`. Swap for a Turso/SQLite-backed saver to persist threads + * across restarts (the tronbrowser BYO-SQLite path). + */ + checkpointer?: unknown; + /** Extra params forwarded to deepagents `createDeepAgent` (subagents, middleware, …). */ + agentParams?: Record; +} + +/** + * Indirection so TypeScript does not statically resolve the optional peers + * (`deepagents`, `@langchain/langgraph`). They are only required by hosts that + * use the real runner; the core package and its tests build without them. + */ +async function loadModule(specifier: string): Promise { + return import(specifier); +} + +function newThreadId(): string { + return `thread_${crypto.randomUUID()}`; +} + +/** Map a LangChain message object to a plain {@link SwarmMessage}. */ +function toSwarmMessage(message: any): SwarmMessage { + const type = + typeof message?.getType === "function" ? message.getType() : (message?.role ?? message?.type); + const role: SwarmMessage["role"] = + type === "human" || type === "user" ? "user" : type === "system" ? "system" : "assistant"; + const content = + typeof message?.content === "string" ? message.content : JSON.stringify(message?.content ?? ""); + return { role, content }; +} + +/** + * Create a {@link SwarmRunner} backed by deepagents (`createDeepAgent`). + * + * Requires the optional peers `deepagents` and `@langchain/langgraph`, plus a + * model provider (e.g. `@langchain/anthropic`), to be installed by the host app: + * `npm i deepagents @langchain/langgraph @langchain/anthropic`. + */ +export async function createDeepAgentRunner(options: DeepAgentRunnerOptions): Promise { + let deepagents: any; + try { + deepagents = await loadModule("deepagents"); + } catch { + throw new Error( + "createDeepAgentRunner requires the 'deepagents' package. Install it in the host app: " + + "npm i deepagents @langchain/langgraph @langchain/anthropic" + ); + } + + let checkpointer = options.checkpointer; + if (!checkpointer) { + const langgraph = await loadModule("@langchain/langgraph"); + checkpointer = new langgraph.MemorySaver(); + } + + const agent = deepagents.createDeepAgent({ + model: options.model, + ...(options.instructions ? { instructions: options.instructions } : {}), + checkpointer, + ...options.agentParams + }); + + return { + async run(input: SwarmRunInput): Promise { + const threadId = input.threadId ?? newThreadId(); + const state: Record = { messages: input.messages }; + if (input.rubric) state.rubric = input.rubric; + + const result = await agent.invoke(state, { configurable: { thread_id: threadId } }); + const messages: SwarmMessage[] = Array.isArray(result?.messages) + ? result.messages.map(toSwarmMessage) + : []; + const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant"); + return { threadId, messages, output: lastAssistant?.content ?? "" }; + } + }; +} diff --git a/packages/agentswarm/src/types.ts b/packages/agentswarm/src/types.ts new file mode 100644 index 0000000..15f39a0 --- /dev/null +++ b/packages/agentswarm/src/types.ts @@ -0,0 +1,58 @@ +/** + * AgentSwarm types — an embeddable, framework-agnostic runtime for running + * deepagents-backed agents that a host app mounts on its own route. + * + * The core is intentionally model- and transport-agnostic: a {@link SwarmRunner} + * executes a turn, and {@link createSwarmHandler} adapts it to a Web `Request`. + */ + +export type SwarmRole = "user" | "assistant" | "system"; + +/** A single chat message in a swarm conversation. */ +export interface SwarmMessage { + role: SwarmRole; + content: string; +} + +/** Input for one swarm turn. */ +export interface SwarmRunInput { + /** Conversation so far; the last message is usually the new user turn. */ + messages: SwarmMessage[]; + /** Optional rubric (LLM-as-judge "done" criteria) for this run. */ + rubric?: string; + /** Thread id for persistence; a new one is minted when omitted. */ + threadId?: string; +} + +/** Result of one swarm turn. */ +export interface SwarmRunResult { + /** The thread the turn ran on (echoed or freshly minted). */ + threadId: string; + /** Full message list after the turn. */ + messages: SwarmMessage[]; + /** Convenience: text of the final assistant message. */ + output: string; +} + +/** + * Engine that actually runs a swarm turn. Keeping this injectable lets the + * handler stay model-agnostic; the deepagents-backed runner is one implementation, + * and tests can supply a mock. + */ +export interface SwarmRunner { + run(input: SwarmRunInput): Promise; +} + +/** + * Error a host can throw from a request gate to control the HTTP status — e.g. + * `throw new SwarmError("payment required", 402)` from an x402 paywall hook. + */ +export class SwarmError extends Error { + constructor( + message: string, + readonly status = 400 + ) { + super(message); + this.name = "SwarmError"; + } +} diff --git a/packages/agentswarm/tsconfig.json b/packages/agentswarm/tsconfig.json new file mode 100644 index 0000000..df59da5 --- /dev/null +++ b/packages/agentswarm/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +}