feat(swarm): UOK-based swarm with PersistentAgent, AgentSwarm, and SwarmDispatchLayer

- PersistentAgent: stable identity across restarts, 3-tier memory (core
  blocks / recall / archival), durable SQLite inbox, sendAndWait request-
  reply, broadcast — all backed by UokCoordinationStore + MessageBus
- AgentSwarm: Letta-style group topology with ManagerType enum
  (round_robin, supervisor, dynamic, sleeptime), tag-based routing,
  shared agent_directory block, persist/load round-trip
- Role agents: CoordinatorAgent, WorkerAgent, ScoutAgent, ReviewerAgent
  extending PersistentAgent with preset tags + createDefaultSwarm factory
  (1 coordinator, 2 workers, 1 scout, 1 reviewer)
- SwarmDispatchLayer: routes UOK DispatchEnvelopes by workMode/unitType
  to the correct role agent, module-level cache, swarmDispatch() convenience fn
- 15 tests passing (identity persistence, messaging, registry, topology,
  dispatch routing) using real SQLite in tmp dirs
- Fix: tsconfig.resources.json — add types:[node] for TypeScript 6 compat

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-09 04:04:42 +02:00
parent efa3ce4492
commit 848ac0dd99
7 changed files with 1722 additions and 0 deletions

View file

