From 848ac0dd99595962fa4db8de3ed80982c07c31de Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 9 May 2026 04:04:42 +0200 Subject: [PATCH] feat(swarm): UOK-based swarm with PersistentAgent, AgentSwarm, and SwarmDispatchLayer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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> --- .../extensions/sf/tests/swarm.test.mjs | 332 ++++++++++ .../extensions/sf/uok/agent-swarm.js | 620 ++++++++++++++++++ src/resources/extensions/sf/uok/index.js | 8 + .../extensions/sf/uok/persistent-agent.js | 364 ++++++++++ .../extensions/sf/uok/swarm-dispatch.js | 201 ++++++ .../extensions/sf/uok/swarm-roles.js | 196 ++++++ tsconfig.resources.json | 1 + 7 files changed, 1722 insertions(+) create mode 100644 src/resources/extensions/sf/tests/swarm.test.mjs create mode 100644 src/resources/extensions/sf/uok/agent-swarm.js create mode 100644 src/resources/extensions/sf/uok/persistent-agent.js create mode 100644 src/resources/extensions/sf/uok/swarm-dispatch.js create mode 100644 src/resources/extensions/sf/uok/swarm-roles.js diff --git a/src/resources/extensions/sf/tests/swarm.test.mjs b/src/resources/extensions/sf/tests/swarm.test.mjs new file mode 100644 index 000000000..909f98575 --- /dev/null +++ b/src/resources/extensions/sf/tests/swarm.test.mjs @@ -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(); + } + }); +}); diff --git a/src/resources/extensions/sf/uok/agent-swarm.js b/src/resources/extensions/sf/uok/agent-swarm.js new file mode 100644 index 000000000..51abb5bf3 --- /dev/null +++ b/src/resources/extensions/sf/uok/agent-swarm.js @@ -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} */ + 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); +} diff --git a/src/resources/extensions/sf/uok/index.js b/src/resources/extensions/sf/uok/index.js index f5a26ac3c..906e65572 100644 --- a/src/resources/extensions/sf/uok/index.js +++ b/src/resources/extensions/sf/uok/index.js @@ -91,8 +91,16 @@ export { export { recordUokKernelTermination, runAutoLoopWithUok } from "./kernel.js"; // ─── Loop Adapter ────────────────────────────────────────────────────────── 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 ─────────────────────────────────────────────────────────── 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 ─────────────────────────────────────────────────────────────── export { buildMetricsText, diff --git a/src/resources/extensions/sf/uok/persistent-agent.js b/src/resources/extensions/sf/uok/persistent-agent.js new file mode 100644 index 000000000..3c99b7657 --- /dev/null +++ b/src/resources/extensions/sf/uok/persistent-agent.js @@ -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, + ); + } +} diff --git a/src/resources/extensions/sf/uok/swarm-dispatch.js b/src/resources/extensions/sf/uok/swarm-dispatch.js new file mode 100644 index 000000000..5301842b1 --- /dev/null +++ b/src/resources/extensions/sf/uok/swarm-dispatch.js @@ -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} + */ + 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} + */ + 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} + */ + 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} + */ +export async function swarmDispatch(basePath, envelope) { + return SwarmDispatchLayer.getOrCreate(basePath).dispatch(envelope); +} diff --git a/src/resources/extensions/sf/uok/swarm-roles.js b/src/resources/extensions/sf/uok/swarm-roles.js new file mode 100644 index 000000000..88d331ceb --- /dev/null +++ b/src/resources/extensions/sf/uok/swarm-roles.js @@ -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:`, 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 }; +} diff --git a/tsconfig.resources.json b/tsconfig.resources.json index cc63b3948..93f103ae4 100644 --- a/tsconfig.resources.json +++ b/tsconfig.resources.json @@ -2,6 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "incremental": false, + "types": ["node"], "rootDir": "src/resources", "outDir": "dist/resources", "declaration": false,