mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 14:57:28 +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
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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue