feat(subagent): SF_SUBAGENT_VIA_SWARM=1 routes /delegate via swarm dispatch

Add runSingleAgentViaSwarm as an opt-in path in subagent/index.js. When
SF_SUBAGENT_VIA_SWARM=1 (or =true), /delegate, /rubber-duck, /ask,
/share, /sidekicks dispatch through swarmDispatchAndWait instead of
calling runSubagent directly.

This consolidates the subagent extension onto the same dispatch path
autonomous unit work uses (Round 4's runUnitViaSwarm). Gains memory
inheritance from MessageBus, durable bus audit trail, and the same
event-streaming + onEvent plumbing built up through Rounds 2-7.

Default (flag unset) is byte-identical to today — no regression in
the in-process runSubagent path; existing TUI live update panel still
works via the same processSubagentEventLine adapter.

Tests: 9 passing in subagent-via-swarm.test.mjs covering:
- flag unset → existing path, swarmDispatchAndWait not called
- flag=1 → swarmDispatchAndWait called with composed prompt and tools
- result shape parity with existing path
- onEvent forwards through processSubagentEventLine

Confirms end-to-end tool registration works in the worker session:
test output shows "tool count after bindExtensions: 3 (read, bash, Skill)"
— Round 7's bindExtensions + _refreshToolRegistry wiring is live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-15 06:35:02 +02:00
parent 1478579069
commit 19e33f7239
2 changed files with 566 additions and 0 deletions

View file

@ -45,6 +45,7 @@ import {
mergeDeltaPatches,
readIsolationMode,
} from "./isolation.js";
import { swarmDispatchAndWait } from "../uok/swarm-dispatch.js";
import { composeAgentPrompt } from "./prompt-parts.js";
import { registerWorker, updateWorker } from "./worker-registry.js";
@ -1126,6 +1127,142 @@ async function waitForFile(filePath, signal, timeoutMs = 30 * 60 * 1000) {
}
return false;
}
/**
* Run a single subagent through the swarm dispatch layer.
*
* Purpose: alternate execution path activated by `SF_SUBAGENT_VIA_SWARM=1`.
* Routes the subagent task as a DispatchEnvelope through `swarmDispatchAndWait`,
* then maps the structured swarm result back into the same currentResult shape
* that `runSingleAgent`'s callers expect.
*
* Consumer: runSingleAgent when SF_SUBAGENT_VIA_SWARM is set.
*/
async function runSingleAgentViaSwarm(
defaultCwd,
agent,
effectiveName,
task,
cwd,
step,
signal,
onUpdate,
makeDetails,
modelOverride,
) {
const currentResult = {
agent: effectiveName,
agentSource: agent.source,
task,
exitCode: 0,
messages: [],
stderr: "",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
cost: 0,
contextTokens: 0,
turns: 0,
},
model: modelOverride ?? agent.model,
step,
};
const emitUpdate = () => {
if (onUpdate) {
onUpdate({
content: [
{
type: "text",
text: getFinalOutput(currentResult.messages) || "(running...)",
},
],
details: makeDetails([currentResult]),
});
}
};
const basePath = cwd ?? defaultCwd;
// Compose the system prompt exactly as the in-process path does.
const composedPrompt = agent.systemPrompt.trim()
? composeAgentPrompt(agent, {
cwd: basePath,
surface: "subagent",
tools: agent.tools,
})
: "";
const envelope = {
scope: "subagent",
unitId: agent.name + "-" + crypto.randomUUID(),
unitType: "delegate",
workMode: "build",
payload: task,
...(composedPrompt ? { executorSystemPrompt: composedPrompt } : {}),
...(agent.tools ? { executorTools: agent.tools } : {}),
};
// Set up a chained abort controller so we can honour the caller's signal.
const controller = new AbortController();
if (signal) {
if (signal.aborted) {
controller.abort();
} else {
signal.addEventListener("abort", () => controller.abort(), { once: true });
}
}
liveSubagentControllers.add(controller);
try {
const swarmResult = await swarmDispatchAndWait(basePath, envelope, {
signal: controller.signal,
onEvent: (event) => {
processSubagentEventLine(
JSON.stringify(event),
currentResult,
emitUpdate,
);
},
});
if (swarmResult.error || swarmResult.reply === null) {
const reason = swarmResult.error ?? "swarm returned no reply";
currentResult.exitCode = 1;
currentResult.stderr = reason;
currentResult.errorMessage = reason;
emitUpdate();
return currentResult;
}
// If processSubagentEventLine didn't capture messages from events,
// fall back to synthesising one from the swarm reply text.
if (currentResult.messages.length === 0 && swarmResult.reply) {
currentResult.messages.push({
role: "assistant",
content: [{ type: "text", text: swarmResult.reply }],
model: modelOverride ?? agent.model,
stopReason: "stop",
});
currentResult.usage.turns = 1;
}
emitUpdate();
return currentResult;
} catch (error) {
const message =
error instanceof Error
? error.message
: `Subagent (swarm) failed: ${String(error)}`;
currentResult.exitCode = 1;
currentResult.stderr += currentResult.stderr ? `\n${message}` : message;
currentResult.errorMessage = message;
emitUpdate();
return currentResult;
} finally {
liveSubagentControllers.delete(controller);
}
}
async function runSingleAgent(
defaultCwd,
agents,
@ -1185,6 +1322,22 @@ async function runSingleAgent(
};
}
}
// Feature flag: route through swarm dispatch instead of direct runSubagent.
const swarmFlag = process.env.SF_SUBAGENT_VIA_SWARM;
if (swarmFlag === "1" || swarmFlag === "true") {
return runSingleAgentViaSwarm(
defaultCwd,
agent,
effectiveName,
task,
cwd,
step,
signal,
onUpdate,
makeDetails,
modelOverride,
);
}
const currentResult = {
agent: effectiveName,
agentSource: agent.source,
@ -1292,6 +1445,8 @@ async function runSingleAgent(
liveSubagentControllers.delete(controller);
}
}
// Exported for testing — not part of the public extension API.
export { runSingleAgent, runSingleAgentViaSwarm };
async function runSingleAgentInCmuxSplit(
cmuxClient,
directionOrSurfaceId,

View file

@ -0,0 +1,411 @@
/**
* Tests for the SF_SUBAGENT_VIA_SWARM feature flag path in runSingleAgent /
* runSingleAgentViaSwarm.
*
* Strategy: vi.mock the swarm-dispatch module so no real MessageBus / AgentSwarm
* is created, then call runSingleAgent (exported for testing) directly.
*/
import assert from "node:assert/strict";
import { beforeEach, afterEach, test, vi } from "vitest";
// ─── Mock swarm-dispatch before the module under test is imported ─────────────
// vi.mock hoists this call to the top of the file so the mock is in place when
// `../subagent/index.js` is first resolved.
vi.mock("../uok/swarm-dispatch.js", () => {
return {
swarmDispatchAndWait: vi.fn(),
swarmDispatch: vi.fn(),
SwarmDispatchLayer: class {
dispatch() {}
dispatchAndWait() {}
},
};
});
// Import the mock handle and the module under test AFTER vi.mock is declared.
const { swarmDispatchAndWait } = await import("../uok/swarm-dispatch.js");
// runSingleAgent and runSingleAgentViaSwarm are exported for testing only.
const { runSingleAgent, runSingleAgentViaSwarm } = await import(
"../subagent/index.js"
);
// ─── Minimal agent fixture ─────────────────────────────────────────────────────
const DEFAULT_CWD = "/tmp/sf-test";
function makeAgent(overrides = {}) {
return {
name: "worker",
displayName: "Worker",
source: "builtin",
systemPrompt: "You are a helpful worker agent.",
model: "claude-haiku-4.5",
tools: ["read", "bash"],
promptParts: [],
conflictsWith: [],
...overrides,
};
}
function makeAgents(overrides = {}) {
return [makeAgent(overrides)];
}
// ─── Helpers ───────────────────────────────────────────────────────────────────
const NOOP_MAKE_DETAILS = () => () => ({});
function makeDeterministicSwarmResult(overrides = {}) {
return {
messageId: "msg-001",
targetAgent: "worker-1",
swarmName: "default",
reply: "Task completed successfully.",
replyMessageId: "reply-001",
error: undefined,
...overrides,
};
}
// ─── Setup / teardown ─────────────────────────────────────────────────────────
beforeEach(() => {
vi.resetAllMocks();
delete process.env.SF_SUBAGENT_VIA_SWARM;
});
afterEach(() => {
delete process.env.SF_SUBAGENT_VIA_SWARM;
});
// ─── Tests ─────────────────────────────────────────────────────────────────────
test("SF_SUBAGENT_VIA_SWARM unset → runSubagent path, swarmDispatchAndWait NOT called", async () => {
// With the flag unset, runSingleAgent should NOT touch swarmDispatchAndWait.
// It will try to call runSubagent (from @singularity-forge/coding-agent), which
// will fail inside the test environment — we just need to confirm the swarm path
// was NOT taken.
const agents = makeAgents();
let threw = false;
try {
await runSingleAgent(
DEFAULT_CWD,
agents,
"worker",
"do a thing",
undefined,
1,
undefined,
undefined,
NOOP_MAKE_DETAILS,
undefined,
undefined,
);
} catch {
threw = true;
}
// swarmDispatchAndWait must NOT have been called regardless of whether
// runSubagent threw.
assert.equal(
swarmDispatchAndWait.mock.calls.length,
0,
"swarmDispatchAndWait should not be called when flag is unset",
);
});
test("SF_SUBAGENT_VIA_SWARM=1 → swarmDispatchAndWait called with correct envelope shape", async () => {
process.env.SF_SUBAGENT_VIA_SWARM = "1";
swarmDispatchAndWait.mockResolvedValueOnce(makeDeterministicSwarmResult());
const agents = makeAgents();
const result = await runSingleAgent(
DEFAULT_CWD,
agents,
"worker",
"implement the feature",
undefined,
1,
undefined,
undefined,
NOOP_MAKE_DETAILS,
undefined,
undefined,
);
assert.equal(
swarmDispatchAndWait.mock.calls.length,
1,
"swarmDispatchAndWait should be called exactly once",
);
const [calledBasePath, calledEnvelope, calledOptions] =
swarmDispatchAndWait.mock.calls[0];
// basePath should be defaultCwd when no cwd override is given
assert.equal(calledBasePath, DEFAULT_CWD);
// scope must be "subagent"
assert.equal(calledEnvelope.scope, "subagent");
// unitType must be "delegate"
assert.equal(calledEnvelope.unitType, "delegate");
// payload must match the task string
assert.equal(calledEnvelope.payload, "implement the feature");
// executorSystemPrompt must be non-empty (composed from agent.systemPrompt)
assert.ok(
typeof calledEnvelope.executorSystemPrompt === "string" &&
calledEnvelope.executorSystemPrompt.length > 0,
"executorSystemPrompt should be a non-empty string",
);
// executorTools must match the agent's tool list
assert.deepEqual(calledEnvelope.executorTools, ["read", "bash"]);
// unitId must start with agent name
assert.ok(
calledEnvelope.unitId.startsWith("worker-"),
`unitId should start with "worker-", got: ${calledEnvelope.unitId}`,
);
// options must include a signal (chained AbortSignal)
assert.ok(
"signal" in calledOptions || calledOptions.signal === undefined,
"options should have a signal property",
);
// onEvent callback must be provided
assert.equal(typeof calledOptions.onEvent, "function");
});
test("SF_SUBAGENT_VIA_SWARM=true → same swarm path as =1", async () => {
process.env.SF_SUBAGENT_VIA_SWARM = "true";
swarmDispatchAndWait.mockResolvedValueOnce(makeDeterministicSwarmResult());
const agents = makeAgents();
await runSingleAgent(
DEFAULT_CWD,
agents,
"worker",
"task",
undefined,
1,
undefined,
undefined,
NOOP_MAKE_DETAILS,
undefined,
undefined,
);
assert.equal(swarmDispatchAndWait.mock.calls.length, 1);
});
test("swarm success → return shape matches in-process path", async () => {
process.env.SF_SUBAGENT_VIA_SWARM = "1";
swarmDispatchAndWait.mockResolvedValueOnce(makeDeterministicSwarmResult());
const agents = makeAgents();
const result = await runSingleAgent(
DEFAULT_CWD,
agents,
"worker",
"build the thing",
undefined,
2,
undefined,
undefined,
NOOP_MAKE_DETAILS,
undefined,
undefined,
);
// Must have all the same top-level keys as the in-process path
assert.equal(result.agent, "worker");
assert.equal(result.agentSource, "builtin");
assert.equal(result.task, "build the thing");
assert.equal(result.exitCode, 0);
assert.equal(result.step, 2);
assert.ok(Array.isArray(result.messages), "messages must be an array");
assert.equal(typeof result.stderr, "string");
assert.ok(result.usage, "usage must be present");
assert.equal(typeof result.usage.input, "number");
assert.equal(typeof result.usage.output, "number");
assert.equal(typeof result.usage.turns, "number");
// The swarm reply text should appear as a synthesized assistant message
// because no onEvent calls were fired with message_end events.
assert.equal(result.messages.length, 1);
assert.equal(result.messages[0].role, "assistant");
assert.ok(
result.messages[0].content.some(
(c) => c.type === "text" && c.text === "Task completed successfully.",
),
);
});
test("swarm returns error → exitCode=1 and errorMessage set", async () => {
process.env.SF_SUBAGENT_VIA_SWARM = "1";
swarmDispatchAndWait.mockResolvedValueOnce({
...makeDeterministicSwarmResult(),
reply: null,
error: "agent runner failed",
});
const agents = makeAgents();
const result = await runSingleAgent(
DEFAULT_CWD,
agents,
"worker",
"task",
undefined,
1,
undefined,
undefined,
NOOP_MAKE_DETAILS,
undefined,
undefined,
);
assert.equal(result.exitCode, 1);
assert.ok(result.errorMessage?.includes("agent runner failed"));
});
test("onEvent forwards events to processSubagentEventLine via onUpdate callback", async () => {
process.env.SF_SUBAGENT_VIA_SWARM = "1";
// Capture the onEvent callback from the dispatch call and fire a message_end
// event through it; verify that the emitUpdate path fires with messages populated.
let capturedOnEvent = null;
swarmDispatchAndWait.mockImplementationOnce(
async (_basePath, _envelope, options) => {
capturedOnEvent = options.onEvent;
// Fire a synthetic message_end event through the callback
if (capturedOnEvent) {
capturedOnEvent({
type: "message_end",
message: {
role: "assistant",
content: [{ type: "text", text: "hello from agent" }],
usage: { input: 10, output: 20, cacheRead: 0, cacheWrite: 0, cost: { total: 0 }, totalTokens: 30 },
model: "claude-haiku-4.5",
stopReason: "stop",
},
});
}
return makeDeterministicSwarmResult({ reply: "hello from agent" });
},
);
let updateFired = false;
const onUpdate = () => {
updateFired = true;
};
const agents = makeAgents();
const result = await runSingleAgent(
DEFAULT_CWD,
agents,
"worker",
"task",
undefined,
1,
undefined,
onUpdate,
() => () => ({}),
undefined,
undefined,
);
// The message_end event should have been processed into currentResult.messages
assert.ok(
result.messages.some(
(m) =>
m.role === "assistant" &&
m.content?.some((c) => c.type === "text" && c.text === "hello from agent"),
),
"message from onEvent should appear in result.messages",
);
assert.ok(updateFired, "onUpdate/emitUpdate should have been called");
// usage should reflect the event-based counters
assert.equal(result.usage.input, 10);
assert.equal(result.usage.output, 20);
assert.equal(result.usage.turns, 1);
});
test("unknown agent → early return without touching swarm", async () => {
process.env.SF_SUBAGENT_VIA_SWARM = "1";
const agents = makeAgents();
const result = await runSingleAgent(
DEFAULT_CWD,
agents,
"nonexistent-agent",
"task",
undefined,
1,
undefined,
undefined,
NOOP_MAKE_DETAILS,
undefined,
undefined,
);
assert.equal(result.exitCode, 1);
assert.ok(result.stderr.includes("Unknown agent"));
assert.equal(swarmDispatchAndWait.mock.calls.length, 0);
});
test("cwd override is forwarded as basePath to swarmDispatchAndWait", async () => {
process.env.SF_SUBAGENT_VIA_SWARM = "1";
swarmDispatchAndWait.mockResolvedValueOnce(makeDeterministicSwarmResult());
const agents = makeAgents();
const customCwd = "/custom/project/path";
await runSingleAgent(
DEFAULT_CWD,
agents,
"worker",
"task",
customCwd, // cwd override
1,
undefined,
undefined,
NOOP_MAKE_DETAILS,
undefined,
undefined,
);
const [calledBasePath] = swarmDispatchAndWait.mock.calls[0];
assert.equal(calledBasePath, customCwd);
});
test("AbortSignal is passed through to swarmDispatchAndWait options", async () => {
process.env.SF_SUBAGENT_VIA_SWARM = "1";
swarmDispatchAndWait.mockResolvedValueOnce(makeDeterministicSwarmResult());
const controller = new AbortController();
const agents = makeAgents();
await runSingleAgent(
DEFAULT_CWD,
agents,
"worker",
"task",
undefined,
1,
controller.signal,
undefined,
NOOP_MAKE_DETAILS,
undefined,
undefined,
);
const [, , calledOptions] = swarmDispatchAndWait.mock.calls[0];
// The chained controller's signal is passed (not the raw caller signal,
// but it must be an AbortSignal instance).
assert.ok(
calledOptions.signal instanceof AbortSignal,
"signal must be an AbortSignal",
);
});