mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 22:37:29 +00:00
feat(agentswarm): M1+M3 — embeddable swarm runtime wrapping deepagents
@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 <noreply@anthropic.com>
This commit is contained in:
parent
ffe37a01ce
commit
018a583a00
13 changed files with 805 additions and 1 deletions
24
package-lock.json
generated
24
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
97
packages/agentswarm/README.md
Normal file
97
packages/agentswarm/README.md
Normal file
|
|
@ -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.
|
||||
34
packages/agentswarm/examples/server.mjs
Normal file
34
packages/agentswarm/examples/server.mjs
Normal file
|
|
@ -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"));
|
||||
25
packages/agentswarm/package.json
Normal file
25
packages/agentswarm/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
81
packages/agentswarm/src/handler.ts
Normal file
81
packages/agentswarm/src/handler.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import type { SwarmRunInput, SwarmRunner } from "./types.js";
|
||||
|
||||
const CORS_HEADERS: Record<string, string> = {
|
||||
"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<void>;
|
||||
}
|
||||
|
||||
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<Response> {
|
||||
const { runner, onRequest } = options;
|
||||
|
||||
return async function handle(request: Request): Promise<Response> {
|
||||
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<SwarmRunInput>;
|
||||
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));
|
||||
}
|
||||
};
|
||||
}
|
||||
118
packages/agentswarm/src/index.test.ts
Normal file
118
packages/agentswarm/src/index.test.ts
Normal file
|
|
@ -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<SwarmRunResult> =>
|
||||
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");
|
||||
});
|
||||
});
|
||||
19
packages/agentswarm/src/index.ts
Normal file
19
packages/agentswarm/src/index.ts
Normal file
|
|
@ -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";
|
||||
107
packages/agentswarm/src/rubric.test.ts
Normal file
107
packages/agentswarm/src/rubric.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
146
packages/agentswarm/src/rubric.ts
Normal file
146
packages/agentswarm/src/rubric.ts
Normal file
|
|
@ -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<SwarmRunResult> {
|
||||
// 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<any> {
|
||||
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<RubricJudge> {
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
87
packages/agentswarm/src/runner.ts
Normal file
87
packages/agentswarm/src/runner.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<any> {
|
||||
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<SwarmRunner> {
|
||||
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<SwarmRunResult> {
|
||||
const threadId = input.threadId ?? newThreadId();
|
||||
const state: Record<string, unknown> = { 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 ?? "" };
|
||||
}
|
||||
};
|
||||
}
|
||||
58
packages/agentswarm/src/types.ts
Normal file
58
packages/agentswarm/src/types.ts
Normal file
|
|
@ -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<SwarmRunResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
}
|
||||
8
packages/agentswarm/tsconfig.json
Normal file
8
packages/agentswarm/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue