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:
Anthony Ettinger 2026-06-25 17:41:49 +00:00
parent ffe37a01ce
commit 018a583a00
13 changed files with 805 additions and 1 deletions

24
package-lock.json generated
View file

@ -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",

View file

@ -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",

View 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.

View 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"));

View 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"
}
}

View 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));
}
};
}

View 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");
});
});

View 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";

View 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");
});
});

View 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);
}
};
}

View 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 ?? "" };
}
};
}

View 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";
}
}

View file

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}