@ -0,0 +1,332 @@
/**
* swarm.test.mjs Full test suite for the SF swarm system.
*
* Purpose: verify PersistentAgent identity/messaging, AgentSwarm registry/routing,
* createDefaultSwarm topology, and SwarmDispatchLayer envelope routing using real
* SQLite in isolated tmp directories (no mocking).
*
* Consumer: CI unit-test suite (`npm run test:unit`).
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { closeDatabase } from "../sf-db.js";
import { _clearSfRootCache } from "../paths.js";
import { PersistentAgent } from "../uok/persistent-agent.js";
import { AgentSwarm } from "../uok/agent-swarm.js";
import {
CoordinatorAgent,
WorkerAgent,
ScoutAgent,
ReviewerAgent,
createDefaultSwarm,
} from "../uok/swarm-roles.js";
import { SwarmDispatchLayer } from "../uok/swarm-dispatch.js";
// ─── Shared Setup ─────────────────────────────────────────────────────────────
const tmpRoots = [];
afterEach(() => {
closeDatabase();
_clearSfRootCache();
for (const root of tmpRoots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});
function makeProject() {
const root = mkdtempSync(join(tmpdir(), "sf-swarm-"));
tmpRoots.push(root);
return root;
}
// ─── PersistentAgent — identity ───────────────────────────────────────────────
describe("PersistentAgent — identity", () => {
test("init_when_new_creates_stable_agentId", () => {
const root = makeProject();
const agent = new PersistentAgent(root, { name: "alice", role: "worker" });
expect(agent.identity.agentId).toMatch(/^agent-/);
expect(agent.identity.name).toBe("alice");
expect(agent.identity.role).toBe("worker");
});
test("init_when_resumed_restores_same_agentId", () => {
const root = makeProject();
const agent1 = new PersistentAgent(root, { name: "bob", role: "worker" });
const originalId = agent1.identity.agentId;
// Simulate process restart
closeDatabase();
_clearSfRootCache();
const agent2 = new PersistentAgent(root, { name: "bob", role: "worker" });
expect(agent2.identity.agentId).toBe(originalId);
});
test("coreBlock_when_set_persists_across_restart", () => {
const root = makeProject();
const agent1 = new PersistentAgent(root, { name: "carol", role: "worker" });
agent1.setCoreBlock("notes", { text: "hello world" });
// Simulate process restart
closeDatabase();
_clearSfRootCache();
const agent2 = new PersistentAgent(root, { name: "carol", role: "worker" });
expect(agent2.getCoreBlock("notes")).toEqual({ text: "hello world" });
});
});
// ─── PersistentAgent — messaging ──────────────────────────────────────────────
describe("PersistentAgent — messaging", () => {
test("send_receive_delivers_message", () => {
const root = makeProject();
const agentA = new PersistentAgent(root, { name: "sender", role: "worker" });
const agentB = new PersistentAgent(root, {
name: "receiver",
role: "worker",
});
agentA.send("receiver", "hello-world-payload");
// Force inbox refresh (bypassing the 30s interval guard)
agentB.inbox.refresh();
const msgs = agentB.receive(true);
expect(msgs.length).toBeGreaterThanOrEqual(1);
const found = msgs.find((m) => m.body === "hello-world-payload");
expect(found).toBeDefined();
});
test("markRead_when_called_hides_from_unread", () => {
const root = makeProject();
const agentA = new PersistentAgent(root, {
name: "sender2",
role: "worker",
});
const agentB = new PersistentAgent(root, {
name: "receiver2",
role: "worker",
});
agentA.send("receiver2", "ping");
agentB.inbox.refresh();
const unread = agentB.receive(true);
expect(unread.length).toBeGreaterThanOrEqual(1);
agentB.markRead(unread[0].id);
agentB.inbox.refresh();
expect(agentB.receive(true)).toHaveLength(0);
});
test("broadcast_delivers_to_all_recipients", () => {
const root = makeProject();
const agentA = new PersistentAgent(root, {
name: "broadcaster",
role: "coordinator",
});
const agentB = new PersistentAgent(root, { name: "recv-b", role: "worker" });
const agentC = new PersistentAgent(root, { name: "recv-c", role: "worker" });
agentA.broadcast("announcement", {}, ["recv-b", "recv-c"]);
agentB.inbox.refresh();
agentC.inbox.refresh();
expect(agentB.receive(true)).toHaveLength(1);
expect(agentC.receive(true)).toHaveLength(1);
});
});
// ─── AgentSwarm — registry ────────────────────────────────────────────────────
describe("AgentSwarm — registry", () => {
test("register_adds_agent_to_roster", () => {
const root = makeProject();
const swarm = new AgentSwarm(root, { name: "test-swarm" });
const worker = new PersistentAgent(root, {
name: "worker",
role: "worker",
});
swarm.register(worker);
expect(swarm.getAll()).toHaveLength(1);
expect(swarm.get("worker").identity.name).toBe("worker");
});
test("getByRole_filters_correctly", () => {
const root = makeProject();
const swarm = new AgentSwarm(root, { name: "test-swarm-roles" });
const coord = new PersistentAgent(root, {
name: "coord",
role: "coordinator",
});
const w1 = new PersistentAgent(root, { name: "w1", role: "worker" });
const w2 = new PersistentAgent(root, { name: "w2", role: "worker" });
swarm.register(coord);
swarm.register(w1);
swarm.register(w2);
expect(swarm.getByRole("worker")).toHaveLength(2);
expect(swarm.getByRole("coordinator")).toHaveLength(1);
});
test("persist_and_load_round_trips", () => {
const root = makeProject();
const swarm = new AgentSwarm(root, { name: "persist-swarm" });
const wA = new PersistentAgent(root, { name: "wA", role: "worker" });
const wB = new PersistentAgent(root, { name: "wB", role: "worker" });
swarm.register(wA);
swarm.register(wB);
swarm.persist();
// Simulate restart
closeDatabase();
_clearSfRootCache();
const loaded = AgentSwarm.load(root, "persist-swarm");
const names = loaded
.getAll()
.map((a) => a.identity.name)
.sort();
expect(names).toEqual(["wA", "wB"]);
});
test("agentDirectory_injected_on_register", () => {
const root = makeProject();
const swarm = new AgentSwarm(root, { name: "dir-swarm" });
const a1 = new PersistentAgent(root, { name: "dir-a1", role: "worker" });
const a2 = new PersistentAgent(root, { name: "dir-a2", role: "worker" });
swarm.register(a1);
swarm.register(a2);
const dir1 = JSON.parse(a1.getCoreBlock("agent_directory"));
const dir2 = JSON.parse(a2.getCoreBlock("agent_directory"));
const names1 = dir1.map((e) => e.name);
const names2 = dir2.map((e) => e.name);
expect(names1).toContain("dir-a1");
expect(names1).toContain("dir-a2");
expect(names2).toContain("dir-a1");
expect(names2).toContain("dir-a2");
});
});
// ─── createDefaultSwarm — topology ────────────────────────────────────────────
describe("createDefaultSwarm — topology", () => {
test("creates_five_agents", async () => {
const root = makeProject();
const { swarm } = await createDefaultSwarm(root);
expect(swarm.getAll()).toHaveLength(5);
});
test("topology_has_correct_roles", async () => {
const root = makeProject();
const { swarm } = await createDefaultSwarm(root);
const topology = swarm.getTopology();
expect(topology.coordinator).toHaveLength(1);
expect(topology.workers).toHaveLength(2);
expect(topology.scouts).toHaveLength(1);
expect(topology.reviewers).toHaveLength(1);
});
});
// ─── SwarmDispatchLayer — routing ─────────────────────────────────────────────
describe("SwarmDispatchLayer — routing", () => {
test("dispatch_build_envelope_routes_to_worker", async () => {
const root = makeProject();
const layer = new SwarmDispatchLayer(root);
const result = await layer.dispatch({
unitId: "task-build-1",
unitType: "task",
workMode: "build",
payload: "build task data",
priority: 5,
scope: "test-scope",
});
expect(result.messageId).toBeTruthy();
expect(result.targetAgent).toBeTruthy();
const swarm = await layer.getOrCreateSwarm();
const agent = swarm.get(result.targetAgent);
expect(agent).toBeDefined();
expect(agent.identity.role).toBe("worker");
});
test("dispatch_review_envelope_routes_to_reviewer", async () => {
const root = makeProject();
const layer = new SwarmDispatchLayer(root);
const result = await layer.dispatch({
unitId: "task-review-1",
unitType: "task",
workMode: "review",
payload: "review task data",
priority: 5,
scope: "test-scope",
});
expect(result.messageId).toBeTruthy();
const swarm = await layer.getOrCreateSwarm();
const agent = swarm.get(result.targetAgent);
expect(agent).toBeDefined();
expect(agent.identity.role).toBe("reviewer");
});
test("dispatchBatch_dispatches_all", async () => {
const root = makeProject();
const layer = new SwarmDispatchLayer(root);
const envelopes = [
{
unitId: "t1",
unitType: "task",
workMode: "build",
payload: "task-1",
priority: 1,
scope: "test",
},
{
unitId: "t2",
unitType: "task",
workMode: "build",
payload: "task-2",
priority: 1,
scope: "test",
},
{
unitId: "t3",
unitType: "task",
workMode: "build",
payload: "task-3",
priority: 1,
scope: "test",
},
];
const results = await layer.dispatchBatch(envelopes);
expect(results).toHaveLength(3);
for (const r of results) {
expect(r.messageId).toBeTruthy();
expect(r.targetAgent).toBeTruthy();
}
});
});

View file

@ -0,0 +1,620 @@
/**
* agent-swarm.js Multi-agent group orchestrator with Letta-style group topology.
*
* Purpose: manage a named group of PersistentAgents with durable SQLite-backed registry,
* tag-based routing, and group turn execution. The swarm persists agent registrations
* across restarts and coordinates via the shared MessageBus.
*
* Consumer: SwarmDispatchLayer, createDefaultSwarm factory, and direct multi-agent
* orchestration flows.
*/
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { sfRoot } from "../paths.js";
import { getDatabase, openDatabase } from "../sf-db.js";
import { UokCoordinationStore } from "./coordination-store.js";
import { MessageBus } from "./message-bus.js";
import { PersistentAgent } from "./persistent-agent.js";
/**
* Letta-style group topology manager types.
*
* Purpose: enumerate valid routing strategies so callers get a type-safe,
* discoverable set of options rather than ad-hoc strings.
*
* Consumer: AgentSwarm constructor and run() dispatch logic.
*/
export const ManagerType = Object.freeze({
ROUND_ROBIN: "round_robin",
SUPERVISOR: "supervisor",
DYNAMIC: "dynamic",
SLEEPTIME: "sleeptime",
});
const DEFAULT_MANAGER_TYPE = ManagerType.SUPERVISOR;
const DEFAULT_TERMINATION_TOKEN = "DONE!";
const DEFAULT_MAX_TURNS = 20;
function ensureDbOpen(basePath) {
const dir = sfRoot(basePath);
mkdirSync(dir, { recursive: true });
const dbPath = join(dir, "sf.db");
if (!getDatabase()) {
openDatabase(dbPath);
}
const db = getDatabase();
if (!db) throw new Error(`AgentSwarm: failed to open database at ${dbPath}`);
return db;
}
/**
* AgentSwarm Multi-agent group with MessageBus coordination and Letta-style topology.
*
* Purpose: manage a named group of PersistentAgents with durable SQLite-backed registry,
* tag-based routing, and group turn execution. The swarm persists agent registrations
* across restarts and coordinates via the shared MessageBus.
*
* Consumer: SwarmDispatchLayer, createDefaultSwarm factory, and direct multi-agent
* orchestration flows.
*/
export class AgentSwarm {
/**
* Create or resume a named agent swarm.
*
* Purpose: initialize swarm identity and reload any previously registered
* agents from the durable KV registry so swarm state survives restarts.
*
* Consumer: orchestration entry points and swarm factory functions.
*
* @param {string} basePath - project root used to locate `.sf/sf.db`
* @param {object} options
* @param {string} options.name - stable swarm identity key (required)
* @param {string} [options.managerType='supervisor'] - routing topology
* @param {string} [options.terminationToken='DONE!'] - token that ends a run loop
* @param {number} [options.maxTurns=20] - max turn iterations per run()
*/
constructor(basePath, options = {}) {
const {
name,
managerType = DEFAULT_MANAGER_TYPE,
terminationToken = DEFAULT_TERMINATION_TOKEN,
maxTurns = DEFAULT_MAX_TURNS,
} = options;
if (!name) throw new Error("AgentSwarm: options.name is required");
if (!Object.values(ManagerType).includes(managerType)) {
throw new Error(
`AgentSwarm: invalid managerType "${managerType}". Must be one of: ${Object.values(ManagerType).join(", ")}`,
);
}
this._basePath = basePath;
this._name = name;
this._managerType = managerType;
this._terminationToken = terminationToken;
this._maxTurns = maxTurns;
/** @type {Map<string, PersistentAgent>} */
this._agents = new Map();
const db = ensureDbOpen(basePath);
this._store = new UokCoordinationStore(db);
this._bus = new MessageBus(basePath);
// Persist initial config
this._store.set(this._configKey(), {
name,
managerType,
terminationToken,
maxTurns,
});
// Reload any previously registered agents from the durable registry
const registry = this._store.get(this._registryKey());
if (registry && Array.isArray(registry.agents)) {
for (const entry of registry.agents) {
const agent = new PersistentAgent(basePath, {
name: entry.name,
role: entry.role,
tags: entry.tags ?? [],
});
this._agents.set(entry.name, agent);
}
}
}
// ─── KV Key Helpers ────────────────────────────────────────────────────────
_registryKey() {
return `swarm:${this._name}:registry`;
}
_configKey() {
return `swarm:${this._name}:config`;
}
_directoryKey() {
return `swarm:${this._name}:directory`;
}
// ─── Agent Registration ────────────────────────────────────────────────────
/**
* Add a PersistentAgent to the swarm and broadcast the updated agent directory.
*
* Purpose: maintain a live roster of agents, keeping every agent's
* agent_directory core block in sync so each agent knows its peers.
*
* Consumer: swarm setup code and dynamic agent onboarding flows.
*
* @param {PersistentAgent} agent
*/
register(agent) {
if (!agent || typeof agent.identity !== "object") {
throw new Error(
"AgentSwarm.register: argument must be a PersistentAgent instance",
);
}
const { name } = agent.identity;
this._agents.set(name, agent);
// Rebuild and persist the directory
const directory = this._buildDirectory();
this._store.set(this._directoryKey(), directory);
// Push updated directory to every registered agent's core block
const directoryJson = JSON.stringify(directory);
for (const a of this._agents.values()) {
a.setCoreBlock("agent_directory", directoryJson);
}
this.persist();
}
/**
* Retrieve a registered agent by name.
*
* Purpose: give callers direct access to a named swarm member for targeted
* operations without exposing the internal agent map.
*
* Consumer: routing logic, tests, and external coordination flows.
*
* @param {string} agentName
* @returns {PersistentAgent | undefined}
*/
get(agentName) {
return this._agents.get(agentName);
}
/**
* Return all registered agents.
*
* Purpose: provide a snapshot of the full swarm roster for iteration or
* broadcast operations.
*
* Consumer: run() loops, broadcast helpers, and topology queries.
*
* @returns {PersistentAgent[]}
*/
getAll() {
return [...this._agents.values()];
}
/**
* Return all agents whose identity.role matches the given role.
*
* Purpose: enable role-based routing so coordinators, workers, scouts,
* and reviewers can be selected without hardcoding agent names.
*
* Consumer: route(), getTopology(), and dynamic dispatch flows.
*
* @param {string} role
* @returns {PersistentAgent[]}
*/
getByRole(role) {
return this.getAll().filter((a) => a.identity.role === role);
}
/**
* Return agents matching Letta-style tag filters.
*
* Purpose: support fine-grained tag-based routing (matchAll = AND,
* matchSome = OR) so callers can select agents by capability labels.
*
* Consumer: sendToAgentsMatchingTags and dynamic swarm routing.
*
* @param {string[]} [matchAll=[]] - agent must have ALL of these tags
* @param {string[]} [matchSome=[]] - agent must have AT LEAST ONE of these tags
* @returns {PersistentAgent[]}
*/
getByTags(matchAll = [], matchSome = []) {
return this.getAll().filter((a) => {
const tags = a.identity.tags ?? [];
const allMatch =
matchAll.length === 0 || matchAll.every((t) => tags.includes(t));
const someMatch =
matchSome.length === 0 || matchSome.some((t) => tags.includes(t));
return allMatch && someMatch;
});
}
// ─── Messaging ─────────────────────────────────────────────────────────────
/**
* Send a message from one registered agent to another.
*
* Purpose: route a point-to-point swarm message via the shared MessageBus
* so it is durably stored and survives process restarts.
*
* Consumer: swarm orchestration flows delegating work between agents.
*
* @param {string} fromName
* @param {string} toName
* @param {*} body
* @param {object} [metadata={}]
* @returns {string} message ID
*/
send(fromName, toName, body, metadata = {}) {
return this._bus.send(
`agent:${fromName}`,
`agent:${toName}`,
body,
metadata,
);
}
/**
* Broadcast a message from one agent to all registered agents.
*
* Purpose: fan-out a notification or task to the full swarm in one call.
*
* Consumer: coordinator agents distributing context or system-level events.
*
* @param {string} fromName
* @param {*} body
* @param {object} [metadata={}]
* @returns {string[]} message IDs
*/
broadcast(fromName, body, metadata = {}) {
const recipients = this.getAll()
.map((a) => a.identity.name)
.filter((n) => n !== fromName);
return this._bus.broadcast(
`agent:${fromName}`,
recipients.map((n) => `agent:${n}`),
body,
metadata,
);
}
/**
* Send a message to all agents matching the given tag filters.
*
* Purpose: target capability-tagged subsets of the swarm without the caller
* knowing individual agent names.
*
* Consumer: dynamic dispatch flows that route by capability rather than identity.
*
* @param {string} fromName
* @param {*} body
* @param {string[]} [matchAll=[]]
* @param {string[]} [matchSome=[]]
* @returns {string[]} message IDs
*/
sendToAgentsMatchingTags(fromName, body, matchAll = [], matchSome = []) {
const targets = this.getByTags(matchAll, matchSome).filter(
(a) => a.identity.name !== fromName,
);
return targets.map((a) => this.send(fromName, a.identity.name, body));
}
// ─── Routing ───────────────────────────────────────────────────────────────
/**
* Route a DispatchEnvelope to the appropriate swarm agent by workMode/unitType.
*
* Purpose: map task routing decisions to the correct role agent so callers
* don't need to know the swarm roster or role layout.
*
* Consumer: SwarmDispatchLayer and autonomous dispatch loops.
*
* @param {{ unitType?: string, workMode?: string }} envelope
* @returns {PersistentAgent | undefined}
*/
route(envelope) {
const workMode =
envelope.workMode ?? this._deriveWorkMode(envelope.unitType);
switch (workMode) {
case "build":
case "repair":
case "code":
return this.getByRole("worker")[0];
case "research":
return this.getByRole("scout")[0];
case "review":
return this.getByRole("reviewer")[0];
default:
return this.getByRole("coordinator")[0] ?? this.getAll()[0];
}
}
_deriveWorkMode(unitType) {
if (!unitType) return undefined;
const t = unitType.toLowerCase();
if (t.includes("research") || t.includes("scout")) return "research";
if (t.includes("review") || t.includes("audit")) return "review";
if (t.includes("repair") || t.includes("fix")) return "repair";
if (t.includes("build") || t.includes("code") || t.includes("implement"))
return "build";
return undefined;
}
// ─── Topology ──────────────────────────────────────────────────────────────
/**
* Return a structured view of the swarm by role.
*
* Purpose: give callers a single snapshot of the swarm topology for
* display, routing, and diagnostic purposes without exposing internals.
*
* Consumer: run() orchestration and swarm health checks.
*
* @returns {{ coordinator: PersistentAgent[], workers: PersistentAgent[], scouts: PersistentAgent[], reviewers: PersistentAgent[], all: PersistentAgent[] }}
*/
getTopology() {
return {
coordinator: this.getByRole("coordinator"),
workers: this.getByRole("worker"),
scouts: this.getByRole("scout"),
reviewers: this.getByRole("reviewer"),
all: this.getAll(),
};
}
// ─── Group Turn Loop ───────────────────────────────────────────────────────
/**
* Execute a group turn loop over the swarm agents.
*
* Purpose: drive a multi-turn conversation across agents using the configured
* topology (round_robin, supervisor, dynamic, sleeptime) and return the full
* turn history with termination status.
*
* Consumer: autonomous dispatch, eval harness, and multi-agent task runners.
*
* @param {string} initialMessage
* @param {object} [opts]
* @param {string} [opts.from] - agent name to send as
* @param {number} [opts.maxTurns] - override swarm default
* @param {number} [opts.timeout] - timeout in ms (informational; not enforced at socket level)
* @returns {{ turns: Array<{agent: string, message: string}>, terminated: boolean, reason: string }}
*/
run(initialMessage, opts = {}) {
const maxTurns = opts.maxTurns ?? this._maxTurns;
const fromName = opts.from ?? "external";
const terminationToken = this._terminationToken;
const turns = [];
let terminated = false;
let reason = "max_turns_reached";
const agents = this.getAll();
if (agents.length === 0) {
return { turns, terminated: false, reason: "no_agents" };
}
switch (this._managerType) {
case ManagerType.ROUND_ROBIN: {
let message = initialMessage;
for (let i = 0; i < maxTurns; i++) {
const agent = agents[i % agents.length];
const agentName = agent.identity.name;
this.send(fromName, agentName, message);
const received = agent.receive(false);
const last = received[received.length - 1];
const reply = _bodyToString(last?.body, message);
turns.push({ agent: agentName, message: reply });
if (reply.includes(terminationToken)) {
terminated = true;
reason = "termination_token";
break;
}
message = reply;
}
break;
}
case ManagerType.SUPERVISOR: {
const { coordinator } = this.getTopology();
const supervisor = coordinator[0] ?? agents[0];
const supervisorName = supervisor.identity.name;
this.send(fromName, supervisorName, initialMessage);
let message = initialMessage;
for (let i = 0; i < maxTurns; i++) {
const received = supervisor.receive(true);
const last = received[received.length - 1];
const reply = _bodyToString(last?.body, message);
turns.push({ agent: supervisorName, message: reply });
if (reply.includes(terminationToken)) {
terminated = true;
reason = "termination_token";
break;
}
// Supervisor routes to a worker (cycle through workers)
const workers = this.getByRole("worker");
if (workers.length > 0) {
const worker = workers[i % workers.length];
const workerName = worker.identity.name;
this.send(supervisorName, workerName, reply);
const workerMessages = worker.receive(true);
const workerLast = workerMessages[workerMessages.length - 1];
const workerReply = _bodyToString(workerLast?.body, reply);
turns.push({ agent: workerName, message: workerReply });
if (workerReply.includes(terminationToken)) {
terminated = true;
reason = "termination_token";
break;
}
// Route worker result back to supervisor for next iteration
this.send(workerName, supervisorName, workerReply);
message = workerReply;
} else {
message = reply;
}
}
break;
}
case ManagerType.DYNAMIC: {
// Each turn: give every agent a chance to reply; first with token ends loop
let message = initialMessage;
for (let i = 0; i < maxTurns; i++) {
let tokenFound = false;
for (const agent of agents) {
const agentName = agent.identity.name;
this.send(fromName, agentName, message);
const received = agent.receive(false);
const last = received[received.length - 1];
const reply = _bodyToString(last?.body, message);
turns.push({ agent: agentName, message: reply });
if (reply.includes(terminationToken)) {
terminated = true;
reason = "termination_token";
tokenFound = true;
break;
}
message = reply;
}
if (tokenFound) break;
}
break;
}
case ManagerType.SLEEPTIME: {
// One conversation agent + background memory consolidation by coordinator
const conversationAgents = agents.filter(
(a) => a.identity.role !== "coordinator",
);
const memoryAgent = this.getByRole("coordinator")[0];
const convoAgent = conversationAgents[0] ?? agents[0];
const convoName = convoAgent.identity.name;
let message = initialMessage;
this.send(fromName, convoName, message);
for (let i = 0; i < maxTurns; i++) {
const received = convoAgent.receive(true);
const last = received[received.length - 1];
const reply = _bodyToString(last?.body, message);
turns.push({ agent: convoName, message: reply });
if (reply.includes(terminationToken)) {
terminated = true;
reason = "termination_token";
break;
}
message = reply;
// Background memory consolidation step
if (memoryAgent) {
const memName = memoryAgent.identity.name;
this.send(convoName, memName, `consolidate: ${reply}`);
const memReceived = memoryAgent.receive(true);
const memLast = memReceived[memReceived.length - 1];
if (memLast) {
turns.push({
agent: memName,
message: `[memory] ${_bodyToString(memLast.body, "")}`,
});
}
}
}
break;
}
}
return { turns, terminated, reason };
}
// ─── Persistence ───────────────────────────────────────────────────────────
/**
* Save the current agent registry snapshot to the KV store.
*
* Purpose: make swarm membership durable so agents can be rehydrated on
* restart without re-registering them programmatically.
*
* Consumer: register() and any code that mutates swarm membership.
*/
persist() {
const agents = this.getAll().map((a) => ({
name: a.identity.name,
role: a.identity.role,
tags: a.identity.tags ?? [],
}));
this._store.set(this._registryKey(), {
name: this._name,
managerType: this._managerType,
agents,
config: {
terminationToken: this._terminationToken,
maxTurns: this._maxTurns,
},
});
}
/**
* Rehydrate an AgentSwarm from a previously persisted registry.
*
* Purpose: restore swarm state across process restarts without requiring
* callers to re-register agents explicitly.
*
* Consumer: orchestration bootstrap and restart recovery paths.
*
* @param {string} basePath
* @param {string} name
* @returns {AgentSwarm}
*/
static load(basePath, name) {
const db = ensureDbOpen(basePath);
const store = new UokCoordinationStore(db);
const registryKey = `swarm:${name}:registry`;
const saved = store.get(registryKey);
if (!saved) {
return new AgentSwarm(basePath, { name });
}
const swarm = new AgentSwarm(basePath, {
name: saved.name ?? name,
managerType: saved.managerType ?? DEFAULT_MANAGER_TYPE,
terminationToken:
saved.config?.terminationToken ?? DEFAULT_TERMINATION_TOKEN,
maxTurns: saved.config?.maxTurns ?? DEFAULT_MAX_TURNS,
});
// Agents are reloaded from the registry inside the constructor.
// Re-call register() on each to ensure agent_directory blocks are current.
for (const agent of swarm.getAll()) {
swarm.register(agent);
}
return swarm;
}
// ─── Internal Helpers ──────────────────────────────────────────────────────
_buildDirectory() {
return this.getAll().map((a) => ({
name: a.identity.name,
role: a.identity.role,
tags: a.identity.tags ?? [],
agentId: a.identity.agentId,
}));
}
}
function _bodyToString(body, fallback) {
if (body == null) return fallback;
return typeof body === "string" ? body : JSON.stringify(body);
}

