diff --git a/packages/agentswarm/src/index.ts b/packages/agentswarm/src/index.ts index 44a6ae3..d986f93 100644 --- a/packages/agentswarm/src/index.ts +++ b/packages/agentswarm/src/index.ts @@ -8,6 +8,15 @@ export { createSwarmHandler } from "./handler.js"; export type { SwarmHandlerOptions } from "./handler.js"; export { createDeepAgentRunner } 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 type { RubricEvaluation, diff --git a/packages/agentswarm/src/swarm.test.ts b/packages/agentswarm/src/swarm.test.ts new file mode 100644 index 0000000..8c67dc5 --- /dev/null +++ b/packages/agentswarm/src/swarm.test.ts @@ -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(); + }); +}); diff --git a/packages/agentswarm/src/swarm.ts b/packages/agentswarm/src/swarm.ts new file mode 100644 index 0000000..f6c8d13 --- /dev/null +++ b/packages/agentswarm/src/swarm.ts @@ -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; +} + +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: ` 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: `, 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 { + 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 { + 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 { + 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 ?? ""); + } + }; +}