logicsrc/packages/agentswarm/src/swarm.test.ts
Anthony Ettinger 925894359f 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>
2026-06-25 17:44:12 +00:00

86 lines
2.9 KiB
TypeScript

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