View file

@ -91,8 +91,16 @@ export {
export { recordUokKernelTermination, runAutoLoopWithUok } from "./kernel.js"; export { recordUokKernelTermination, runAutoLoopWithUok } from "./kernel.js";
// ─── Loop Adapter ────────────────────────────────────────────────────────── // ─── Loop Adapter ──────────────────────────────────────────────────────────
export { createTurnObserver } from "./loop-adapter.js"; export { createTurnObserver } from "./loop-adapter.js";
// ─── Agent Swarm ───────────────────────────────────────────────────────────
export { AgentSwarm, ManagerType } from "./agent-swarm.js";
// ─── Letta Agent ───────────────────────────────────────────────────────────
export { PersistentAgent } from "./persistent-agent.js";
// ─── Message Bus ─────────────────────────────────────────────────────────── // ─── Message Bus ───────────────────────────────────────────────────────────
export { AgentInbox, MessageBus } from "./message-bus.js"; export { AgentInbox, MessageBus } from "./message-bus.js";
// ─── Swarm Roles ───────────────────────────────────────────────────────────
export { CoordinatorAgent, WorkerAgent, ScoutAgent, ReviewerAgent, createDefaultSwarm } from "./swarm-roles.js";
// ─── Swarm Dispatch ────────────────────────────────────────────────────────
export { SwarmDispatchLayer, swarmDispatch } from "./swarm-dispatch.js";
// ─── Metrics ─────────────────────────────────────────────────────────────── // ─── Metrics ───────────────────────────────────────────────────────────────
export { export {
buildMetricsText, buildMetricsText,

View file

@ -0,0 +1,364 @@
/**
* PersistentAgent Persistent named agent with durable inbox, 3-tier memory, and UOK integration.
*
* Purpose: provide persistent agents as first-class citizens in the SF
* swarm system. Each agent has a stable identity that survives restarts, a SQLite-backed
* inbox, and three memory tiers (core blocks, recall, archival).
*
* Consumer: AgentSwarm orchestrator, swarm role agents (CoordinatorAgent, WorkerAgent etc),
* and direct use in multi-agent dispatch flows.
*/
import { randomUUID } from "node:crypto";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { getDatabase, openDatabase } from "../sf-db.js";
import { sfRoot } from "../paths.js";
import { UokCoordinationStore } from "./coordination-store.js";
import { MessageBus } from "./message-bus.js";
const VALID_ROLES = ["coordinator", "worker", "scout", "reviewer", "adversary", "architect"];
const DEFAULT_ROLE = "worker";
const DEFAULT_MAX_INBOX_SIZE = 200;
const DEFAULT_REFRESH_INTERVAL_MS = 30_000;
const DEFAULT_SEND_AND_WAIT_TIMEOUT_MS = 30_000;
const DEFAULT_SEND_AND_WAIT_POLL_INTERVAL_MS = 500;
function ensureDbOpen(basePath) {
const dir = sfRoot(basePath);
mkdirSync(dir, { recursive: true });
const dbPath = join(dir, "sf.db");
if (!getDatabase()) {
openDatabase(dbPath);
}
const db = getDatabase();
if (!db) throw new Error(`PersistentAgent: failed to open database at ${dbPath}`);
return db;
}
/**
* Persistent named agent with durable inbox, 3-tier memory, and UOK integration.
*
* Purpose: first-class swarm citizen with Letta-style identity, memory, and messaging.
* Survives restarts via SQLite-backed identity and CoordinationStore memory.
*
* Consumer: AgentSwarm orchestrator and role agents (coordinator, worker, scout, etc).
*/
export class PersistentAgent {
/**
* Create or resume a named agent.
*
* Purpose: establish stable agent identity on first init and reload it on
* subsequent runs, so agent state (memory blocks, archival, tags) persists
* across process restarts without re-registration.
*
* Consumer: AgentSwarm when spawning or resuming swarm role agents.
*
* @param {string} basePath - project root used to locate `.sf/sf.db`
* @param {object} options
* @param {string} options.name - stable human-readable routing key (required)
* @param {string} [options.role='worker'] - agent role
* @param {string[]} [options.tags=[]] - routing metadata tags
* @param {number} [options.maxInboxSize=200]
* @param {number} [options.refreshIntervalMs=30000]
*/
constructor(basePath, options = {}) {
const { name, role = DEFAULT_ROLE, tags = [], maxInboxSize = DEFAULT_MAX_INBOX_SIZE, refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS } = options;
if (!name) throw new Error("PersistentAgent: options.name is required");
if (!VALID_ROLES.includes(role)) throw new Error(`PersistentAgent: invalid role "${role}". Must be one of: ${VALID_ROLES.join(", ")}`);
this._basePath = basePath;
this._name = name;
this._maxInboxSize = maxInboxSize;
this._refreshIntervalMs = refreshIntervalMs;
const db = ensureDbOpen(basePath);
this._store = new UokCoordinationStore(db);
this._bus = new MessageBus(basePath, { maxInboxSize, refreshIntervalMs });
this._identity = this._loadOrCreateIdentity({ name, role, tags });
this._inbox = this._bus.getInbox(`agent:${name}`);
}
// ─── Identity ─────────────────────────────────────────────────────────────
/**
* Returns the agent's stable identity record.
*
* Purpose: provide callers a single authoritative snapshot of who this agent
* is (id, name, role, tags) without exposing mutable internals.
*
* Consumer: AgentSwarm roster queries and routing logic.
*/
get identity() {
return { ...this._identity };
}
/**
* Returns the agent's durable inbox instance.
*
* Purpose: expose the AgentInbox so callers can subscribe or integrate
* with the inbox directly when the messaging helpers are insufficient.
*
* Consumer: AgentSwarm orchestrator and advanced swarm coordination flows.
*/
get inbox() {
return this._inbox;
}
_identityKey() {
return `agent:${this._name}:identity`;
}
_loadOrCreateIdentity({ name, role, tags }) {
const existing = this._store.get(this._identityKey());
if (existing) return existing;
const identity = {
agentId: `agent-${randomUUID()}`,
name,
role,
tags,
createdAt: new Date().toISOString(),
};
this._store.set(this._identityKey(), identity);
return identity;
}
// ─── Core Blocks (in-context memory) ──────────────────────────────────────
/**
* Set a named core memory block.
*
* Purpose: persist agent in-context slabs (persona, human profile, system
* state) so they survive restarts and can be injected into prompts via
* getContextBlocks().
*
* Consumer: agent setup code and AgentSwarm role initialization.
*
* @param {string} label - block label (e.g. "persona", "human")
* @param {*} value - serializable block content
* @param {number} [ttlMs] - optional TTL in milliseconds
*/
setCoreBlock(label, value, ttlMs) {
const key = `agent:${this._name}:block:${label}`;
this._store.set(key, value, ttlMs != null ? { ttlMs } : undefined);
}
/**
* Retrieve a named core memory block.
*
* Purpose: load agent in-context state for prompt injection or decision logic.
*
* Consumer: getContextBlocks and agent reasoning loops.
*
* @param {string} label
* @returns {*} stored value or null if absent/expired
*/
getCoreBlock(label) {
return this._store.get(`agent:${this._name}:block:${label}`);
}
/**
* Return all core blocks as a formatted markdown string for prompt injection.
*
* Purpose: produce a ready-to-inject prompt section from all non-expired
* core memory blocks so LLM callers don't need to know the block schema.
*
* Consumer: prompt builders in AgentSwarm dispatch and task execution flows.
*
* @returns {string} formatted markdown section
*/
getContextBlocks() {
const prefix = `agent:${this._name}:block:`;
const entries = this._store.entries(prefix);
if (entries.length === 0) return "";
const lines = ["## Agent Memory Blocks", ""];
for (const { key, value } of entries) {
const label = key.slice(prefix.length);
const rendered = typeof value === "string" ? value : JSON.stringify(value);
lines.push(`**${label}**: ${rendered}`);
}
return lines.join("\n");
}
// ─── Archival Memory (long-term KV) ───────────────────────────────────────
/**
* Store a value in archival memory.
*
* Purpose: persist long-term agent knowledge (learned facts, task history,
* resource pointers) that outlives individual task runs.
*
* Consumer: agent reasoning loops when surfacing durable conclusions.
*
* @param {string} key
* @param {*} value
* @param {number} [ttlMs] - optional TTL in milliseconds
*/
remember(key, value, ttlMs) {
const storeKey = `agent:${this._name}:archive:${key}`;
this._store.set(storeKey, value, ttlMs != null ? { ttlMs } : undefined);
}
/**
* Retrieve a value from archival memory.
*
* Purpose: load previously stored long-term knowledge for decision or context.
*
* Consumer: agent reasoning loops and task context assembly.
*
* @param {string} key
* @returns {*} stored value or null if absent/expired
*/
recall(key) {
return this._store.get(`agent:${this._name}:archive:${key}`);
}
/**
* Delete a value from archival memory.
*
* Purpose: remove stale or invalidated archival knowledge to keep agent
* context clean and prevent outdated facts from contaminating reasoning.
*
* Consumer: agent cleanup routines and explicit memory invalidation paths.
*
* @param {string} key
*/
forget(key) {
this._store.delete(`agent:${this._name}:archive:${key}`);
}
// ─── Recall (conversation history) ────────────────────────────────────────
/**
* Return the last N inbox messages in chronological order.
*
* Purpose: provide agents with recent conversation context so they can
* reason over message history without loading the full inbox.
*
* Consumer: agent prompt builders and response generation loops.
*
* @param {number} [limit=20]
* @returns {object[]} messages sorted oldest-first
*/
getRecall(limit = 20) {
const all = this._inbox.list(false);
return all.slice(-limit);
}
// ─── Messaging ────────────────────────────────────────────────────────────
/**
* Send a message to another agent by name.
*
* Purpose: route a message to a named agent's durable inbox so it is
* delivered even if the recipient is not currently running.
*
* Consumer: agent coordination flows and swarm task delegation.
*
* @param {string} toAgentName - recipient agent name (not agentId)
* @param {*} body
* @param {object} [metadata={}]
* @returns {string} message ID
*/
send(toAgentName, body, metadata = {}) {
return this._bus.send(
`agent:${this._name}`,
`agent:${toAgentName}`,
body,
metadata,
);
}
/**
* Send a message and wait for a reply on this agent's inbox.
*
* Purpose: implement synchronous request-reply across agent boundaries
* without coupling sender and receiver to a shared call stack. The reply
* is identified by metadata.replyTo matching the sent message ID.
*
* Consumer: coordinator-to-worker task delegation where a result is
* needed before the coordinator can proceed.
*
* @param {string} toAgentName
* @param {*} body
* @param {object} [opts]
* @param {number} [opts.timeoutMs=30000]
* @param {number} [opts.pollIntervalMs=500]
* @returns {Promise<*>} reply body
*/
sendAndWait(toAgentName, body, opts = {}) {
const { timeoutMs = DEFAULT_SEND_AND_WAIT_TIMEOUT_MS, pollIntervalMs = DEFAULT_SEND_AND_WAIT_POLL_INTERVAL_MS } = opts;
const sentId = this.send(toAgentName, body, opts.metadata ?? {});
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeoutMs;
const interval = setInterval(() => {
this._inbox.refresh();
const messages = this._inbox.list(false);
const reply = messages.find(
(m) => m.metadata?.replyTo === sentId,
);
if (reply) {
clearInterval(interval);
resolve(reply.body);
return;
}
if (Date.now() >= deadline) {
clearInterval(interval);
reject(new Error(`PersistentAgent.sendAndWait: timeout after ${timeoutMs}ms waiting for reply to ${sentId}`));
}
}, pollIntervalMs);
});
}
/**
* Receive messages from this agent's inbox.
*
* Purpose: drain incoming messages for agent processing, with an option to
* filter to unread-only to avoid reprocessing.
*
* Consumer: agent polling loops and event-driven dispatch handlers.
*
* @param {boolean} [unreadOnly=true]
* @returns {object[]}
*/
receive(unreadOnly = true) {
return this._inbox.list(unreadOnly);
}
/**
* Mark a message as read in this agent's inbox.
*
* Purpose: advance the inbox read cursor so subsequent receive(true) calls
* do not re-deliver already-processed messages.
*
* Consumer: agent processing loops after handling each message.
*
* @param {string} messageId
*/
markRead(messageId) {
this._inbox.markRead(messageId);
}
/**
* Broadcast a message to multiple agents by name.
*
* Purpose: fan-out a notification or task to a set of agents in one call
* without the caller managing individual send loops.
*
* Consumer: coordinator agents distributing work to worker pools.
*
* @param {*} body
* @param {object} metadata
* @param {string[]} recipientNames - agent names (not IDs)
* @returns {string[]} message IDs
*/
broadcast(body, metadata, recipientNames) {
return this._bus.broadcast(
`agent:${this._name}`,
recipientNames.map((n) => `agent:${n}`),
body,
metadata,
);
}
}

View file

@ -0,0 +1,201 @@
/**
* swarm-dispatch.js SwarmDispatchLayer routing UOK DispatchEnvelopes through the agent swarm.
*
* Purpose: bridge the UOK dispatch model (envelopes with unitType, workMode, unitId) to the
* swarm agent topology, routing each envelope to the appropriate role agent and returning
* a structured result. Provides cached swarm access so the same AgentSwarm instance is
* reused across dispatch calls within a process.
*
* Consumer: UOK kernel dispatch path, parallel orchestrators, and /sf autonomous controller
* when SF_A2A_ENABLED is set.
*/
import { AgentSwarm } from "./agent-swarm.js";
import { MessageBus } from "./message-bus.js";
import { createDefaultSwarm } from "./swarm-roles.js";
// Module-level cache keyed by `${basePath}:${swarmName}`
const _cache = new Map();
/**
* @typedef {object} DispatchEnvelope
* @property {string} unitId
* @property {string} unitType - 'task' | 'slice' | 'milestone' | 'eval' | 'research'
* @property {string} workMode - 'build' | 'repair' | 'review' | 'research' | 'coordinate'
* @property {object} payload - arbitrary task data
* @property {number} priority - 0-10
* @property {string} scope - project scope string
*/
/**
* @typedef {object} DispatchResult
* @property {string} messageId
* @property {string} targetAgent
* @property {string} swarmName
* @property {DispatchEnvelope} envelope
*/
/**
* SwarmDispatchLayer routes DispatchEnvelopes through the agent swarm topology.
*
* Purpose: bridge the UOK dispatch model to the swarm agent topology, routing each
* envelope to the appropriate role agent and returning a structured result.
*
* Consumer: UOK kernel dispatch path, parallel orchestrators, and /sf autonomous
* controller when SF_A2A_ENABLED is set.
*/
export class SwarmDispatchLayer {
/**
* @param {string} basePath - project root used to locate `.sf/sf.db`
* @param {object} [options={}]
* @param {string} [options.swarmName='default']
* @param {boolean} [options.autoCreate=true]
*/
constructor(basePath, options = {}) {
this._basePath = basePath;
this._swarmName = options.swarmName ?? "default";
this._autoCreate = options.autoCreate !== false;
/** @type {AgentSwarm | null} */
this._swarm = null;
this._bus = new MessageBus(basePath);
}
/**
* Return the cached AgentSwarm, loading or creating it as needed.
*
* Purpose: ensure a single AgentSwarm instance is reused across dispatch
* calls within a process to avoid redundant DB round-trips and agent
* re-registration on every envelope.
*
* Consumer: dispatch() and dispatchBatch() before routing envelopes.
*
* @returns {Promise<AgentSwarm>}
*/
async getOrCreateSwarm() {
if (this._swarm) return this._swarm;
let swarm = AgentSwarm.load(this._basePath, this._swarmName);
// load() returns a new empty swarm when no saved registry exists.
// If it has no agents and autoCreate is on, bootstrap a default topology.
if (swarm.getAll().length === 0 && this._autoCreate) {
const result = await createDefaultSwarm(this._basePath, {
swarmName: this._swarmName,
});
swarm = result.swarm;
}
this._swarm = swarm;
return this._swarm;
}
/**
* Route a single DispatchEnvelope to the appropriate swarm agent.
*
* Purpose: select the right role agent for the envelope's workMode/unitType,
* deliver the payload to that agent's durable inbox, and return a structured
* result so callers can track message delivery without knowing the swarm topology.
*
* Consumer: UOK kernel dispatch path and swarmDispatch() convenience function.
*
* @param {DispatchEnvelope} envelope
* @returns {Promise<DispatchResult>}
*/
async dispatch(envelope) {
const swarm = await this.getOrCreateSwarm();
const target = swarm.route(envelope);
if (!target) {
throw new Error(
`SwarmDispatchLayer: no agent available to handle envelope unitType=${envelope.unitType} workMode=${envelope.workMode}`,
);
}
const from = `dispatch:${envelope.scope}:${envelope.unitId}`;
const to = `agent:${target.identity.name}`;
const metadata = {
unitId: envelope.unitId,
unitType: envelope.unitType,
workMode: envelope.workMode,
};
const messageId = this._bus.send(from, to, envelope.payload, metadata);
return {
messageId,
targetAgent: target.identity.name,
swarmName: this._swarmName,
envelope,
};
}
/**
* Route multiple DispatchEnvelopes through the swarm in parallel.
*
* Purpose: fan-out a batch of work units to the swarm concurrently so
* throughput scales with the number of envelopes rather than serializing them.
*
* Consumer: parallel orchestrators and autonomous dispatch batching loops.
*
* @param {DispatchEnvelope[]} envelopes
* @returns {Promise<DispatchResult[]>}
*/
async dispatchBatch(envelopes) {
return Promise.all(envelopes.map((envelope) => this.dispatch(envelope)));
}
/**
* Return a structured view of the swarm topology by role.
*
* Purpose: expose the swarm role snapshot so callers can inspect routing
* capacity without reaching into the AgentSwarm directly.
*
* Consumer: diagnostic tooling, health checks, and /sf swarm status.
*
* @returns {Promise<{ coordinator: import('./persistent-agent.js').PersistentAgent[], workers: import('./persistent-agent.js').PersistentAgent[], scouts: import('./persistent-agent.js').PersistentAgent[], reviewers: import('./persistent-agent.js').PersistentAgent[], all: import('./persistent-agent.js').PersistentAgent[] }>}
*/
async getTopology() {
const swarm = await this.getOrCreateSwarm();
return swarm.getTopology();
}
/**
* Return or create a SwarmDispatchLayer for the given basePath and swarmName.
*
* Purpose: provide a process-level singleton factory so callers that don't
* manage a SwarmDispatchLayer instance directly share the same cached swarm
* and avoid redundant initialization on repeated dispatch calls.
*
* Consumer: swarmDispatch() convenience function and any caller that wants
* a default dispatch layer without explicit lifecycle management.
*
* @param {string} basePath
* @param {object} [options={}]
* @param {string} [options.swarmName='default']
* @param {boolean} [options.autoCreate=true]
* @returns {SwarmDispatchLayer}
*/
static getOrCreate(basePath, options = {}) {
const key = `${basePath}:${options.swarmName ?? "default"}`;
if (!_cache.has(key)) {
_cache.set(key, new SwarmDispatchLayer(basePath, options));
}
return _cache.get(key);
}
}
/**
* Route a single DispatchEnvelope through the default swarm for basePath.
*
* Purpose: one-liner entry point for dispatch callers that don't need to manage
* a SwarmDispatchLayer instance directly.
*
* Consumer: UOK kernel dispatch integration, test helpers.
*
* @param {string} basePath
* @param {DispatchEnvelope} envelope
* @returns {Promise<DispatchResult>}
*/
export async function swarmDispatch(basePath, envelope) {
return SwarmDispatchLayer.getOrCreate(basePath).dispatch(envelope);
}

View file

@ -0,0 +1,196 @@
/**
* Swarm Role Agents Purpose-built PersistentAgent subclasses for the default SF swarm topology.
*
* Purpose: provide ready-to-use agent roles (coordinator, worker, scout, reviewer) with
* preset tags and capabilities, and a factory function that assembles a complete default
* swarm topology persisted to SQLite.
*
* Consumer: SwarmDispatchLayer, /sf swarm command, and any code needing a default
* multi-agent topology without manually wiring individual agents.
*/
import { PersistentAgent } from "./persistent-agent.js";
import { AgentSwarm, ManagerType } from "./agent-swarm.js";
/**
* Coordinator agent owns routing and task dispatch across a worker pool.
*
* Purpose: provide a persistent orchestrator that routes incoming work to
* the right worker based on message workMode, accumulates results, and
* maintains swarm-level state in its core blocks.
*
* Consumer: createDefaultSwarm, SwarmDispatchLayer, and /sf swarm command.
*/
export class CoordinatorAgent extends PersistentAgent {
/**
* @param {string} basePath
* @param {object} [options={}]
* @param {string} [options.name='coordinator']
* @param {string[]} [options.tags=[]]
*/
constructor(basePath, options = {}) {
super(basePath, {
name: options.name ?? "coordinator",
role: "coordinator",
tags: ["role:coordinator", "tier:persistent", ...(options.tags ?? [])],
...options,
});
}
/**
* Route an incoming message body to the right worker by reading its workMode field.
*
* Purpose: select a suitable worker from the swarm roster and forward the
* message so the coordinator does not block on task execution. Selection
* prefers agents tagged with `workMode:<value>`, falling back to any
* available worker-tier agent.
*
* Consumer: autonomous dispatch loops and /sf swarm run command.
*
* @param {AgentSwarm} swarm - the swarm containing candidate workers
* @param {object} messageBody - message body; `messageBody.workMode` guides selection
* @param {object} [metadata={}] - forwarded as message metadata
* @returns {{ targetAgent: PersistentAgent, messageId: string } | null}
*/
routeToWorker(swarm, messageBody, metadata = {}) {
const { workMode } = messageBody ?? {};
const candidates = swarm.agents.filter((a) => {
const id = a.identity;
if (id.name === this._name) return false;
if (!id.tags.includes("tier:worker")) return false;
if (workMode && id.tags.includes(`workMode:${workMode}`)) return true;
return !workMode;
});
// Prefer specialised match; fall back to first available worker.
const specialised = workMode
? candidates.filter((a) => a.identity.tags.includes(`workMode:${workMode}`))
: [];
const target = specialised[0] ?? candidates[0] ?? null;
if (!target) return null;
const messageId = this.send(target.identity.name, messageBody, metadata);
return { targetAgent: target, messageId };
}
}
/**
* Worker agent executes assigned tasks in the swarm.
*
* Purpose: provide a general-purpose execution agent that accepts task
* messages from a coordinator and runs them to completion.
*
* Consumer: createDefaultSwarm worker pool and direct task assignment.
*/
export class WorkerAgent extends PersistentAgent {
/**
* @param {string} basePath
* @param {object} [options={}]
* @param {string} [options.name='worker']
* @param {string[]} [options.tags=[]]
*/
constructor(basePath, options = {}) {
super(basePath, {
name: options.name ?? "worker",
role: "worker",
tags: ["role:worker", "tier:worker", ...(options.tags ?? [])],
...options,
});
}
}
/**
* Scout agent discovers and surfaces information for the swarm.
*
* Purpose: provide a dedicated research/discovery agent whose results feed
* into coordinator routing decisions and worker task context.
*
* Consumer: createDefaultSwarm and coordinator agents that need pre-fetched
* context before delegating execution tasks.
*/
export class ScoutAgent extends PersistentAgent {
/**
* @param {string} basePath
* @param {object} [options={}]
* @param {string} [options.name='scout']
* @param {string[]} [options.tags=[]]
*/
constructor(basePath, options = {}) {
super(basePath, {
name: options.name ?? "scout",
role: "scout",
tags: ["role:scout", "tier:worker", ...(options.tags ?? [])],
...options,
});
}
}
/**
* Reviewer agent validates and critiques worker output.
*
* Purpose: provide a dedicated quality-gate agent that receives completed
* task output from workers and either approves, requests revision, or
* escalates to the coordinator.
*
* Consumer: createDefaultSwarm and any dispatch flow that requires a
* review pass before accepting task output as final.
*/
export class ReviewerAgent extends PersistentAgent {
/**
* @param {string} basePath
* @param {object} [options={}]
* @param {string} [options.name='reviewer']
* @param {string[]} [options.tags=[]]
*/
constructor(basePath, options = {}) {
super(basePath, {
name: options.name ?? "reviewer",
role: "reviewer",
tags: ["role:reviewer", "tier:worker", ...(options.tags ?? [])],
...options,
});
}
}
/**
* Create a complete default swarm topology.
*
* Purpose: assemble the standard SF agent topology (1 coordinator + 2 workers +
* 1 scout + 1 reviewer) wired to a shared MessageBus, with the agent_directory
* core block set on all agents so each knows who else is in the swarm.
*
* Consumer: SwarmDispatchLayer.getOrCreateSwarm() and direct initialization in
* autonomous dispatch bootstrap.
*
* @param {string} basePath
* @param {object} [options={}]
* @param {string} [options.swarmName='default']
* @param {string} [options.managerType] - ManagerType value, default 'supervisor'
* @returns {Promise<{ swarm: AgentSwarm, coordinator: CoordinatorAgent, workers: WorkerAgent[], scout: ScoutAgent, reviewer: ReviewerAgent }>}
*/
export async function createDefaultSwarm(basePath, options = {}) {
const coordinator = new CoordinatorAgent(basePath, { name: "coordinator" });
const workers = [
new WorkerAgent(basePath, { name: "worker-1" }),
new WorkerAgent(basePath, { name: "worker-2" }),
];
const scout = new ScoutAgent(basePath, { name: "scout" });
const reviewer = new ReviewerAgent(basePath, { name: "reviewer" });
const swarm = new AgentSwarm(basePath, {
name: options.swarmName ?? "default",
managerType: options.managerType ?? ManagerType.SUPERVISOR,
});
swarm.register(coordinator);
for (const worker of workers) {
swarm.register(worker);
}
swarm.register(scout);
swarm.register(reviewer);
swarm.persist();
return { swarm, coordinator, workers, scout, reviewer };
}

View file

@ -2,6 +2,7 @@
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"incremental": false, "incremental": false,
"types": ["node"],
"rootDir": "src/resources", "rootDir": "src/resources",
"outDir": "dist/resources", "outDir": "dist/resources",
"declaration": false, "declaration": false,