mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 14:37:26 +00:00
feat(agentswarm): M2 — peer-coordination swarm (router + handoffs)
createSwarm() composes named agents into one SwarmRunner: a router picks the first agent; any agent hands control to a named peer via `HANDOFF: <name>`, bounded by maxHandoffs. createLLMRouter() is a cheap-model router. Composes with the rubric loop and HTTP handler since a swarm is itself a SwarmRunner. 6 new tests (routing, handoff, maxHandoffs cap, unknown-peer/unknown-route fallbacks, empty-agents guard); 19/19 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
018a583a00
commit
925894359f
3 changed files with 223 additions and 0 deletions
|
|
@ -8,6 +8,15 @@ export { createSwarmHandler } from "./handler.js";
|
||||||
export type { SwarmHandlerOptions } from "./handler.js";
|
export type { SwarmHandlerOptions } from "./handler.js";
|
||||||
export { createDeepAgentRunner } from "./runner.js";
|
export { createDeepAgentRunner } from "./runner.js";
|
||||||
export type { DeepAgentRunnerOptions } from "./runner.js";
|
export type { DeepAgentRunnerOptions } from "./runner.js";
|
||||||
|
export { createSwarm, createLLMRouter } from "./swarm.js";
|
||||||
|
export type {
|
||||||
|
SwarmAgent,
|
||||||
|
SwarmRouter,
|
||||||
|
SwarmRouterInput,
|
||||||
|
SwarmHandoff,
|
||||||
|
SwarmOptions,
|
||||||
|
LLMRouterOptions
|
||||||
|
} from "./swarm.js";
|
||||||
export { createRubricRunner, createLLMJudge } from "./rubric.js";
|
export { createRubricRunner, createLLMJudge } from "./rubric.js";
|
||||||
export type {
|
export type {
|
||||||
RubricEvaluation,
|
RubricEvaluation,
|
||||||
|
|
|
||||||
86
packages/agentswarm/src/swarm.test.ts
Normal file
86
packages/agentswarm/src/swarm.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createSwarm } from "./index.js";
|
||||||
|
import type { SwarmAgent, SwarmRouter, SwarmRunner } from "./index.js";
|
||||||
|
|
||||||
|
/** A runner that always emits the same output (optionally containing a HANDOFF). */
|
||||||
|
function fixedRunner(output: string): SwarmRunner {
|
||||||
|
return {
|
||||||
|
run: vi.fn(async (input) => ({
|
||||||
|
threadId: input.threadId ?? "t",
|
||||||
|
messages: [...input.messages, { role: "assistant" as const, content: output }],
|
||||||
|
output
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function agent(name: string, output: string): SwarmAgent {
|
||||||
|
return { name, description: `${name} agent`, runner: fixedRunner(output) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixedRouter(name: string): SwarmRouter {
|
||||||
|
return { route: vi.fn(async () => name) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ask = { messages: [{ role: "user" as const, content: "do it" }] };
|
||||||
|
|
||||||
|
describe("createSwarm", () => {
|
||||||
|
it("routes to the chosen agent and returns its answer", async () => {
|
||||||
|
const swarm = createSwarm({
|
||||||
|
agents: [agent("alpha", "alpha done"), agent("beta", "beta done")],
|
||||||
|
router: fixedRouter("beta")
|
||||||
|
});
|
||||||
|
const res = await swarm.run(ask);
|
||||||
|
expect(res.output).toBe("beta done");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands off to a named peer and returns the peer's answer", async () => {
|
||||||
|
const handoffs: string[] = [];
|
||||||
|
const swarm = createSwarm({
|
||||||
|
agents: [agent("router", "HANDOFF: worker"), agent("worker", "worker finished")],
|
||||||
|
router: fixedRouter("router"),
|
||||||
|
onHandoff: (h) => handoffs.push(`${h.from}->${h.to}`)
|
||||||
|
});
|
||||||
|
const res = await swarm.run(ask);
|
||||||
|
expect(handoffs).toEqual(["router->worker"]);
|
||||||
|
expect(res.output).toBe("worker finished");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops at maxHandoffs when agents keep handing off", async () => {
|
||||||
|
const onHandoff = vi.fn();
|
||||||
|
const swarm = createSwarm({
|
||||||
|
agents: [agent("ping", "HANDOFF: pong"), agent("pong", "HANDOFF: ping")],
|
||||||
|
router: fixedRouter("ping"),
|
||||||
|
maxHandoffs: 2,
|
||||||
|
onHandoff
|
||||||
|
});
|
||||||
|
const res = await swarm.run(ask);
|
||||||
|
// hop 0 ping->pong, hop 1 pong->ping, hop 2 ping reached maxHandoffs -> return
|
||||||
|
expect(onHandoff).toHaveBeenCalledTimes(2);
|
||||||
|
expect(res.output).toContain("HANDOFF");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a handoff to an unknown agent", async () => {
|
||||||
|
const onHandoff = vi.fn();
|
||||||
|
const swarm = createSwarm({
|
||||||
|
agents: [agent("solo", "HANDOFF: ghost")],
|
||||||
|
router: fixedRouter("solo"),
|
||||||
|
onHandoff
|
||||||
|
});
|
||||||
|
const res = await swarm.run(ask);
|
||||||
|
expect(onHandoff).not.toHaveBeenCalled();
|
||||||
|
expect(res.output).toBe("HANDOFF: ghost");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first agent when the router names an unknown one", async () => {
|
||||||
|
const swarm = createSwarm({
|
||||||
|
agents: [agent("first", "first done"), agent("second", "second done")],
|
||||||
|
router: fixedRouter("does-not-exist")
|
||||||
|
});
|
||||||
|
const res = await swarm.run(ask);
|
||||||
|
expect(res.output).toBe("first done");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when constructed with no agents", () => {
|
||||||
|
expect(() => createSwarm({ agents: [], router: fixedRouter("x") })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
128
packages/agentswarm/src/swarm.ts
Normal file
128
packages/agentswarm/src/swarm.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import type { SwarmMessage, SwarmRunInput, SwarmRunResult, SwarmRunner } from "./types.js";
|
||||||
|
|
||||||
|
/** A named agent in a swarm. The description is what the router sees when picking. */
|
||||||
|
export interface SwarmAgent {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
runner: SwarmRunner;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwarmRouterInput {
|
||||||
|
messages: SwarmMessage[];
|
||||||
|
agents: { name: string; description: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picks which agent should handle a task. Injectable: LLM, rules, or a stub. */
|
||||||
|
export interface SwarmRouter {
|
||||||
|
route(input: SwarmRouterInput): Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwarmHandoff {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
/** 1-based hop number at which the handoff occurred. */
|
||||||
|
iteration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwarmOptions {
|
||||||
|
agents: SwarmAgent[];
|
||||||
|
/** Chooses the first agent. Use {@link createLLMRouter} or supply your own. */
|
||||||
|
router: SwarmRouter;
|
||||||
|
/** Max peer-to-peer handoffs before returning the current answer. Default 4. */
|
||||||
|
maxHandoffs?: number;
|
||||||
|
/** Called on each handoff — wire to logging/telemetry. */
|
||||||
|
onHandoff?: (handoff: SwarmHandoff) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An agent signals a handoff by emitting `HANDOFF: <peer-name>` in its output. */
|
||||||
|
const HANDOFF_RE = /HANDOFF:\s*([A-Za-z0-9_-]+)/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose several agents into one {@link SwarmRunner}. A router picks the first
|
||||||
|
* agent for the task; any agent can hand control to a named peer by emitting
|
||||||
|
* `HANDOFF: <name>`, up to `maxHandoffs` hops. This is the peer-coordination
|
||||||
|
* layer on top of deepagents' single parent→subagent model.
|
||||||
|
*
|
||||||
|
* Because the result is itself a SwarmRunner, a swarm composes with the rubric
|
||||||
|
* loop and the HTTP handler like any other runner.
|
||||||
|
*/
|
||||||
|
export function createSwarm(options: SwarmOptions): SwarmRunner {
|
||||||
|
if (options.agents.length === 0) {
|
||||||
|
throw new Error("createSwarm requires at least one agent");
|
||||||
|
}
|
||||||
|
const { router, onHandoff } = options;
|
||||||
|
const agents = new Map(options.agents.map((agent) => [agent.name, agent]));
|
||||||
|
const roster = options.agents.map(({ name, description }) => ({ name, description }));
|
||||||
|
const maxHandoffs = Math.max(0, options.maxHandoffs ?? 4);
|
||||||
|
|
||||||
|
return {
|
||||||
|
async run(input: SwarmRunInput): Promise<SwarmRunResult> {
|
||||||
|
const chosen = await router.route({ messages: input.messages, agents: roster });
|
||||||
|
let current = agents.get(chosen) ?? options.agents[0];
|
||||||
|
|
||||||
|
let messages = input.messages;
|
||||||
|
let threadId = input.threadId;
|
||||||
|
|
||||||
|
for (let hop = 0; ; hop++) {
|
||||||
|
const result = await current.runner.run({ messages, rubric: input.rubric, threadId });
|
||||||
|
threadId = result.threadId;
|
||||||
|
|
||||||
|
const target = result.output.match(HANDOFF_RE)?.[1];
|
||||||
|
if (!target || target === current.name || !agents.has(target) || hop >= maxHandoffs) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
onHandoff?.({ from: current.name, to: target, iteration: hop + 1 });
|
||||||
|
current = agents.get(target)!;
|
||||||
|
messages = [
|
||||||
|
...result.messages,
|
||||||
|
{ role: "user" as const, content: `[swarm] Control handed to ${target}. Continue the task.` }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LLMRouterOptions {
|
||||||
|
/** Provider-prefixed model id for routing. Default a small, fast model. */
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a {@link SwarmRouter} that asks a cheap LLM which agent should take the
|
||||||
|
* task. Requires the host to have `langchain` installed, or pass a `chatModel`.
|
||||||
|
*/
|
||||||
|
export async function createLLMRouter(options: LLMRouterOptions = {}): Promise<SwarmRouter> {
|
||||||
|
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 route({ messages, agents }) {
|
||||||
|
const task = [...messages].reverse().find((m) => m.role === "user")?.content ?? "";
|
||||||
|
const roster = agents.map((a) => `- ${a.name}: ${a.description}`).join("\n");
|
||||||
|
const res = await llm.invoke([
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content:
|
||||||
|
"Choose the single best agent to handle the user's request. " +
|
||||||
|
`Reply with ONLY the agent name, nothing else.\n\nAgents:\n${roster}`
|
||||||
|
},
|
||||||
|
{ role: "user", content: task }
|
||||||
|
]);
|
||||||
|
const text = typeof res?.content === "string" ? res.content : JSON.stringify(res?.content ?? "");
|
||||||
|
const name = text.trim().split(/\s+/)[0];
|
||||||
|
return agents.some((a) => a.name === name) ? name : (agents[0]?.name ?? "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue