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:
parent
efa3ce4492
commit
848ac0dd99
7 changed files with 1722 additions and 0 deletions
332
src/resources/extensions/sf/tests/swarm.test.mjs
Normal file
332
src/resources/extensions/sf/tests/swarm.test.mjs
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
620
src/resources/extensions/sf/uok/agent-swarm.js
Normal file
620
src/resources/extensions/sf/uok/agent-swarm.js
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
364
src/resources/extensions/sf/uok/persistent-agent.js
Normal file
364
src/resources/extensions/sf/uok/persistent-agent.js
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
201
src/resources/extensions/sf/uok/swarm-dispatch.js
Normal file
201
src/resources/extensions/sf/uok/swarm-dispatch.js
Normal 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);
|
||||||
|
}
|
||||||
196
src/resources/extensions/sf/uok/swarm-roles.js
Normal file
196
src/resources/extensions/sf/uok/swarm-roles.js
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue