feat(swarm): add QuickJS guest orchestration (#111298)

* feat(swarm): add QuickJS guest orchestration (#110325)

* fix(agents): turn-stable swarm replay identity — distinct exec invocations cannot collide

* fix(agents): turn-stable swarm replay identity — distinct exec invocations cannot collide

* fix(agents): turn-stable swarm replay identity — distinct exec invocations cannot collide
This commit is contained in:
Peter Steinberger
2026-07-19 03:58:59 -07:00
committed by GitHub
parent bbcfec9e96
commit 104691f822
28 changed files with 1848 additions and 161 deletions
@@ -145,7 +145,7 @@ c1826ee55763f9c1f86e873cda221bdf32122fce5f5a5a46c384521a8ec8bf6c module/infra-r
ee34dd840075bd687624ead228d3aa906fc52965e1708a5a335bb3ad3b671949 module/json-unsafe-integers
8f37bca66178f4d77303fdd3ded0d445c9358b4f094c07a2f8e32b1721e953e4 module/keyed-async-queue
d8ca27a737f235e09f1a9f8cd4b82ac0cde96f10c662347b6ac82e27b70b4a40 module/lazy-runtime
11fc218065ff47d3b2c0a2ca36ee66a1effac4e24e2981c9859f17b7b4cb82ae module/llm
8728059798af7b0912bb9369c7bc75472035d61c943fa558db94a52c45767fff module/llm
6ae9b0f1f55ec50fa3e0ebc02052bfd04eca5f34c4a936692634b36a4cfa2035 module/lmstudio
dd166527916dc5b9694512e3526e774da0b2427ba7fc8316d2fd799c06238e80 module/lmstudio-runtime
fd00374a91ab5b393152bbbaf90010f9140cf15827bde360d0c6c8724ed34e2e module/logging-core
@@ -12,6 +12,10 @@ import {
type Message,
type Model,
} from "./llm.js";
import {
getAgentToolExecutionContext,
type AgentToolExecutionContext,
} from "./tool-execution-context.js";
import type {
AgentContext,
AgentEvent,
@@ -978,6 +982,57 @@ describe("agentLoop tool termination", () => {
};
}
it("persists and passes a local turn id when the provider omits one", async () => {
let turn = 0;
const toolCall = { type: "toolCall" as const, id: "call_0", name: "exec", arguments: {} };
const assistantMessage = { ...makeAssistantMessage([toolCall]), responseId: " " };
const executionContexts: AgentToolExecutionContext[] = [];
const persistedAssistantMessages: AssistantMessage[] = [];
const execTool: AgentTool = {
...makeTool("exec", []),
execute: async () => {
const executionContext = getAgentToolExecutionContext();
if (executionContext) {
executionContexts.push(executionContext);
}
return { content: [{ type: "text", text: "ok" }], details: { ok: true } };
},
};
const streamFn: StreamFn = () => {
turn += 1;
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
const message =
turn === 1 ? assistantMessage : makeAssistantMessage([{ type: "text", text: "done" }]);
stream.push({
type: "done",
reason: message.stopReason === "toolUse" ? "toolUse" : "stop",
message,
});
stream.end();
});
return stream;
};
await runAgentLoop(
[{ role: "user", content: "run", timestamp: 1 }],
{ systemPrompt: "", messages: [], tools: [execTool] },
config,
(event) => {
if (event.type === "message_end" && event.message.role === "assistant") {
persistedAssistantMessages.push(event.message);
}
},
undefined,
streamFn,
);
const toolTurnId = executionContexts[0]?.assistantMessage.turnId;
expect(toolTurnId).toMatch(/^[0-9a-f-]{36}$/u);
expect(executionContexts[0]?.toolCall).toBe(toolCall);
expect(persistedAssistantMessages[0]?.turnId).toBe(toolTurnId);
});
it("marks lifecycle events from the concrete hidden tool instance", async () => {
let turn = 0;
const streamFn: StreamFn = () => {
+57 -27
View File
@@ -10,8 +10,13 @@ import type {
} from "@openclaw/llm-core";
import type { EventStream as SourceEventStream } from "@openclaw/llm-core";
import { TranscriptNotContinuableError } from "./errors.js";
import { uuidv7 } from "./harness/session/uuid.js";
import { resolveAgentReasoningOption } from "./reasoning.js";
import { type AgentCoreStreamRuntimeDeps, resolveAgentCoreStreamFn } from "./runtime-deps.js";
import {
type AgentToolExecutionContext,
runWithAgentToolExecutionContext,
} from "./tool-execution-context.js";
import {
appendInterruptedTurnMessage,
createFailureMessage,
@@ -87,6 +92,14 @@ function removeNonExecutableToolCalls(message: AssistantMessage): AssistantMessa
return content.length === message.content.length ? message : { ...message, content };
}
function ensureToolTurnIdentity(message: AssistantMessage): AssistantMessage {
if (message.stopReason !== "toolUse" || message.responseId?.trim() || message.turnId?.trim()) {
return message;
}
// message_end persists this local identity before any tool can execute.
return { ...message, turnId: uuidv7() };
}
/**
* Start an agent loop with a new prompt message.
* The prompt is added to the context and events are emitted for it.
@@ -514,7 +527,9 @@ async function streamAssistantResponse(
case "done":
case "error": {
const finalMessage = removeNonExecutableToolCalls(await response.result());
const finalMessage = ensureToolTurnIdentity(
removeNonExecutableToolCalls(await response.result()),
);
if (addedPartial) {
context.messages[context.messages.length - 1] = finalMessage;
} else {
@@ -529,7 +544,9 @@ async function streamAssistantResponse(
}
}
const finalMessage = removeNonExecutableToolCalls(await response.result());
const finalMessage = ensureToolTurnIdentity(
removeNonExecutableToolCalls(await response.result()),
);
if (addedPartial) {
context.messages[context.messages.length - 1] = finalMessage;
} else {
@@ -661,7 +678,12 @@ async function executeToolCallsSequential(
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
};
} else {
const executed = await executePreparedToolCall(preparation, signal, emit);
const executed = await executePreparedToolCall(
preparation,
{ assistantMessage, toolCall: preparation.toolCall },
signal,
emit,
);
finalized = await finalizeExecutedToolCall(
currentContext,
assistantMessage,
@@ -740,7 +762,12 @@ async function executeToolCallsParallel(
}
finalizedCalls.push(async () => {
const executed = await executePreparedToolCall(preparation, signal, emit);
const executed = await executePreparedToolCall(
preparation,
{ assistantMessage, toolCall: preparation.toolCall },
signal,
emit,
);
const finalized = await finalizeExecutedToolCall(
currentContext,
assistantMessage,
@@ -980,6 +1007,7 @@ async function prepareToolCall(
async function executePreparedToolCall(
prepared: PreparedToolCall,
executionContext: AgentToolExecutionContext,
signal: AbortSignal | undefined,
emit: AgentEventSink,
): Promise<ExecutedToolCallOutcome> {
@@ -997,29 +1025,31 @@ async function executePreparedToolCall(
let acceptingUpdates = true;
try {
const result = await prepared.tool.execute(
prepared.toolCall.id,
prepared.args as never,
signal,
(partialResult) => {
if (!acceptingUpdates) {
return;
}
updateEvents.push(
Promise.resolve(
emit({
type: "tool_execution_update",
toolCallId: prepared.toolCall.id,
toolName: prepared.toolCall.name,
args: prepared.toolCall.arguments,
partialResult,
...(prepared.tool.hideFromChannelProgress === true
? { hideFromChannelProgress: true }
: {}),
}),
),
);
},
const result = await runWithAgentToolExecutionContext(executionContext, () =>
prepared.tool.execute(
prepared.toolCall.id,
prepared.args as never,
signal,
(partialResult) => {
if (!acceptingUpdates) {
return;
}
updateEvents.push(
Promise.resolve(
emit({
type: "tool_execution_update",
toolCallId: prepared.toolCall.id,
toolName: prepared.toolCall.name,
args: prepared.toolCall.arguments,
partialResult,
...(prepared.tool.hideFromChannelProgress === true
? { hideFromChannelProgress: true }
: {}),
}),
),
);
},
),
);
acceptingUpdates = false;
await Promise.all(updateEvents);
@@ -0,0 +1,22 @@
import { AsyncLocalStorage } from "node:async_hooks";
import type { AssistantMessage } from "@openclaw/llm-core";
import type { AgentToolCall } from "./types.js";
/** Internal assistant-turn context for one concrete tool invocation. */
export interface AgentToolExecutionContext {
assistantMessage: AssistantMessage;
toolCall: AgentToolCall;
}
const activeToolExecution = new AsyncLocalStorage<AgentToolExecutionContext>();
export function getAgentToolExecutionContext(): AgentToolExecutionContext | undefined {
return activeToolExecution.getStore();
}
export function runWithAgentToolExecutionContext<T>(
context: AgentToolExecutionContext,
run: () => T,
): T {
return activeToolExecution.run(context, run);
}
+1
View File
@@ -314,6 +314,7 @@ export interface AssistantMessage {
model: string;
responseModel?: string; // Concrete `chunk.model` when different from the requested `model` (e.g. OpenRouter `auto` -> `anthropic/...`)
responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one
turnId?: string; // Runtime-assigned stable turn identity when the provider does not expose one
diagnostics?: AssistantMessageDiagnostic[]; // Redacted provider/runtime diagnostics for failures and recoveries.
usage: Usage;
stopReason: StopReason;
+19 -2
View File
@@ -19,9 +19,15 @@ function fakeTool(name: string, execute: AnyAgentTool["execute"]): AnyAgentTool
};
}
function createHeadlessHarness(tools: AnyAgentTool[] = []): ToolSearchToolContext {
function createHeadlessHarness(
tools: AnyAgentTool[] = [],
options: { swarmEnabled?: boolean } = {},
): ToolSearchToolContext {
const config = {
tools: { codeMode: { enabled: false, timeoutMs: 60_000 } },
tools: {
codeMode: { enabled: false, timeoutMs: 60_000 },
...(options.swarmEnabled ? { swarm: true } : {}),
},
} as never;
const catalogRef = createToolSearchCatalogRef();
registerHeadlessToolSearchCatalog({ catalogRef, tools });
@@ -88,6 +94,17 @@ describe("headless Code Mode", () => {
expect(second.execute).toHaveBeenCalledOnce();
});
it("does not expose collector globals without resumable snapshot state", async () => {
const result = expectCompleted(
await runCodeModeScriptHeadless({
ctx: createHeadlessHarness([], { swarmEnabled: true }),
code: "return [typeof agents, typeof phase, typeof log];",
}),
);
expect(result.value).toEqual(["undefined", "undefined", "undefined"]);
});
it("injects deeply frozen trigger state and emits replacement state through json", async () => {
const result = expectCompleted(
await runCodeModeScriptHeadless({
+49 -10
View File
@@ -11,12 +11,14 @@ const NAMESPACE_PATH_KEY_SEPARATOR = "\u0000";
const CODE_MODE_NAMESPACE_TOOL_CALL = Symbol.for("openclaw.codeMode.namespaceToolCall");
const RESERVED_NAMESPACE_GLOBALS = new Set([
"ALL_TOOLS",
"agents",
"API",
"Array",
"Boolean",
"Date",
"Error",
"globalThis",
"log",
"json",
"JSON",
"Map",
@@ -26,6 +28,7 @@ const RESERVED_NAMESPACE_GLOBALS = new Set([
"Number",
"Object",
"Promise",
"phase",
"Set",
"String",
"text",
@@ -872,23 +875,59 @@ function createMcpNamespaceScope(
return createMcpNamespaceModel(catalog)?.root;
}
/** Builds virtual API declaration files for visible MCP namespace tools. */
const SWARM_AGENTS_API_CONTENT = `type AgentJsonSchema = Record<string, unknown>;
interface AgentRunOptions {
label?: string;
model?: string;
thinking?: string;
fastMode?: boolean | "auto";
agentId?: string;
schema?: AgentJsonSchema;
phase?: string;
}
interface AgentsApi {
run(prompt: string, options?: AgentRunOptions & { schema?: undefined }): Promise<string>;
run<T>(prompt: string, options: AgentRunOptions & { schema: AgentJsonSchema }): Promise<T>;
}
/** Spawn collector agents concurrently. */
declare const agents: Readonly<AgentsApi>;
/** Publish a phase heading for this swarm. */
declare function phase(title: string): void;
/** Publish a progress note for this swarm. */
declare function log(message: string): void;
// Fan-out: const reports = await Promise.all(prompts.map((prompt) => agents.run(prompt)));
// Gate: while (!ready) { ready = await agents.run("Check readiness") === "ready"; }
// Cycle: for (let pass = 0; pass < 3; pass++) draft = await agents.run("Improve: " + draft);
// Schema: const fact = await agents.run<{ answer: string }>("Research", { schema: { type: "object", properties: { answer: { type: "string" } }, required: ["answer"] } });
`;
/** Builds virtual API declaration files for visible guest and MCP namespace tools. */
export function createCodeModeApiVirtualFiles(
catalog: readonly CodeModeNamespaceCatalogEntry[] = [],
): CodeModeApiVirtualFile[] {
const model = createMcpNamespaceModel(catalog);
if (!model) {
return [];
}
const rootContent = renderMcpRootFile(model.docs);
const files: CodeModeApiVirtualFile[] = [
{
path: "mcp/index.d.ts",
description: "Root MCP namespace declaration and server list.",
content: rootContent,
bytes: Buffer.byteLength(rootContent, "utf8"),
path: "agents.d.ts",
description: "Swarm collector globals and orchestration idioms.",
content: SWARM_AGENTS_API_CONTENT,
bytes: Buffer.byteLength(SWARM_AGENTS_API_CONTENT, "utf8"),
},
];
const model = createMcpNamespaceModel(catalog);
if (!model) {
return files;
}
const rootContent = renderMcpRootFile(model.docs);
files.push({
path: "mcp/index.d.ts",
description: "Root MCP namespace declaration and server list.",
content: rootContent,
bytes: Buffer.byteLength(rootContent, "utf8"),
});
for (const server of model.docs) {
const content = renderMcpServerHeader(server, server.tools);
files.push({
@@ -0,0 +1,40 @@
/** Guest-side Swarm helpers injected into the isolated QuickJS controller. */
export const CODE_MODE_SWARM_CONTROLLER_SOURCE = String.raw`
class SwarmAgentError extends Error {
constructor(runId, status, detail) {
super("Swarm agent " + runId + " " + status + ": " + detail);
this.name = "SwarmAgentError";
this.runId = runId;
this.status = status;
}
}
function swarmNote(kind, value) {
if (typeof value !== "string" || !value.trim()) {
throw new TypeError(kind + " note must be a non-empty string");
}
void request("swarmNote", [{ kind, text: value }]).catch(() => {});
}
async function runAgent(prompt, options = {}) {
if (typeof prompt !== "string" || !prompt.trim()) {
throw new TypeError("agents.run prompt must be a non-empty string");
}
if (options === null || typeof options !== "object" || Array.isArray(options)) {
throw new TypeError("agents.run options must be an object");
}
if (options.phase !== undefined && (typeof options.phase !== "string" || !options.phase.trim())) {
throw new TypeError("agents.run phase must be a non-empty string");
}
if (options.phase !== undefined) swarmNote("phase", options.phase);
const spawned = await request("agentSpawn", [prompt, options]);
const completion = await request("agentWait", [spawned.runId]);
if (!completion || completion.status !== "done") {
const runId = completion?.runId ?? spawned.runId ?? "unknown";
const status = completion?.status ?? "failed";
const detail = completion?.schemaError || completion?.result || "collector returned no result";
throw new SwarmAgentError(runId, status, detail);
}
return options.schema !== undefined ? completion.structured : completion.result;
}
`;
+621
View File
@@ -0,0 +1,621 @@
import { createHash } from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createCodeModeApiVirtualFiles,
registerCodeModeNamespaceForPlugin,
} from "./code-mode-namespaces.js";
import { clearCodeModeNamespacesForTest } from "./code-mode-namespaces.test-support.js";
import { resolveCodeModeConfig } from "./code-mode.js";
import { testing } from "./code-mode.test-support.js";
import { stableStringify } from "./stable-stringify.js";
import {
SWARM_CODE_MODE_IDEMPOTENCY_KEY,
SWARM_CODE_MODE_REQUEST_FINGERPRINT,
} from "./swarm-code-mode.js";
const config = resolveCodeModeConfig({ tools: { codeMode: true } } as never);
function workerExec(source: string, swarmEnabled: boolean) {
return testing.runCodeModeWorker(
{
kind: "exec",
source,
config,
catalog: [],
apiFiles: [],
namespaces: [],
swarmEnabled,
},
10_000,
);
}
function workerResume(
waiting: Extract<Awaited<ReturnType<typeof workerExec>>, { status: "waiting" }>,
settledRequests: Array<{ id: string; ok: true; value: unknown }>,
) {
return testing.runCodeModeWorker(
{
kind: "resume",
snapshotBytes: waiting.snapshotBytes,
config,
settledRequests,
},
10_000,
);
}
function expectWaiting(
result: Awaited<ReturnType<typeof workerExec>>,
): asserts result is Extract<typeof result, { status: "waiting" }> {
expect(result.status).toBe("waiting");
if (result.status !== "waiting") {
throw new Error("expected waiting worker result");
}
}
function swarmContext() {
const runtimeConfig = {
tools: {
codeMode: true,
swarm: { enabled: true },
},
};
return {
config: runtimeConfig,
runtimeConfig,
sessionKey: "agent:main:main",
sessionId: "session-swarm",
runId: "run-swarm",
};
}
afterEach(() => {
testing.activeRuns.clear();
testing.setSwarmDepsForTest();
clearCodeModeNamespacesForTest();
});
describe("Code Mode swarm guest", () => {
it("gates swarm globals in the worker", async () => {
const result = await workerExec(
"return [typeof agents, typeof phase, typeof log, (await API.list()).files.length];",
false,
);
expect(result).toMatchObject({
status: "completed",
value: ["undefined", "undefined", "undefined", 0],
});
});
it("maps agents.run schema options through spawn and returns structured completion", async () => {
const first = await workerExec(
`return await agents.run("Research", {
label: "facts",
model: "openai/gpt-5",
thinking: "high",
fastMode: "auto",
agentId: "researcher",
phase: "Research phase",
schema: { type: "object", properties: { answer: { type: "string" } } }
});`,
true,
);
expectWaiting(first);
expect(first.pendingRequests).toEqual([
{
id: "bridge:swarmNote:1",
method: "swarmNote",
args: [{ kind: "phase", text: "Research phase" }],
},
expect.objectContaining({
id: "bridge:agentSpawn:1",
method: "agentSpawn",
args: [
"Research",
expect.objectContaining({
label: "facts",
model: "openai/gpt-5",
thinking: "high",
fastMode: "auto",
agentId: "researcher",
schema: expect.objectContaining({ type: "object" }),
}),
],
}),
]);
const second = await workerResume(first, [
{ id: "bridge:swarmNote:1", ok: true, value: { ok: true } },
{ id: "bridge:agentSpawn:1", ok: true, value: { runId: "collector-1" } },
]);
expectWaiting(second);
expect(second.pendingRequests).toEqual([
{
id: "bridge:agentWait:1",
method: "agentWait",
args: ["collector-1"],
},
]);
const completed = await workerResume(second, [
{
id: second.pendingRequests[0]!.id,
ok: true,
value: {
runId: "collector-1",
status: "done",
result: '{"answer":"42"}',
structured: { answer: "42" },
},
},
]);
expect(completed).toMatchObject({ status: "completed", value: { answer: "42" } });
});
it("returns text and raises a typed guest error for failed collectors", async () => {
const first = await workerExec('return await agents.run("Research");', true);
expectWaiting(first);
const second = await workerResume(first, [
{ id: first.pendingRequests[0]!.id, ok: true, value: { runId: "collector-2" } },
]);
expectWaiting(second);
const completed = await workerResume(second, [
{
id: second.pendingRequests[0]!.id,
ok: true,
value: { runId: "collector-2", status: "done", result: "plain text" },
},
]);
expect(completed).toMatchObject({ status: "completed", value: "plain text" });
const failedFirst = await workerExec('return await agents.run("Fail");', true);
expectWaiting(failedFirst);
const failedSecond = await workerResume(failedFirst, [
{ id: failedFirst.pendingRequests[0]!.id, ok: true, value: { runId: "collector-3" } },
]);
expectWaiting(failedSecond);
const failed = await workerResume(failedSecond, [
{
id: failedSecond.pendingRequests[0]!.id,
ok: true,
value: { runId: "collector-3", status: "timeout", result: "deadline exceeded" },
},
]);
expect(failed).toMatchObject({ status: "failed", code: "internal_error" });
if (failed.status === "failed") {
expect(failed.error).toContain(
"SwarmAgentError: Swarm agent collector-3 timeout: deadline exceeded",
);
}
});
it("sends phase and log as fire-and-forget swarm notes", async () => {
const first = await workerExec('phase("Plan"); log("Working"); return "ok";', true);
expectWaiting(first);
expect(first.pendingRequests.map(({ method, args }) => ({ method, args }))).toEqual([
{ method: "swarmNote", args: [{ kind: "phase", text: "Plan" }] },
{ method: "swarmNote", args: [{ kind: "log", text: "Working" }] },
]);
const completed = await workerResume(
first,
first.pendingRequests.map((request) => ({ id: request.id, ok: true, value: { ok: true } })),
);
expect(completed).toMatchObject({ status: "completed", value: "ok" });
});
it("documents the typed swarm API and orchestration idioms", () => {
const files = createCodeModeApiVirtualFiles([]);
expect(files.map((file) => file.path)).toEqual(["agents.d.ts"]);
expect(files[0]?.content).toContain("Promise.all");
expect(files[0]?.content).toContain("while (!ready)");
expect(files[0]?.content).toContain("schema: AgentJsonSchema");
});
it.each(["agents", "phase", "log"])("reserves the %s global", (globalName) => {
expect(() =>
registerCodeModeNamespaceForPlugin("test", {
id: `test-${globalName}`,
globalName,
requiredToolNames: ["noop"],
createScope: () => ({}),
}),
).toThrow(`globalName "${globalName}" is reserved`);
});
});
describe("Code Mode swarm host bridge", () => {
it("keeps one invocation stable across restore and separates identical later turns", () => {
const ctx = swarmContext();
const code = 'agents.run("one")';
const restoredAssistantTurnId = structuredClone("response-turn-1");
const first = testing.codeModeReplayIdForToolCall(ctx, "call_0", code, "response-turn-1");
expect(testing.codeModeReplayIdForToolCall(ctx, "call_0", code, restoredAssistantTurnId)).toBe(
first,
);
expect(testing.codeModeReplayIdForToolCall(ctx, "call_0", code, "response-turn-2")).not.toBe(
first,
);
expect(
testing.codeModeReplayIdForToolCall(
{ ...ctx, runId: "run-next" },
"call_0",
code,
"response-turn-1",
),
).not.toBe(first);
expect(
testing.codeModeReplayIdForToolCall(ctx, "call_0", 'agents.run("two")', "response-turn-1"),
).not.toBe(first);
});
it("dispatches notes with the canonical swarm group", async () => {
const emitSessionLifecycleEvent = vi.fn();
testing.setSwarmDepsForTest({ emitSessionLifecycleEvent });
const result = await testing.runBridgeRequest({
runtime: {},
namespaceRuntime: {},
parentToolCallId: "parent",
codeModeRunId: "cm-note",
ctx: swarmContext(),
request: {
id: "bridge:1",
method: "swarmNote",
args: [{ kind: "phase", text: "Plan" }],
},
});
expect(result).toMatchObject({ ok: true, value: { ok: true } });
expect(emitSessionLifecycleEvent).toHaveBeenCalledWith({
sessionKey: "agent:main:main",
reason: "swarm-note",
swarmGroupId: "swarm:agent:main:main:run-swarm",
kind: "phase",
text: "Plan",
});
});
it("re-settles a persisted collector after restart without double-spawn", async () => {
let persisted: Record<string, unknown> | undefined;
let replayId = "";
const callExactId = vi.fn(async (_id: string, input: Record<PropertyKey, unknown>) => {
const idempotencyKey = input[SWARM_CODE_MODE_IDEMPOTENCY_KEY];
const requestFingerprint = input[SWARM_CODE_MODE_REQUEST_FINGERPRINT];
expect(idempotencyKey).toBe(`${replayId}:bridge:1`);
expect(requestFingerprint).toMatch(/^sha256:[0-9a-f]{64}$/u);
persisted = {
runId: "collector-1",
swarmRunId: "collector-1",
childSessionKey: "agent:main:subagent:1",
collect: true,
swarmLaunchReplayKey: idempotencyKey,
swarmLaunchRequestFingerprint: requestFingerprint,
};
return {
result: { details: { status: "accepted", runId: "collector-1" } },
};
});
const runtime = {
namespaceEntries: () => [
{ id: "openclaw:core:sessions_spawn", source: "openclaw", name: "sessions_spawn" },
],
callExactId,
};
const getSwarmRunByLaunchReplayKey = vi.fn(() => persisted);
const waitForCollectorCompletion = vi.fn(async () => ({
runId: "collector-1",
status: "done",
result: "restored",
sessionKey: "agent:main:subagent:1",
}));
testing.setSwarmDepsForTest({
getSwarmRunByLaunchReplayKey,
waitForCollectorCompletion,
});
const spawnRequest = {
id: "bridge:1",
method: "agentSpawn",
args: ["Research", { label: "facts" }],
};
const globalAliasContext = {
...swarmContext(),
sessionKey: "main",
config: { tools: { codeMode: true, swarm: { enabled: true } }, session: { scope: "global" } },
runtimeConfig: {
tools: { codeMode: true, swarm: { enabled: true } },
session: { scope: "global" },
},
} as const;
const code = 'return await agents.run("Research", { label: "facts" });';
replayId = testing.codeModeReplayIdForToolCall(
globalAliasContext,
"call_0",
code,
"response-turn-1",
);
const restoredReplayId = testing.codeModeReplayIdForToolCall(
globalAliasContext,
"call_0",
code,
structuredClone("response-turn-1"),
);
expect(restoredReplayId).toBe(replayId);
const bridgeBase = {
runtime,
namespaceRuntime: {},
parentToolCallId: "parent",
codeModeRunId: restoredReplayId,
ctx: globalAliasContext,
};
const first = await testing.runBridgeRequest({ ...bridgeBase, request: spawnRequest });
const replayed = await testing.runBridgeRequest({ ...bridgeBase, request: spawnRequest });
const waited = await testing.runBridgeRequest({
...bridgeBase,
request: { id: "bridge:2", method: "agentWait", args: ["collector-1"] },
});
expect(first).toMatchObject({ ok: true, value: { runId: "collector-1" } });
expect(replayed).toMatchObject({ ok: true, value: { runId: "collector-1" } });
expect(waited).toMatchObject({ ok: true, value: { status: "done", result: "restored" } });
expect(callExactId).toHaveBeenCalledTimes(1);
expect(getSwarmRunByLaunchReplayKey).toHaveBeenNthCalledWith(
1,
`${replayId}:bridge:1`,
"global",
);
expect(getSwarmRunByLaunchReplayKey).toHaveBeenNthCalledWith(
2,
`${replayId}:bridge:1`,
"global",
);
expect(waitForCollectorCompletion).toHaveBeenCalledWith({
runId: "collector-1",
currentSessionKeys: new Set(["main", "global"]),
signal: undefined,
});
});
it("spawns two collectors when later turns reuse the tool-call id and source", async () => {
const persistedByReplayKey = new Map<string, Record<string, unknown>>();
const callExactId = vi.fn(async (_id: string, input: Record<PropertyKey, unknown>) => {
const replayKey = String(input[SWARM_CODE_MODE_IDEMPOTENCY_KEY]);
const runId = `collector-${persistedByReplayKey.size + 1}`;
persistedByReplayKey.set(replayKey, {
runId,
childSessionKey: `agent:main:subagent:${persistedByReplayKey.size + 1}`,
swarmLaunchReplayKey: replayKey,
swarmLaunchRequestFingerprint: input[SWARM_CODE_MODE_REQUEST_FINGERPRINT],
});
return { result: { details: { status: "accepted", runId } } };
});
testing.setSwarmDepsForTest({
getSwarmRunByLaunchReplayKey: (key) => persistedByReplayKey.get(key),
});
const runtime = {
namespaceEntries: () => [
{ id: "openclaw:core:sessions_spawn", source: "openclaw", name: "sessions_spawn" },
],
callExactId,
};
const ctx = swarmContext();
const code = 'return await agents.run("Research");';
const firstReplayId = testing.codeModeReplayIdForToolCall(
ctx,
"call_0",
code,
"response-turn-1",
);
const secondReplayId = testing.codeModeReplayIdForToolCall(
ctx,
"call_0",
code,
"response-turn-2",
);
const bridgeBase = {
runtime,
namespaceRuntime: {},
parentToolCallId: "parent",
ctx,
request: { id: "bridge:1", method: "agentSpawn", args: ["Research", {}] },
};
const first = await testing.runBridgeRequest({
...bridgeBase,
codeModeRunId: firstReplayId,
});
const second = await testing.runBridgeRequest({
...bridgeBase,
codeModeRunId: secondReplayId,
});
expect(secondReplayId).not.toBe(firstReplayId);
expect(first).toMatchObject({ ok: true, value: { runId: "collector-1" } });
expect(second).toMatchObject({ ok: true, value: { runId: "collector-2" } });
expect(callExactId).toHaveBeenCalledTimes(2);
expect([...persistedByReplayKey.keys()]).toEqual([
`${firstReplayId}:bridge:1`,
`${secondReplayId}:bridge:1`,
]);
});
it("rejects replay when the collector request payload changes", async () => {
const callExactId = vi.fn(async (_id: string, input: Record<PropertyKey, unknown>) => ({
result: { details: { status: "accepted", runId: "collector-1" } },
fingerprint: input[SWARM_CODE_MODE_REQUEST_FINGERPRINT],
}));
const runtime = {
namespaceEntries: () => [
{ id: "openclaw:core:sessions_spawn", source: "openclaw", name: "sessions_spawn" },
],
callExactId,
};
const persisted: { value?: Record<string, unknown> } = {};
testing.setSwarmDepsForTest({
getSwarmRunByLaunchReplayKey: () => persisted.value,
});
const bridgeBase = {
runtime,
namespaceRuntime: {},
parentToolCallId: "parent",
codeModeRunId: "cm-restart",
ctx: swarmContext(),
};
const first = await testing.runBridgeRequest({
...bridgeBase,
request: { id: "bridge:1", method: "agentSpawn", args: ["Research one", {}] },
});
expect(first.ok).toBe(true);
const spawnInput = callExactId.mock.calls[0]?.[1] as Record<PropertyKey, unknown>;
persisted.value = {
runId: "collector-1",
childSessionKey: "agent:main:subagent:1",
swarmLaunchRequestFingerprint: spawnInput[SWARM_CODE_MODE_REQUEST_FINGERPRINT],
};
const replay = await testing.runBridgeRequest({
...bridgeBase,
request: { id: "bridge:1", method: "agentSpawn", args: ["Research two", {}] },
});
expect(replay).toMatchObject({ ok: false });
expect(replay.ok ? "" : replay.error).toContain("does not match the persisted collector");
expect(callExactId).toHaveBeenCalledTimes(1);
});
it("rejects a pending reservation without durable launch state", async () => {
const callExactId = vi.fn(async (_id: string, _input: Record<PropertyKey, unknown>) => ({
result: { details: { status: "accepted", runId: "collector-1" } },
}));
const runtime = {
namespaceEntries: () => [
{ id: "openclaw:core:sessions_spawn", source: "openclaw", name: "sessions_spawn" },
],
callExactId,
};
const persisted: { value?: Record<string, unknown> } = {};
const initSubagentRegistry = vi.fn();
testing.setSwarmDepsForTest({
getSwarmRunByLaunchReplayKey: () => persisted.value,
initSubagentRegistry,
});
const bridgeBase = {
runtime,
namespaceRuntime: {},
parentToolCallId: "parent",
codeModeRunId: "cm-restart",
ctx: swarmContext(),
};
await testing.runBridgeRequest({
...bridgeBase,
request: { id: "bridge:1", method: "agentSpawn", args: ["Research", {}] },
});
const spawnInput = callExactId.mock.calls[0]?.[1] as Record<PropertyKey, unknown>;
persisted.value = {
runId: "collector-1",
childSessionKey: "agent:main:subagent:1",
swarmLaunchPending: true,
swarmLaunchRequestFingerprint: spawnInput[SWARM_CODE_MODE_REQUEST_FINGERPRINT],
};
const replay = await testing.runBridgeRequest({
...bridgeBase,
request: { id: "bridge:1", method: "agentSpawn", args: ["Research", {}] },
});
expect(replay).toMatchObject({ ok: false });
expect(replay.ok ? "" : replay.error).toContain("launch reservation cannot be recovered");
expect(initSubagentRegistry).not.toHaveBeenCalled();
expect(callExactId).toHaveBeenCalledTimes(1);
});
it("re-enqueues a durable pending reservation before returning its handle", async () => {
const initSubagentRegistry = vi.fn();
testing.setSwarmDepsForTest({
initSubagentRegistry,
getSwarmRunByLaunchReplayKey: () => ({
runId: "collector-1",
childSessionKey: "agent:main:subagent:1",
swarmLaunchPending: true,
swarmLaunchRequestFingerprint: `sha256:${createHash("sha256")
.update(
stableStringify({
task: "Research",
collect: true,
groupId: "swarm:agent:main:main:run-swarm",
}),
)
.digest("hex")}`,
queuedLaunch: { request: {}, timeoutMs: 1, schedulerGroupKey: "group", maxConcurrent: 1 },
}),
});
const runtime = {
namespaceEntries: () => [
{ id: "openclaw:core:sessions_spawn", source: "openclaw", name: "sessions_spawn" },
],
callExactId: vi.fn(),
};
const replay = await testing.runBridgeRequest({
runtime,
namespaceRuntime: {},
parentToolCallId: "parent",
codeModeRunId: "cm-restart",
ctx: swarmContext(),
request: { id: "bridge:1", method: "agentSpawn", args: ["Research", {}] },
});
expect(replay).toMatchObject({ ok: true, value: { runId: "collector-1" } });
expect(initSubagentRegistry).toHaveBeenCalledOnce();
expect(runtime.callExactId).not.toHaveBeenCalled();
});
it("renews expired snapshots while agentWait remains pending", () => {
const now = 10_000;
testing.activeRuns.set("cm-pending-agent", {
config: { ...config, snapshotTtlSeconds: 60 },
expiresAt: now - 1,
agentWaitRetainUntil: now + 120_000,
pending: [
{
id: "bridge:2",
method: "agentWait",
args: ["collector-1"],
promise: new Promise(() => {}),
},
],
} as never);
testing.removeExpiredRuns(now);
expect(testing.activeRuns.get("cm-pending-agent")?.expiresAt).toBe(now + 60_000);
});
it("evicts and cancels an agentWait snapshot at its retention cap", () => {
const now = 10_000;
const cancel = vi.fn();
testing.activeRuns.set("cm-expired-agent", {
config: { ...config, snapshotTtlSeconds: 60 },
expiresAt: now - 1,
agentWaitRetainUntil: now - 1,
pending: [
{
id: "bridge:agentWait:1",
method: "agentWait",
args: ["collector-1"],
promise: new Promise(() => {}),
cancel,
},
],
} as never);
testing.removeExpiredRuns(now);
expect(testing.activeRuns.has("cm-expired-agent")).toBe(false);
expect(cancel).toHaveBeenCalledOnce();
});
});
+82
View File
@@ -0,0 +1,82 @@
import type { Result } from "@openclaw/normalization-core/result";
import type { CodeModeApiVirtualFile } from "./code-mode-namespaces.js";
type CodeModeBridgeMethod =
| "search"
| "describe"
| "call"
| "callValue"
| "yield"
| "namespace"
| "agentSpawn"
| "agentWait"
| "swarmNote";
export type CodeModeConfig = {
timeoutMs: number;
memoryLimitBytes: number;
maxPendingToolCalls: number;
maxSnapshotBytes: number;
};
export type PendingBridgeRequest = {
id: string;
method: CodeModeBridgeMethod;
args: unknown[];
};
export type SettledBridgeRequest = { id: string } & Result<unknown, string>;
type SerializedCodeModeNamespaceValue =
| { kind: "array"; items: SerializedCodeModeNamespaceValue[] }
| { kind: "function"; path: string[] }
| { kind: "object"; entries: Array<[string, SerializedCodeModeNamespaceValue]> }
| { kind: "value"; value: unknown };
export type CodeModeNamespaceDescriptor = {
id: string;
globalName: string;
description?: string;
scope: SerializedCodeModeNamespaceValue;
};
export type CodeModeWorkerInput =
| {
kind: "exec";
source: string;
config: CodeModeConfig;
catalog: unknown[];
apiFiles?: CodeModeApiVirtualFile[];
namespaces: CodeModeNamespaceDescriptor[];
swarmEnabled?: boolean;
}
| {
kind: "resume";
snapshotBytes: Uint8Array;
config: CodeModeConfig;
settledRequests: SettledBridgeRequest[];
};
export type CodeModeWorkerResult =
| {
status: "completed";
value: unknown;
output: unknown[];
}
| {
status: "waiting";
snapshotBytes: Uint8Array;
pendingRequests: PendingBridgeRequest[];
output: unknown[];
}
| {
status: "failed";
error: string;
code:
| "invalid_input"
| "runtime_unavailable"
| "timeout"
| "snapshot_limit_exceeded"
| "internal_error";
output: unknown[];
};
+33 -1
View File
@@ -36,8 +36,34 @@ type CodeModeWorkerResult =
| { status: "failed"; error: string; code: CodeModeFailureCode; output: unknown[] };
type CodeModeTestApi = {
activeRuns: Map<string, { config: CodeModeConfig; expiresAt: number }>;
activeRuns: Map<
string,
{
config: CodeModeConfig;
expiresAt: number;
replayId?: string;
agentWaitRetainUntil?: number;
pending: Array<{
id: string;
method: string;
args: unknown[];
promise: Promise<unknown>;
settled?: unknown;
cancel?: () => void;
}>;
}
>;
resumingRunIds: Set<string>;
codeModeReplayIdForToolCall(
ctx: ToolSearchToolContext,
toolCallId: string,
code: string,
assistantTurnId?: string,
): string;
removeExpiredRuns(now?: number): void;
runBridgeRequest(
params: Record<string, unknown>,
): Promise<{ id: string; ok: true; value: unknown } | { id: string; ok: false; error: string }>;
createHeadlessAbortScope(
signal: AbortSignal | undefined,
wallClockMs: number,
@@ -65,6 +91,12 @@ type CodeModeTestApi = {
resolveCodeModeWorkerUrl(currentModuleUrl: string): URL;
getTypescriptRuntimePromise(): Promise<typeof import("typescript")> | null;
setTypescriptRuntimeForTest(runtime: typeof import("typescript") | null): void;
setSwarmDepsForTest(overrides?: {
emitSessionLifecycleEvent?: (event: Record<string, unknown>) => void;
getSwarmRunByLaunchReplayKey?: (key: string, requesterSessionKey?: string) => unknown;
initSubagentRegistry?: () => void;
waitForCollectorCompletion?: (params: Record<string, unknown>) => Promise<unknown>;
}): void;
};
function getTestApi(): CodeModeTestApi {
+44 -9
View File
@@ -3,6 +3,7 @@
import { expectDefined } from "@openclaw/normalization-core";
import { Type } from "typebox";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runWithAgentToolExecutionContext } from "../../packages/agent-core/src/tool-execution-context.js";
import { isRecord } from "../../packages/normalization-core/src/record-coerce.js";
import { setPluginToolMeta } from "../plugins/tools.js";
import { buildBlockedToolResult } from "./agent-tools.before-tool-call.js";
@@ -934,7 +935,7 @@ describe("Code Mode", () => {
expect(details.value).toEqual(["shared", "shared"]);
});
it("rejects forged namespace bridge paths that were not serialized", async () => {
it("hides the raw host bridge while exposing serialized namespace members", async () => {
const hidden = createCodeModeNamespaceTool("fake_noop", () => ({ value: "hidden" }));
const scope = {
exposed: createCodeModeNamespaceTool("fake_noop", () => ({ value: "visible" })),
@@ -964,7 +965,7 @@ describe("Code Mode", () => {
execTool: expectDefined(codeModeTools[0], "codeModeTools[0] test invariant"),
waitTool: expectDefined(codeModeTools[1], "codeModeTools[1] test invariant"),
code: `
globalThis.__openclawHostRequest("namespace", JSON.stringify(["leaky", ["hidden"], []]));
if (typeof globalThis.__openclawHostRequest !== "undefined") throw new Error("raw bridge exposed");
await yield_control("pause");
const exposed = await Leaky.exposed();
return exposed.input.value;
@@ -1911,6 +1912,42 @@ describe("Code Mode", () => {
]);
});
it("allocates distinct replay identities when a later turn reuses a tool-call id", async () => {
const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness();
applyCodeModeCatalog({
tools: [...codeModeTools, pluginTool("fake_noop", "Noop")],
config,
sessionId: "session-code-mode",
sessionKey: "agent:main:main",
runId: "run-code-mode",
catalogRef,
});
const execTool = expectDefined(codeModeTools[0], "codeModeTools[0] test invariant");
const input = { code: 'await yield_control("pause"); return "done";' };
const executionContext = (turnId: string) =>
({
assistantMessage: { responseId: " ", turnId },
toolCall: { type: "toolCall", id: "reused-call-id", name: "exec", arguments: input },
}) as never;
const first = resultDetails(
await runWithAgentToolExecutionContext(executionContext("response-turn-1"), () =>
execTool.execute("reused-call-id", input),
),
);
const second = resultDetails(
await runWithAgentToolExecutionContext(executionContext("response-turn-2"), () =>
execTool.execute("reused-call-id", input),
),
);
expect(first.status).toBe("waiting");
expect(second.status).toBe("waiting");
expect(second.runId).not.toBe(first.runId);
expect(testing.activeRuns.size).toBe(2);
expect(new Set([...testing.activeRuns.values()].map((state) => state.replayId)).size).toBe(2);
});
it("keeps restart-safe mode across audited core reads", async () => {
const targetTool = fakeTool("read", "Read");
const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness();
@@ -2456,7 +2493,7 @@ describe("Code Mode", () => {
expect(error.startsWith("at ")).toBe(false);
});
it("does not duplicate host error headers or expose host stack frames", async () => {
it("does not expose the raw host request callback", async () => {
const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness();
applyCodeModeCatalog({
tools: [...codeModeTools, pluginTool("fake_noop", "Noop")],
@@ -2469,16 +2506,14 @@ describe("Code Mode", () => {
const details = resultDetails(
await expectDefined(codeModeTools[0], "codeModeTools[0] test invariant").execute(
"code-call-host-error",
{
code: 'return globalThis.__openclawHostRequest("unsupported", "[]");',
},
"code-hidden-host-request",
{ code: "return typeof globalThis.__openclawHostRequest;" },
),
);
expect(details).toMatchObject({
status: "failed",
error: "Error: unsupported code mode bridge method",
status: "completed",
value: "undefined",
});
});
+431 -29
View File
@@ -2,7 +2,7 @@
* Host-side Code Mode controller for isolated QuickJS execution with bridged
* tool search/call/yield support.
*/
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Worker } from "node:worker_threads";
@@ -14,7 +14,9 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type { Result } from "@openclaw/normalization-core/result";
import { uniqueValues } from "@openclaw/normalization-core/string-normalization";
import { Type } from "typebox";
import { getAgentToolExecutionContext } from "../../packages/agent-core/src/tool-execution-context.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { emitSessionLifecycleEvent } from "../sessions/session-lifecycle-events.js";
import { createLazyPromiseLoader } from "../shared/lazy-runtime.js";
import { clampNumber } from "../utils.js";
import { resolveAgentConfig } from "./agent-scope-config.js";
@@ -37,6 +39,14 @@ import {
import type { AgentToolUpdateCallback } from "./runtime/index.js";
import { optionalStringEnum } from "./schema/typebox.js";
import type { ToolDefinition } from "./sessions/index.js";
import { stableStringify } from "./stable-stringify.js";
import { getSwarmRunByLaunchReplayKey, initSubagentRegistry } from "./subagent-registry.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
import {
SWARM_CODE_MODE_IDEMPOTENCY_KEY,
SWARM_CODE_MODE_REQUEST_FINGERPRINT,
} from "./swarm-code-mode.js";
import { resolveSwarmConfig } from "./swarm-config.js";
import {
addClientToolsToToolCatalog,
applyToolCatalogCompaction,
@@ -51,12 +61,17 @@ import {
type ToolSearchConfig,
type ToolSearchToolContext,
} from "./tool-search.js";
import {
waitForCollectorCompletion,
type CollectorCompletionResult,
} from "./tools/agents-wait-tool.js";
import {
asToolParamsRecord,
jsonResult,
ToolInputError,
type AnyAgentTool,
} from "./tools/common.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js";
export { CODE_MODE_EXEC_TOOL_NAME, CODE_MODE_WAIT_TOOL_NAME } from "./code-mode-control-tools.js";
const DEFAULT_TIMEOUT_MS = 10_000;
@@ -68,7 +83,9 @@ const DEFAULT_SNAPSHOT_TTL_SECONDS = 900;
const DEFAULT_SEARCH_LIMIT = 8;
const DEFAULT_MAX_SEARCH_LIMIT = 50;
const MAX_ACTIVE_CODE_MODE_RUNS = 64;
const MAX_AGENT_WAIT_SNAPSHOT_TTL_WINDOWS = 4;
const MAX_CODE_MODE_CATALOG_INDEX_CHARS = 8_000;
const CODE_MODE_WORKER_WATCHDOG_GRACE_MS = 2_000;
type CodeModeLanguage = "javascript" | "typescript";
@@ -88,7 +105,16 @@ type CodeModeConfig = {
maxSearchLimit: number;
};
type CodeModeBridgeMethod = "search" | "describe" | "call" | "callValue" | "yield" | "namespace";
type CodeModeBridgeMethod =
| "search"
| "describe"
| "call"
| "callValue"
| "yield"
| "namespace"
| "agentSpawn"
| "agentWait"
| "swarmNote";
type PendingBridgeRequest = {
id: string;
@@ -101,10 +127,12 @@ type SettledBridgeRequest = { id: string } & Result<unknown, string>;
type PendingBridgeState = PendingBridgeRequest & {
promise: Promise<SettledBridgeRequest>;
settled?: SettledBridgeRequest;
cancel?: () => void;
};
type CodeModeRunState = {
runId: string;
replayId: string;
parentToolCallId: string;
ctx: ToolSearchToolContext;
config: CodeModeConfig;
@@ -115,6 +143,7 @@ type CodeModeRunState = {
output: unknown[];
createdAt: number;
expiresAt: number;
agentWaitRetainUntil?: number;
runtime: ToolSearchRuntime;
namespaceRuntime: CodeModeNamespaceRuntime;
};
@@ -172,6 +201,22 @@ const typescriptRuntimeLoader = createLazyPromiseLoader(() => import("typescript
});
let typescriptRuntimeForTest: typeof import("typescript") | null = null;
type CodeModeSwarmDeps = {
emitSessionLifecycleEvent: typeof emitSessionLifecycleEvent;
getSwarmRunByLaunchReplayKey: typeof getSwarmRunByLaunchReplayKey;
initSubagentRegistry: typeof initSubagentRegistry;
waitForCollectorCompletion: typeof waitForCollectorCompletion;
};
const defaultCodeModeSwarmDeps: CodeModeSwarmDeps = {
emitSessionLifecycleEvent,
getSwarmRunByLaunchReplayKey,
initSubagentRegistry,
waitForCollectorCompletion,
};
let codeModeSwarmDeps = defaultCodeModeSwarmDeps;
function normalizeCodeModeRawConfig(value: unknown): Record<string, unknown> | undefined {
const codeMode = value;
if (codeMode === true) {
@@ -312,12 +357,34 @@ function resolveCodeModeHeadlessConfig(
function removeExpiredRuns(now = Date.now()): void {
for (const [runId, state] of activeRuns) {
if (!isFutureDateTimestampMs(state.expiresAt, { nowMs: now })) {
activeRuns.delete(runId);
resumingRunIds.delete(runId);
// Parked collectors extend idle TTL, bounded so a lost terminal event cannot pin all slots.
if (
state.pending?.some((entry) => entry.method === "agentWait" && !entry.settled) &&
state.agentWaitRetainUntil !== undefined &&
isFutureDateTimestampMs(state.agentWaitRetainUntil, { nowMs: now })
) {
const renewed = resolveCodeModeSnapshotExpiresAt(now, state.config.snapshotTtlSeconds);
if (renewed !== undefined) {
state.expiresAt = Math.min(renewed, state.agentWaitRetainUntil);
continue;
}
}
disposeCodeModeRun(runId);
}
}
}
function disposeCodeModeRun(runId: string): void {
const state = activeRuns.get(runId);
for (const pending of state?.pending ?? []) {
if (!pending.settled) {
pending.cancel?.();
}
}
activeRuns.delete(runId);
resumingRunIds.delete(runId);
}
function resolveCodeModeSnapshotExpiresAt(now: number, ttlSeconds: number): number | undefined {
return resolveExpiresAtMsFromDurationSeconds(ttlSeconds, { nowMs: now });
}
@@ -548,10 +615,230 @@ function errorMessage(error: unknown): string {
return String(error);
}
function codeModeReplayIdForToolCall(
ctx: ToolSearchToolContext,
toolCallId: string,
code: string,
assistantTurnId?: string,
): string {
const outerRunId = ctx.runId?.trim();
if (!outerRunId) {
// Swarm bridges require an outer run id; ordinary Code Mode still gets an isolated identity.
return `cm_replay_${randomUUID()}`;
}
// Provider response ids survive transcript restore and scope resettable tool-call ids to one turn.
const identity = JSON.stringify([
ctx.sessionKey ?? "",
ctx.sessionId ?? "",
outerRunId,
assistantTurnId?.trim() ?? "",
toolCallId,
code,
]);
const digest = createHash("sha256").update(identity).digest("hex").slice(0, 24);
return `cm_replay_${digest}`;
}
function requireCodeModeSwarmEnabled(ctx: ToolSearchToolContext): void {
if (!resolveSwarmConfig(ctx.runtimeConfig ?? ctx.config, ctx.agentId).enabled) {
throw new ToolInputError("code mode swarm globals are disabled.");
}
}
function resolveCodeModeRequesterSessionKey(ctx: ToolSearchToolContext): string {
const sessionKey = ctx.sessionKey?.trim();
if (!sessionKey) {
throw new ToolInputError("code mode swarm globals require session and run identity.");
}
const { mainKey, alias } = resolveMainSessionAlias(ctx.runtimeConfig ?? ctx.config ?? {});
return resolveInternalSessionKey({ key: sessionKey, alias, mainKey });
}
function resolveCodeModeSwarmGroupId(ctx: ToolSearchToolContext): string {
const sessionKey = resolveCodeModeRequesterSessionKey(ctx);
const runId = ctx.runId?.trim();
if (!runId) {
throw new ToolInputError("code mode swarm globals require session and run identity.");
}
return `swarm:${sessionKey}:${runId}`;
}
function replayedSpawnResult(entry: SubagentRunRecord) {
return {
status: "accepted",
runId: entry.swarmRunId ?? entry.runId,
sessionKey: entry.childSessionKey,
...(entry.label ? { label: entry.label } : {}),
};
}
function readOptionalStringOption(
options: Record<string, unknown>,
key: "label" | "model" | "thinking" | "agentId",
): string | undefined {
const value = options[key];
if (value === undefined) {
return undefined;
}
if (typeof value !== "string" || !value.trim()) {
throw new ToolInputError(`agents.run ${key} must be a non-empty string.`);
}
return value.trim();
}
async function runAgentSpawnBridge(params: {
runtime: ToolSearchRuntime;
parentToolCallId: string;
request: PendingBridgeRequest;
codeModeRunId: string;
ctx: ToolSearchToolContext;
signal?: AbortSignal;
onUpdate?: AgentToolUpdateCallback;
}) {
requireCodeModeSwarmEnabled(params.ctx);
const prompt = params.request.args[0];
const options = isRecord(params.request.args[1]) ? params.request.args[1] : {};
if (typeof prompt !== "string" || !prompt.trim()) {
throw new ToolInputError("agents.run prompt must be a non-empty string.");
}
const fastMode = options.fastMode;
if (fastMode !== undefined && fastMode !== true && fastMode !== false && fastMode !== "auto") {
throw new ToolInputError('agents.run fastMode must be boolean or "auto".');
}
const schema = options.schema;
if (schema !== undefined && !isRecord(schema)) {
throw new ToolInputError("agents.run schema must be a JSON schema object.");
}
const label = readOptionalStringOption(options, "label");
const model = readOptionalStringOption(options, "model");
const thinking = readOptionalStringOption(options, "thinking");
const agentId = readOptionalStringOption(options, "agentId");
const spawnEntry = params.runtime
.namespaceEntries()
.find((entry) => entry.source === "openclaw" && entry.name === "sessions_spawn");
if (!spawnEntry) {
throw new ToolInputError("agents.run requires the sessions_spawn tool.");
}
const spawnInput: Record<PropertyKey, unknown> = {
task: prompt.trim(),
collect: true,
groupId: resolveCodeModeSwarmGroupId(params.ctx),
...(label ? { label } : {}),
...(model ? { model } : {}),
...(thinking ? { thinking } : {}),
...(agentId ? { agentId } : {}),
...(fastMode !== undefined ? { fastMode } : {}),
...(schema ? { outputSchema: schema } : {}),
};
const requestFingerprint = `sha256:${createHash("sha256")
.update(stableStringify(spawnInput))
.digest("hex")}`;
// The registry persists this exact tuple and payload hash before launch.
const idempotencyKey = `${params.codeModeRunId}:${params.request.id}`;
const requesterSessionKey = resolveCodeModeRequesterSessionKey(params.ctx);
let existing = codeModeSwarmDeps.getSwarmRunByLaunchReplayKey(
idempotencyKey,
requesterSessionKey,
);
if (existing) {
if (existing.swarmLaunchRequestFingerprint !== requestFingerprint) {
throw new ToolInputError("agents.run replay request does not match the persisted collector.");
}
if (existing.swarmLaunchPending === true) {
if (!existing.queuedLaunch) {
throw new ToolInputError("agents.run persisted launch reservation cannot be recovered.");
}
// Cold-start restore idempotently re-enqueues this durable launch before agentWait parks.
codeModeSwarmDeps.initSubagentRegistry();
existing =
codeModeSwarmDeps.getSwarmRunByLaunchReplayKey(idempotencyKey, requesterSessionKey) ??
existing;
if (existing.swarmLaunchPending === true && !existing.queuedLaunch) {
throw new ToolInputError("agents.run persisted launch reservation cannot be recovered.");
}
}
return replayedSpawnResult(existing);
}
Object.defineProperty(spawnInput, SWARM_CODE_MODE_IDEMPOTENCY_KEY, {
value: idempotencyKey,
});
Object.defineProperty(spawnInput, SWARM_CODE_MODE_REQUEST_FINGERPRINT, {
value: requestFingerprint,
});
const called = await params.runtime.callExactId(spawnEntry.id, spawnInput, {
parentToolCallId: params.parentToolCallId,
signal: params.signal,
onUpdate: params.onUpdate,
});
const value =
isRecord(called.result) && "details" in called.result ? called.result.details : called.result;
if (!isRecord(value) || value.status !== "accepted" || typeof value.runId !== "string") {
const detail =
isRecord(value) && typeof value.error === "string"
? value.error
: "collector spawn was not accepted";
throw new ToolInputError(`agents.run spawn failed: ${detail}`);
}
return value;
}
async function runAgentWaitBridge(params: {
request: PendingBridgeRequest;
ctx: ToolSearchToolContext;
signal?: AbortSignal;
}): Promise<CollectorCompletionResult> {
requireCodeModeSwarmEnabled(params.ctx);
const runId = params.request.args[0];
if (typeof runId !== "string" || !runId.trim()) {
throw new ToolInputError("agentWait run id must be a non-empty string.");
}
const rawSessionKey = params.ctx.sessionKey?.trim();
if (!rawSessionKey) {
throw new ToolInputError("agents.run wait requires session identity.");
}
const requesterSessionKey = resolveCodeModeRequesterSessionKey(params.ctx);
return await codeModeSwarmDeps.waitForCollectorCompletion({
runId: runId.trim(),
currentSessionKeys: new Set([rawSessionKey, requesterSessionKey]),
signal: params.signal,
});
}
function runSwarmNoteBridge(params: {
request: PendingBridgeRequest;
ctx: ToolSearchToolContext;
}): { ok: true } {
requireCodeModeSwarmEnabled(params.ctx);
const note = isRecord(params.request.args[0]) ? params.request.args[0] : undefined;
const kind = note?.kind;
const text = note?.text;
if ((kind !== "phase" && kind !== "log") || typeof text !== "string" || !text.trim()) {
throw new ToolInputError("swarmNote requires phase/log kind and non-empty text.");
}
const sessionKey = params.ctx.sessionKey?.trim();
if (!sessionKey) {
throw new ToolInputError("swarmNote requires session identity.");
}
codeModeSwarmDeps.emitSessionLifecycleEvent({
sessionKey,
reason: "swarm-note",
swarmGroupId: resolveCodeModeSwarmGroupId(params.ctx),
kind,
text: text.trim(),
} as Parameters<typeof emitSessionLifecycleEvent>[0] & {
swarmGroupId: string;
kind: "phase" | "log";
text: string;
});
return { ok: true };
}
async function runBridgeRequest(params: {
runtime: ToolSearchRuntime;
namespaceRuntime: CodeModeNamespaceRuntime;
parentToolCallId: string;
codeModeRunId: string;
ctx: ToolSearchToolContext;
request: PendingBridgeRequest;
signal?: AbortSignal;
onUpdate?: AgentToolUpdateCallback;
@@ -661,6 +948,18 @@ async function runBridgeRequest(params: {
);
break;
}
case "agentSpawn": {
value = await runAgentSpawnBridge(params);
break;
}
case "agentWait": {
value = await runAgentWaitBridge(params);
break;
}
case "swarmNote": {
value = runSwarmNoteBridge(params);
break;
}
}
return { id: params.request.id, ok: true, value: toCodeModeJsonSafe(value) };
} catch (error) {
@@ -911,7 +1210,10 @@ async function runHeadlessWorkerLeg(params: {
}): Promise<CodeModeWorkerResult> {
const remainingMs = remainingHeadlessMs(params.deadline);
const timeoutMs = Math.max(1, Math.min(params.config.timeoutMs, remainingMs));
const workerTimeoutMs = Math.max(1, Math.min(remainingMs, timeoutMs + 1000));
const workerTimeoutMs = Math.max(
1,
Math.min(remainingMs, timeoutMs + CODE_MODE_WORKER_WATCHDOG_GRACE_MS),
);
return await runCodeModeWorker(
{
...params.input,
@@ -986,6 +1288,14 @@ function headlessNamespaceFreezePrelude(descriptors: CodeModeNamespaceDescriptor
})();\n`;
}
function createCodeModeApiFilesForRun(
catalog: Parameters<typeof createCodeModeApiVirtualFiles>[0],
swarmEnabled: boolean,
) {
const files = createCodeModeApiVirtualFiles(catalog);
return swarmEnabled ? files : files.filter((file) => file.path !== "agents.d.ts");
}
/** Run Code Mode to completion without publishing resumable snapshot state. */
export async function runCodeModeScriptHeadless(params: {
ctx: ToolSearchToolContext;
@@ -1014,6 +1324,9 @@ export async function runCodeModeScriptHeadless(params: {
const output: unknown[] = [];
let toolCallCount = 0;
try {
// Headless runs publish no resumable snapshot/handle, so collector globals stay unavailable.
const swarmEnabled = false;
const codeModeRunId = `cm_headless_${randomUUID()}`;
const runtime = new ToolSearchRuntime(params.ctx, toToolSearchConfig(config));
const catalog = runtime.all({ includeMcp: false });
const namespaceCatalog = runtime.namespaceEntries();
@@ -1039,8 +1352,9 @@ export async function runCodeModeScriptHeadless(params: {
kind: "exec",
source,
catalog,
apiFiles: createCodeModeApiVirtualFiles(namespaceCatalog),
apiFiles: createCodeModeApiFilesForRun(namespaceCatalog, swarmEnabled),
namespaces,
swarmEnabled,
},
config,
deadline,
@@ -1088,6 +1402,8 @@ export async function runCodeModeScriptHeadless(params: {
runtime,
namespaceRuntime,
parentToolCallId,
codeModeRunId,
ctx: params.ctx,
request,
signal: abortScope.signal,
}),
@@ -1127,6 +1443,7 @@ function snapshotState(params: {
pendingRequests: PendingBridgeRequest[];
snapshotBytes: Uint8Array;
parentToolCallId: string;
codeModeReplayId: string;
ctx: ToolSearchToolContext;
config: CodeModeConfig;
runtime: ToolSearchRuntime;
@@ -1137,9 +1454,16 @@ function snapshotState(params: {
onUpdate?: AgentToolUpdateCallback;
}) {
enforceSnapshotStateLimits(params);
const runId = `cm_${randomUUID()}`;
return storeSnapshotState({
...params,
pending: createPendingBridgeStates(params),
runId,
replayId: params.codeModeReplayId,
pending: createPendingBridgeStates({
...params,
activeRunId: runId,
codeModeRunId: params.codeModeReplayId,
}),
replaySafe:
params.replaySafe && pendingBridgeRequestsReplaySafe(params.pendingRequests, params.runtime),
});
@@ -1153,7 +1477,9 @@ function pendingBridgeRequestsReplaySafe(
if (
request.method === "search" ||
request.method === "describe" ||
request.method === "yield"
request.method === "yield" ||
request.method === "agentSpawn" ||
request.method === "agentWait"
) {
return true;
}
@@ -1190,29 +1516,56 @@ function createPendingBridgeStates(params: {
runtime: ToolSearchRuntime;
namespaceRuntime: CodeModeNamespaceRuntime;
parentToolCallId: string;
codeModeRunId: string;
activeRunId?: string;
ctx: ToolSearchToolContext;
signal?: AbortSignal;
onUpdate?: AgentToolUpdateCallback;
}): PendingBridgeState[] {
return params.pendingRequests.map((request) => {
// Bridge calls start immediately while the VM snapshot is stored. Their
// settled values are later replayed into QuickJS by the wait tool.
const abortController = new AbortController();
const signal = params.signal
? AbortSignal.any([params.signal, abortController.signal])
: abortController.signal;
const promise = runBridgeRequest({
runtime: params.runtime,
namespaceRuntime: params.namespaceRuntime,
parentToolCallId: params.parentToolCallId,
codeModeRunId: params.codeModeRunId,
ctx: params.ctx,
request,
signal: params.signal,
signal,
onUpdate: params.onUpdate,
});
const state: PendingBridgeState = { ...request, promise };
const state: PendingBridgeState = {
...request,
promise,
cancel: () => abortController.abort(),
};
void promise.then((settled) => {
state.settled = settled;
if (state.method === "agentWait" && params.activeRunId) {
const active = activeRuns.get(params.activeRunId);
if (active?.pending.includes(state)) {
const renewed = resolveCodeModeSnapshotExpiresAt(
Date.now(),
active.config.snapshotTtlSeconds,
);
if (renewed !== undefined) {
active.expiresAt = renewed;
}
}
}
});
return state;
});
}
function storeSnapshotState(params: {
runId: string;
replayId: string;
pending: PendingBridgeState[];
replaySafe: boolean;
snapshotBytes: Uint8Array;
@@ -1223,14 +1576,23 @@ function storeSnapshotState(params: {
namespaceRuntime: CodeModeNamespaceRuntime;
output: unknown[];
}) {
const runId = `cm_${randomUUID()}`;
const now = Date.now();
const expiresAt = resolveCodeModeSnapshotExpiresAt(now, params.config.snapshotTtlSeconds);
if (expiresAt === undefined) {
throw new ToolInputError("code mode run expiry is unavailable.");
}
activeRuns.set(runId, {
runId,
const hasPendingAgentWait = params.pending.some(
(entry) => entry.method === "agentWait" && !entry.settled,
);
const agentWaitRetainUntil = hasPendingAgentWait
? resolveCodeModeSnapshotExpiresAt(
now,
params.config.snapshotTtlSeconds * MAX_AGENT_WAIT_SNAPSHOT_TTL_WINDOWS,
)
: undefined;
activeRuns.set(params.runId, {
runId: params.runId,
replayId: params.replayId,
parentToolCallId: params.parentToolCallId,
ctx: params.ctx,
config: params.config,
@@ -1240,12 +1602,13 @@ function storeSnapshotState(params: {
output: params.output,
createdAt: now,
expiresAt,
agentWaitRetainUntil,
runtime: params.runtime,
namespaceRuntime: params.namespaceRuntime,
});
return {
status: "waiting" as const,
runId,
runId: params.runId,
reason: codeModeWaitingReason(params.pending),
pendingToolCalls: pendingToolCalls(params.pending),
replaySafe: params.replaySafe,
@@ -1332,16 +1695,19 @@ function createCodeModeExecDescription(
catalog?: readonly ToolSearchCatalogEntry[],
): string {
const namespacePrompt = describeCodeModeNamespacesForPrompt(ctx, catalog);
// A known run catalog with no MCP tools has no virtual API files, so drop the
// API.list/API.read/MCP guidance instead of luring the model into wasting exec
// turns probing an empty surface. An unknown catalog (initial tool creation,
// before compaction binds the run catalog) keeps the full guidance.
// A known run catalog with neither MCP nor swarm has no virtual API files.
const catalogKnown = catalog !== undefined;
const hasMcp = catalog?.some((entry) => entry.source === "mcp") ?? false;
const mcpGuidance =
!catalogKnown || hasMcp
? " Read TypeScript-style declaration files with `API.list(prefix?)` and `API.read(path)`. MCP tools are available only through the `MCP` namespace."
const swarmEnabled = resolveSwarmConfig(ctx.runtimeConfig ?? ctx.config, ctx.agentId).enabled;
const apiGuidance =
!catalogKnown || hasMcp || swarmEnabled
? " Read TypeScript-style declaration files with `API.list(prefix?)` and `API.read(path)`."
: "";
const mcpGuidance =
!catalogKnown || hasMcp ? " MCP tools are available only through the `MCP` namespace." : "";
const swarmGuidance = swarmEnabled
? " Swarm globals `agents.run`, `phase`, and `log` are available; read `agents.d.ts` for types and orchestration idioms."
: "";
const namespaceGuidance =
!catalogKnown || namespacePrompt
? " Registered plugin namespaces are available as direct globals and through `namespaces` when their required tools are visible in the run catalog."
@@ -1349,7 +1715,9 @@ function createCodeModeExecDescription(
const catalogIndex = catalog ? formatCodeModeCatalogIndex(catalog) : "";
return (
"Run JavaScript or TypeScript in OpenClaw code mode. Use `return` to pass the final value back to the agent; awaited calls without a returned value complete as `null`. Quick-index arrows show trusted declared output hints; `-> ?` means never guess result field names. When the needed tool has an unknown output, including a final dependent call after declared-output calls, the first exec must return the raw tool value unchanged with `return await tools.callValue(id, args);`; do not wrap it in the requested answer shape or read guessed fields; filter or map it only in a later exec after observing its shape. When the arrow declares the fields you need, select, call, and process them in the first exec; do not spend another exec inspecting that declared shape. Within that exec, perform dependent reads, checks, and follow-up calls in order; nested calls still enforce normal tool policy and approvals. Parallelize only independent work. `ALL_TOOLS` is the complete compact catalog with exact ids, input hints, and declared output hints. Select from it directly when practical, use `tools.search(query: string, options?)` when lookup is ambiguous, and use `tools.describe(id: string)` only when the compact input hint is insufficient. Never invent or transform a tool id. `tools.callValue(id: string, args?)` executes a tool and returns its JSON value directly; `tools.call(id: string, args?)` preserves the raw `{ tool, result }` envelope. Example: `const hit = ALL_TOOLS.find((entry) => entry.description.includes('weather')) ?? (await tools.search('weather'))[0]; return await tools.callValue(hit.id, {});`. Node.js modules and `require`/`import` are NOT available; for any shell, file, network, or external action, use enabled catalog tools allowed by policy from inside your code." +
apiGuidance +
mcpGuidance +
swarmGuidance +
namespaceGuidance +
' The `language` field accepts only "javascript" or "typescript"; do not pass "bash", "shell", or other values.' +
(namespacePrompt ? `\n\n${namespacePrompt}` : "") +
@@ -1361,6 +1729,7 @@ async function runExec(params: {
toolCallId: string;
ctx: CodeModeToolContext;
code: string;
assistantTurnId?: string;
language?: CodeModeLanguage;
restartSafe: boolean;
signal?: AbortSignal;
@@ -1387,10 +1756,20 @@ async function runExec(params: {
}
const catalog = runtime.all({ includeMcp: false });
const namespaceCatalog = runtime.namespaceEntries();
const swarmEnabled = resolveSwarmConfig(
params.ctx.runtimeConfig ?? params.ctx.config,
params.ctx.agentId,
).enabled;
const codeModeReplayId = codeModeReplayIdForToolCall(
params.ctx,
params.toolCallId,
params.code,
params.assistantTurnId,
);
// Namespace scope factories are trusted plugin registrations; abort is
// re-checked at the worker boundary rather than racing this setup.
const namespaceRuntime = await createCodeModeNamespaceRuntime(params.ctx, namespaceCatalog);
const apiFiles = createCodeModeApiVirtualFiles(namespaceCatalog);
const apiFiles = createCodeModeApiFilesForRun(namespaceCatalog, swarmEnabled);
let source: string;
try {
source = await prepareSource({ code: params.code, language: params.language, config });
@@ -1415,8 +1794,9 @@ async function runExec(params: {
catalog,
apiFiles,
namespaces: namespaceRuntime.descriptors,
swarmEnabled,
},
config.timeoutMs + 1000,
config.timeoutMs + CODE_MODE_WORKER_WATCHDOG_GRACE_MS,
undefined,
params.signal,
),
@@ -1427,6 +1807,7 @@ async function runExec(params: {
replaySafe: params.restartSafe,
deadlineMs,
parentToolCallId: params.toolCallId,
codeModeReplayId,
ctx: params.ctx,
config,
runtime,
@@ -1501,6 +1882,7 @@ async function settleCodeModeResult(params: {
output: unknown[];
replaySafe: boolean;
parentToolCallId: string;
codeModeReplayId: string;
ctx: ToolSearchToolContext;
config: CodeModeConfig;
runtime: ToolSearchRuntime;
@@ -1566,11 +1948,15 @@ async function settleCodeModeResult(params: {
});
const releaseReservation = reserveActiveRunSlot();
try {
const activeRunId = `cm_${randomUUID()}`;
const pending = createPendingBridgeStates({
pendingRequests: result.pendingRequests,
runtime: params.runtime,
namespaceRuntime: params.namespaceRuntime,
parentToolCallId: params.parentToolCallId,
codeModeRunId: params.codeModeReplayId,
activeRunId,
ctx: params.ctx,
signal: params.signal,
onUpdate: params.onUpdate,
});
@@ -1588,6 +1974,8 @@ async function settleCodeModeResult(params: {
// Parked rather than resumed: without a usable budget the restore alone
// would burn the remaining deadline and fail a recoverable run.
return storeSnapshotState({
runId: activeRunId,
replayId: params.codeModeReplayId,
pending,
replaySafe: false,
snapshotBytes: result.snapshotBytes,
@@ -1604,7 +1992,7 @@ async function settleCodeModeResult(params: {
settledRequests.push(entry.settled ?? (await entry.promise));
}
// The resumed guest inherits only the remaining shared budget as its
// QuickJS interrupt deadline; the extra 1000ms is host watchdog grace,
// QuickJS interrupt deadline; the extra host margin is watchdog grace,
// not extra guest run time.
result = normalizeCodeModeWorkerResult(
await runCodeModeWorker(
@@ -1617,7 +2005,7 @@ async function settleCodeModeResult(params: {
},
settledRequests,
},
resumeBudgetMs + 1000,
resumeBudgetMs + CODE_MODE_WORKER_WATCHDOG_GRACE_MS,
undefined,
params.signal,
),
@@ -1650,6 +2038,7 @@ async function settleCodeModeResult(params: {
pendingRequests: result.pendingRequests,
snapshotBytes: result.snapshotBytes,
parentToolCallId: params.parentToolCallId,
codeModeReplayId: params.codeModeReplayId,
ctx: params.ctx,
config: params.config,
runtime: params.runtime,
@@ -1715,7 +2104,7 @@ async function runWait(params: {
// An aborted wait drops the suspended run: nothing will resume it, and
// parking it would pin a process-global active-run slot until TTL expiry.
if (params.signal?.aborted) {
activeRuns.delete(state.runId);
disposeCodeModeRun(state.runId);
return {
status: "failed" as const,
error: "code mode execution aborted",
@@ -1740,13 +2129,13 @@ async function runWait(params: {
};
}
activeRuns.delete(state.runId);
disposeCodeModeRun(state.runId);
const settledRequests: SettledBridgeRequest[] = [];
for (const entry of state.pending) {
settledRequests.push(entry.settled ?? (await entry.promise));
}
// The resumed guest inherits only the remaining shared budget as its QuickJS
// interrupt deadline; the extra 1000ms is host watchdog grace only.
// interrupt deadline; the extra host margin is watchdog grace only.
const result = normalizeCodeModeWorkerResult(
await runCodeModeWorker(
{
@@ -1758,7 +2147,7 @@ async function runWait(params: {
},
settledRequests,
},
resumeBudgetMs + 1000,
resumeBudgetMs + CODE_MODE_WORKER_WATCHDOG_GRACE_MS,
undefined,
params.signal,
),
@@ -1771,6 +2160,7 @@ async function runWait(params: {
replaySafe: state.replaySafe,
deadlineMs,
parentToolCallId: params.toolCallId,
codeModeReplayId: state.replayId,
ctx: state.ctx,
config: state.config,
runtime: state.runtime,
@@ -1828,12 +2218,16 @@ export function createCodeModeTools(ctx: CodeModeToolContext): AnyAgentTool[] {
onUpdate?: AgentToolUpdateCallback,
) => {
const input = readCode(args);
const executionContext = getAgentToolExecutionContext();
return jsonResult(
normalizeCodeModeTimeoutResult(
await runExec({
toolCallId,
ctx,
code: input.code,
assistantTurnId:
executionContext?.assistantMessage.responseId?.trim() ||
executionContext?.assistantMessage.turnId?.trim(),
language: input.language,
restartSafe: ctx.forceRestartSafeTools === true || input.restartSafe,
signal,
@@ -1950,6 +2344,9 @@ export function addClientToolsToCodeModeCatalog(params: {
const testing = {
activeRuns,
resumingRunIds,
codeModeReplayIdForToolCall,
removeExpiredRuns,
runBridgeRequest,
createHeadlessAbortScope,
normalizeCodeModeWorkerResult,
runCodeModeWorker,
@@ -1960,6 +2357,11 @@ const testing = {
setTypescriptRuntimeForTest: (runtime: typeof import("typescript") | null) => {
typescriptRuntimeForTest = runtime;
},
setSwarmDepsForTest: (overrides?: Partial<CodeModeSwarmDeps>) => {
codeModeSwarmDeps = overrides
? { ...defaultCodeModeSwarmDeps, ...overrides }
: defaultCodeModeSwarmDeps;
},
};
if (process.env.VITEST || process.env.NODE_ENV === "test") {
+60 -76
View File
@@ -6,83 +6,21 @@ import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { parentPort, workerData } from "node:worker_threads";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type { Result } from "@openclaw/normalization-core/result";
import { EvalFlags, JSException, QuickJS, type JSValueHandle } from "quickjs-wasi";
import type { CodeModeApiVirtualFile } from "./code-mode-namespaces.js";
import { CODE_MODE_SWARM_CONTROLLER_SOURCE } from "./code-mode-swarm-controller-source.js";
import type {
CodeModeConfig,
CodeModeNamespaceDescriptor,
CodeModeWorkerInput,
CodeModeWorkerResult,
PendingBridgeRequest,
SettledBridgeRequest,
} from "./code-mode-worker-types.js";
const require = createRequire(import.meta.url);
const QUICKJS_WASM_PATH = require.resolve("quickjs-wasi/quickjs.wasm");
let quickJsWasmModulePromise: Promise<WebAssembly.Module> | undefined;
type CodeModeBridgeMethod = "search" | "describe" | "call" | "callValue" | "yield" | "namespace";
type CodeModeConfig = {
timeoutMs: number;
memoryLimitBytes: number;
maxPendingToolCalls: number;
maxSnapshotBytes: number;
};
type PendingBridgeRequest = {
id: string;
method: CodeModeBridgeMethod;
args: unknown[];
};
type SettledBridgeRequest = { id: string } & Result<unknown, string>;
type SerializedCodeModeNamespaceValue =
| { kind: "array"; items: SerializedCodeModeNamespaceValue[] }
| { kind: "function"; path: string[] }
| { kind: "object"; entries: Array<[string, SerializedCodeModeNamespaceValue]> }
| { kind: "value"; value: unknown };
type CodeModeNamespaceDescriptor = {
id: string;
globalName: string;
description?: string;
scope: SerializedCodeModeNamespaceValue;
};
type CodeModeWorkerInput =
| {
kind: "exec";
source: string;
config: CodeModeConfig;
catalog: unknown[];
apiFiles?: CodeModeApiVirtualFile[];
namespaces: CodeModeNamespaceDescriptor[];
}
| {
kind: "resume";
snapshotBytes: Uint8Array;
config: CodeModeConfig;
settledRequests: SettledBridgeRequest[];
};
type CodeModeWorkerResult =
| {
status: "completed";
value: unknown;
output: unknown[];
}
| {
status: "waiting";
snapshotBytes: Uint8Array;
pendingRequests: PendingBridgeRequest[];
output: unknown[];
}
| {
status: "failed";
error: string;
code:
| "invalid_input"
| "runtime_unavailable"
| "timeout"
| "snapshot_limit_exceeded"
| "internal_error";
output: unknown[];
};
class CodeModeWorkerFailure extends Error {
readonly code: Extract<CodeModeWorkerResult, { status: "failed" }>["code"];
@@ -194,6 +132,9 @@ const CONTROLLER_SOURCE = String.raw`
const catalog = Array.isArray(globalThis.__openclawCatalog) ? globalThis.__openclawCatalog : [];
const apiFiles = Array.isArray(globalThis.__openclawApiFiles) ? globalThis.__openclawApiFiles : [];
const namespaceDescriptors = Array.isArray(globalThis.__openclawNamespaces) ? globalThis.__openclawNamespaces : [];
const hostRequest = globalThis.__openclawHostRequest;
delete globalThis.__openclawHostRequest;
const bridgeSequences = new Map();
function safe(value) {
if (value === undefined) return null;
@@ -217,12 +158,18 @@ const CONTROLLER_SOURCE = String.raw`
}
function request(method, args) {
const id = String(globalThis.__openclawHostRequest(String(method), JSON.stringify(safe(args ?? []))));
const methodName = String(method);
const sequence = (bridgeSequences.get(methodName) ?? 0) + 1;
bridgeSequences.set(methodName, sequence);
const bridgeId = "bridge:" + methodName + ":" + String(sequence);
const id = String(hostRequest(methodName, JSON.stringify(safe(args ?? [])), bridgeId));
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
});
}
${CODE_MODE_SWARM_CONTROLLER_SOURCE}
function namespaceFunction(namespaceId, path) {
const callablePath = Object.freeze((Array.isArray(path) ? path : []).map((entry) => String(entry)));
return (...args) => request("namespace", [namespaceId, callablePath, args]);
@@ -278,6 +225,17 @@ const CONTROLLER_SOURCE = String.raw`
callValue: { value: (id, input) => request("callValue", [id, input]), enumerable: true },
});
if (globalThis.__openclawSwarmEnabled === true) {
Object.defineProperties(globalThis, {
agents: {
value: Object.freeze({ run: runAgent }),
enumerable: true,
},
phase: { value: (title) => swarmNote("phase", title), enumerable: true },
log: { value: (message) => swarmNote("log", message), enumerable: true },
});
}
function normalizeApiPath(value) {
const text = String(value ?? "").trim().replace(/^\/+/, "");
if (!text || text.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
@@ -381,8 +339,13 @@ function createHostRequestHandler(params: {
vm: QuickJS;
pendingRequests: PendingBridgeRequest[];
config: CodeModeConfig;
}): (this: JSValueHandle, method: JSValueHandle, argsJson: JSValueHandle) => JSValueHandle {
return (methodHandle, argsHandle) => {
}): (
this: JSValueHandle,
method: JSValueHandle,
argsJson: JSValueHandle,
bridgeId?: JSValueHandle,
) => JSValueHandle {
return (methodHandle, argsHandle, bridgeIdHandle) => {
if (params.pendingRequests.length >= params.config.maxPendingToolCalls) {
throw new Error("too many pending code mode tool calls");
}
@@ -393,7 +356,10 @@ function createHostRequestHandler(params: {
method !== "call" &&
method !== "callValue" &&
method !== "yield" &&
method !== "namespace"
method !== "namespace" &&
method !== "agentSpawn" &&
method !== "agentWait" &&
method !== "swarmNote"
) {
throw new Error("unsupported code mode bridge method");
}
@@ -403,7 +369,19 @@ function createHostRequestHandler(params: {
} catch {
args = [];
}
const id = `bridge:${params.pendingRequests.length + 1}:${randomUUID()}`;
// Snapshotted method counters keep launch identity independent of unrelated bridge traffic.
const requestedId = bridgeIdHandle?.toString() ?? "undefined";
const id = requestedId === "undefined" ? `bridge:legacy:${randomUUID()}` : requestedId;
const validId =
requestedId === "undefined"
? /^bridge:legacy:[0-9a-f-]+$/u.test(id)
: id.startsWith(`bridge:${method}:`) && /^bridge:[A-Za-z]+:[1-9]\d*$/u.test(id);
if (!validId) {
throw new Error("invalid code mode bridge id");
}
if (params.pendingRequests.some((request) => request.id === id)) {
throw new Error("duplicate code mode bridge id");
}
// The guest receives only an opaque id. Host-side tool execution and policy
// happen after the worker returns a waiting snapshot.
params.pendingRequests.push({
@@ -419,6 +397,7 @@ async function createVm(params: {
catalog: unknown[];
apiFiles: CodeModeApiVirtualFile[];
namespaces: CodeModeNamespaceDescriptor[];
swarmEnabled: boolean;
config: CodeModeConfig;
pendingRequests: PendingBridgeRequest[];
}): Promise<VmRun> {
@@ -443,6 +422,9 @@ async function createVm(params: {
vm.hostToHandle(params.apiFiles).consume((handle) =>
vm.global.setProp("__openclawApiFiles", handle),
);
vm.hostToHandle(params.swarmEnabled).consume((handle) =>
vm.global.setProp("__openclawSwarmEnabled", handle),
);
vm.newFunction(
"__openclawHostRequest",
createHostRequestHandler({
@@ -628,6 +610,7 @@ async function runExec(input: Extract<CodeModeWorkerInput, { kind: "exec" }>) {
catalog: input.catalog,
apiFiles: input.apiFiles ?? [],
namespaces: input.namespaces,
swarmEnabled: input.swarmEnabled === true,
config: input.config,
pendingRequests,
});
@@ -702,6 +685,7 @@ async function main(): Promise<CodeModeWorkerResult> {
namespaces: Array.isArray(input.namespaces)
? (input.namespaces as CodeModeNamespaceDescriptor[])
: [],
swarmEnabled: input.swarmEnabled === true,
});
}
if (input.kind === "resume" && input.snapshotBytes instanceof Uint8Array) {
@@ -229,6 +229,8 @@ export type RegisterSubagentRunParams = {
collect?: boolean;
swarmRequesterSessionKey?: string;
swarmLaunchIdempotencyKey?: string;
swarmLaunchReplayKey?: string;
swarmLaunchRequestFingerprint?: string;
groupId?: string;
outputSchema?: Record<string, unknown>;
queuedLaunch?: SwarmQueuedLaunch;
@@ -831,6 +833,8 @@ export function createSubagentRunManager(params: {
swarmRunId: registerParams.collect ? runId : undefined,
schedulerSlotId: registerParams.collect ? runId : undefined,
swarmLaunchIdempotencyKey: registerParams.swarmLaunchIdempotencyKey,
swarmLaunchReplayKey: registerParams.swarmLaunchReplayKey,
swarmLaunchRequestFingerprint: registerParams.swarmLaunchRequestFingerprint,
swarmLaunchPending: registerParams.collect === true,
groupId: registerParams.groupId,
outputSchema: registerParams.outputSchema,
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearSubagentRunsReadCacheForTest,
getSubagentRunsSnapshotForRead,
onSubagentRegistryPersisted,
persistSubagentRunsToDisk,
} from "./subagent-registry-state.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
@@ -82,4 +83,22 @@ describe("subagent registry state read cache", () => {
expect(mocks.saveSubagentRegistryToSqlite).toHaveBeenCalledOnce();
expect(mocks.loadSubagentRegistryFromSqlite).toHaveBeenCalledTimes(1);
});
it("wakes local readers when a best-effort write fails", () => {
const staleRun = createRun("stale");
const updatedRun = createRun("updated");
mocks.loadSubagentRegistryFromSqlite.mockReturnValue(new Map([[staleRun.runId, staleRun]]));
expect([...getSubagentRunsSnapshotForRead(new Map()).keys()]).toEqual(["stale"]);
const listener = vi.fn();
const unsubscribe = onSubagentRegistryPersisted(listener);
mocks.saveSubagentRegistryToSqlite.mockImplementationOnce(() => {
throw new Error("disk unavailable");
});
persistSubagentRunsToDisk(new Map([[updatedRun.runId, updatedRun]]));
expect(listener).toHaveBeenCalledOnce();
expect([...getSubagentRunsSnapshotForRead(new Map()).keys()]).toEqual(["updated"]);
unsubscribe();
});
});
+27 -1
View File
@@ -18,6 +18,28 @@ let persistedSubagentRunsReadCache:
}
| undefined;
type SubagentRegistryPersistListener = () => void;
const SUBAGENT_REGISTRY_PERSIST_LISTENERS = new Set<SubagentRegistryPersistListener>();
function emitSubagentRegistryPersisted(): void {
for (const listener of SUBAGENT_REGISTRY_PERSIST_LISTENERS) {
try {
listener();
} catch {
// Persistence already succeeded; observers are best-effort.
}
}
}
/** Wake process-local readers after a registry mutation, even if persistence failed. */
export function onSubagentRegistryPersisted(listener: SubagentRegistryPersistListener): () => void {
SUBAGENT_REGISTRY_PERSIST_LISTENERS.add(listener);
return () => {
SUBAGENT_REGISTRY_PERSIST_LISTENERS.delete(listener);
};
}
function cloneSubagentRunsSnapshot(
runs: Map<string, SubagentRunRecord>,
): Map<string, SubagentRunRecord> {
@@ -56,15 +78,19 @@ export function clearSubagentRunsReadCacheForTest(): void {
export function persistSubagentRunsToDisk(runs: Map<string, SubagentRunRecord>) {
try {
saveSubagentRegistryToSqlite(runs);
rememberPersistedSubagentRunsSnapshot(runs);
} catch {
// ignore persistence failures
} finally {
// In-process readers must observe the authoritative memory snapshot before the wake.
rememberPersistedSubagentRunsSnapshot(runs);
emitSubagentRegistryPersisted();
}
}
export function persistSubagentRunsToDiskOrThrow(runs: Map<string, SubagentRunRecord>) {
saveSubagentRegistryToSqlite(runs);
rememberPersistedSubagentRunsSnapshot(runs);
emitSubagentRegistryPersisted();
}
export function restoreSubagentRunsFromDisk(params: {
+19
View File
@@ -2530,6 +2530,25 @@ export function listSwarmRunsForGroup(
);
}
/** Resolve a collector reserved by a replay-safe host bridge request. */
export function getSwarmRunByLaunchReplayKey(
replayKey: string,
requesterSessionKey?: string,
): SubagentRunRecord | undefined {
const key = replayKey.trim();
const requesterKey = requesterSessionKey?.trim();
if (!key) {
return undefined;
}
return [...subagentRegistryDeps.getSubagentRunsSnapshotForRead(subagentRuns).values()].find(
(entry) =>
entry.collect === true &&
entry.swarmLaunchReplayKey === key &&
(!requesterKey ||
(entry.swarmRequesterSessionKey ?? entry.requesterSessionKey) === requesterKey),
);
}
export function countActiveRunsForSession(requesterSessionKey: string): number {
return countActiveRunsForSessionFromRuns(
subagentRegistryDeps.getSubagentRunsSnapshotForRead(subagentRuns),
+4
View File
@@ -224,6 +224,10 @@ export type SubagentRunRecord = {
schedulerSlotId?: string;
/** Exact host-reserved Gateway request identity for the current collector turn. */
swarmLaunchIdempotencyKey?: string;
/** Replay-safe host bridge identity used to recover a collector after restart. */
swarmLaunchReplayKey?: string;
/** Canonical collector request hash paired with a host-reserved launch identity. */
swarmLaunchRequestFingerprint?: string;
/** True only between host reservation and accepted Gateway dispatch. */
swarmLaunchPending?: boolean;
groupId?: string;
+39
View File
@@ -333,6 +333,45 @@ describe("spawnSubagentDirect seam flow", () => {
);
});
it("persists a host-reserved collector launch identity", async () => {
hoisted.configOverride = createConfigOverride({ tools: { swarm: true } });
const result = await spawnSubagentDirect(
{
task: "collect replay-safe evidence",
collect: true,
groupId: "swarm:replay",
swarmLaunchReplayKey: "cm-restart:bridge:1",
swarmLaunchRequestFingerprint: "sha256:request",
},
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
);
const otherRequesterResult = await spawnSubagentDirect(
{
task: "collect replay-safe evidence",
collect: true,
groupId: "swarm:replay",
swarmLaunchReplayKey: "cm-restart:bridge:1",
swarmLaunchRequestFingerprint: "sha256:request",
},
{ agentSessionKey: "agent:main:other", requesterRunId: "parent-run" },
);
expect(result).toMatchObject({ status: "accepted" });
expect(result.runId).toMatch(/^swarm_[0-9a-f]{32}$/u);
expect(otherRequesterResult).toMatchObject({ status: "accepted" });
expect(otherRequesterResult.runId).toMatch(/^swarm_[0-9a-f]{32}$/u);
expect(otherRequesterResult.runId).not.toBe(result.runId);
expect(firstRegisteredSubagentRun()).toMatchObject({
runId: result.runId,
swarmLaunchIdempotencyKey: result.runId,
swarmLaunchReplayKey: "cm-restart:bridge:1",
swarmLaunchRequestFingerprint: "sha256:request",
});
await vi.waitFor(() => expect(gatewayRequest("agent")).toBeDefined());
expect(requireRecord(gatewayRequest("agent").params).idempotencyKey).toBe(result.runId);
});
it("aborts a collector cancelled while its gateway launch is in flight", async () => {
hoisted.configOverride = createConfigOverride({ tools: { swarm: true } });
hoisted.startQueuedSubagentRunMock.mockReturnValue(false);
+17 -1
View File
@@ -165,6 +165,10 @@ type SpawnSubagentParams = {
collect?: boolean;
outputSchema?: Record<string, unknown>;
groupId?: string;
/** Host bridge identity used to recover a replay-safe collector launch. */
swarmLaunchReplayKey?: string;
/** Canonical request hash checked before reusing a host-reserved collector. */
swarmLaunchRequestFingerprint?: string;
cwd?: string;
runTimeoutSeconds?: number;
thread?: boolean;
@@ -1160,7 +1164,15 @@ export async function spawnSubagentDirect(
if (initialSwarmCapError) {
return { status: "forbidden", error: initialSwarmCapError };
}
const childIdem = crypto.randomUUID();
const swarmLaunchReplayKey = normalizeOptionalString(params.swarmLaunchReplayKey);
// Registry and Gateway identities are global, while host replay keys are requester-scoped.
const childIdem = swarmLaunchReplayKey
? `swarm_${crypto
.createHash("sha256")
.update(JSON.stringify([requesterInternalKey, swarmLaunchReplayKey]))
.digest("hex")
.slice(0, 32)}`
: crypto.randomUUID();
let childRunId: string = childIdem;
let swarmReservationPending = false;
if (params.collect && swarmGroupId && swarmSchedulerGroupKey) {
@@ -1734,6 +1746,10 @@ export async function spawnSubagentDirect(
collect: params.collect === true,
swarmRequesterSessionKey: params.collect ? requesterInternalKey : undefined,
swarmLaunchIdempotencyKey: params.collect ? childIdem : undefined,
swarmLaunchReplayKey: params.collect ? swarmLaunchReplayKey : undefined,
swarmLaunchRequestFingerprint: params.collect
? params.swarmLaunchRequestFingerprint
: undefined,
outputSchema: params.outputSchema,
groupId: swarmGroupId,
queuedLaunch:
+6
View File
@@ -0,0 +1,6 @@
/** Internal host-only metadata used to make Code Mode collector spawns replay-safe. */
export const SWARM_CODE_MODE_IDEMPOTENCY_KEY = Symbol.for("openclaw.swarmCodeModeIdempotencyKey");
export const SWARM_CODE_MODE_REQUEST_FINGERPRINT = Symbol.for(
"openclaw.swarmCodeModeRequestFingerprint",
);
+55 -2
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SubagentRunRecord } from "../subagent-registry.types.js";
const records = new Map<string, SubagentRunRecord>();
const registryEvents = vi.hoisted(() => ({ listeners: new Set<() => void>() }));
vi.mock("../subagent-registry.js", () => ({
getSubagentRunsByRunIds: (runIds: readonly string[]) => ({
@@ -19,7 +20,14 @@ vi.mock("../subagent-registry.js", () => ({
}),
}));
import { createAgentsWaitTool } from "./agents-wait-tool.js";
vi.mock("../subagent-registry-state.js", () => ({
onSubagentRegistryPersisted: (listener: () => void) => {
registryEvents.listeners.add(listener);
return () => registryEvents.listeners.delete(listener);
},
}));
import { createAgentsWaitTool, waitForCollectorCompletion } from "./agents-wait-tool.js";
import { testing } from "./agents-wait-tool.test-support.js";
function collectorRun(
@@ -45,7 +53,52 @@ function collectorRun(
}
describe("agents_wait", () => {
beforeEach(() => records.clear());
beforeEach(() => {
records.clear();
registryEvents.listeners.clear();
});
it("settles a parked collector bridge from a registry write event", async () => {
const entry = collectorRun("event-driven", "agent:main:main");
records.set(entry.runId, entry);
const completion = waitForCollectorCompletion({
runId: entry.runId,
currentSessionKeys: new Set(["agent:main:main"]),
});
entry.completion = { required: false, resultText: "event result" };
entry.collectorCompletion = { status: "done" };
for (const listener of registryEvents.listeners) {
listener();
}
await expect(completion).resolves.toMatchObject({
runId: "event-driven",
status: "done",
result: "event result",
});
expect(registryEvents.listeners.size).toBe(0);
});
it("rejects when abort wins the listener-registration race", async () => {
const entry = collectorRun("abort-race", "agent:main:main");
records.set(entry.runId, entry);
const controller = new AbortController();
const originalAddEventListener = controller.signal.addEventListener.bind(controller.signal);
vi.spyOn(controller.signal, "addEventListener").mockImplementation((...args) => {
controller.abort();
originalAddEventListener(...args);
});
await expect(
waitForCollectorCompletion({
runId: entry.runId,
currentSessionKeys: new Set(["agent:main:main"]),
signal: controller.signal,
}),
).rejects.toThrow("agents.run wait aborted");
expect(registryEvents.listeners.size).toBe(0);
});
it("exposes ownership helpers through test support", () => {
const entry = collectorRun("owned", "agent:main:main");
+61
View File
@@ -1,5 +1,6 @@
import { Type } from "typebox";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { onSubagentRegistryPersisted } from "../subagent-registry-state.js";
import { getSubagentRunsByRunIds } from "../subagent-registry.js";
import type { SubagentRunRecord } from "../subagent-registry.types.js";
import { resolveSwarmConfig } from "../swarm-config.js";
@@ -45,6 +46,66 @@ function completionResult(entry: SubagentRunRecord) {
};
}
export type CollectorCompletionResult = NonNullable<ReturnType<typeof completionResult>>;
/** Park one host bridge until its collector completes; registry writes wake it without polling. */
export async function waitForCollectorCompletion(params: {
runId: string;
currentSessionKeys: ReadonlySet<string>;
signal?: AbortSignal;
}): Promise<CollectorCompletionResult> {
const readCompletion = (): CollectorCompletionResult | undefined => {
const state = readWaitState([params.runId], params.currentSessionKeys);
const error = state.errors?.[0];
if (error) {
throw new ToolInputError(`agents.run ${error.error}: ${error.runId}`);
}
return state.completed[0];
};
const immediate = readCompletion();
if (immediate) {
return immediate;
}
if (params.signal?.aborted) {
throw new ToolInputError("agents.run wait aborted.");
}
return await new Promise<CollectorCompletionResult>((resolve, reject) => {
let settled = false;
const finish = (result: CollectorCompletionResult | Error) => {
if (settled) {
return;
}
settled = true;
unsubscribe();
params.signal?.removeEventListener("abort", onAbort);
if (result instanceof Error) {
reject(result);
} else {
resolve(result);
}
};
const check = () => {
try {
const completion = readCompletion();
if (completion) {
finish(completion);
}
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)));
}
};
const onAbort = () => finish(new ToolInputError("agents.run wait aborted."));
const unsubscribe = onSubagentRegistryPersisted(check);
params.signal?.addEventListener("abort", onAbort, { once: true });
// Close the read/subscribe race if completion persisted between both operations.
if (params.signal?.aborted) {
onAbort();
} else {
check();
}
});
}
function resolveWaitTargets(ids: readonly string[], currentSessionKeys: ReadonlySet<string>) {
const targets: WaitTarget[] = [];
const errors: WaitError[] = [];
@@ -4,6 +4,10 @@ import path from "node:path";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { upsertSessionEntry } from "../../config/sessions/session-accessor.js";
import { withTempDir } from "../../test-helpers/temp-dir.js";
import {
SWARM_CODE_MODE_IDEMPOTENCY_KEY,
SWARM_CODE_MODE_REQUEST_FINGERPRINT,
} from "../swarm-code-mode.js";
const hoisted = vi.hoisted(() => {
const spawnSubagentDirectMock = vi.fn();
@@ -326,6 +330,27 @@ describe("sessions_spawn tool", () => {
expect(spawnContext.requesterRunId).toBe("parent-run");
});
it("forwards host-only Code Mode idempotency metadata", async () => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
requesterRunId: "parent-run",
config: { tools: { swarm: true } },
});
const input: Record<PropertyKey, unknown> = { task: "collect", collect: true };
Object.defineProperty(input, SWARM_CODE_MODE_IDEMPOTENCY_KEY, {
value: "cm-restart:bridge:1",
});
Object.defineProperty(input, SWARM_CODE_MODE_REQUEST_FINGERPRINT, {
value: "sha256:request",
});
await tool.execute("collector", input);
const spawnArgs = hoisted.spawnSubagentDirectMock.mock.calls[0]?.[0] as Record<string, unknown>;
expect(spawnArgs.swarmLaunchReplayKey).toBe("cm-restart:bridge:1");
expect(spawnArgs.swarmLaunchRequestFingerprint).toBe("sha256:request");
});
it("requires collect=true for outputSchema", async () => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
+13 -1
View File
@@ -30,6 +30,10 @@ import {
spawnSubagentDirect,
} from "../subagent-spawn.js";
import { normalizeSubagentTaskName } from "../subagent-task-name.js";
import {
SWARM_CODE_MODE_IDEMPOTENCY_KEY,
SWARM_CODE_MODE_REQUEST_FINGERPRINT,
} from "../swarm-code-mode.js";
import { resolveSwarmConfig } from "../swarm-config.js";
import {
describeSessionsSpawnTool,
@@ -264,7 +268,7 @@ export function createSessionsSpawnTool(
swarmEnabled: swarmConfig.enabled,
}),
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const params = args as Record<PropertyKey, unknown>;
if (opts?.swarmCollector && params.collect !== true) {
throw new ToolInputError(
"sessions_spawn from a collector requires collect=true so approvals stay non-interactive.",
@@ -473,6 +477,14 @@ export function createSessionsSpawnTool(
? params.fastMode
: undefined,
groupId: readStringParam(params, "groupId"),
swarmLaunchReplayKey:
typeof params[SWARM_CODE_MODE_IDEMPOTENCY_KEY] === "string"
? params[SWARM_CODE_MODE_IDEMPOTENCY_KEY]
: undefined,
swarmLaunchRequestFingerprint:
typeof params[SWARM_CODE_MODE_REQUEST_FINGERPRINT] === "string"
? params[SWARM_CODE_MODE_REQUEST_FINGERPRINT]
: undefined,
cwd,
thread,
mode,
+32 -1
View File
@@ -35,7 +35,8 @@ vi.mock("../agents/embedded-agent-runner/runs.js", async () => {
};
});
const { createTranscriptUpdateBroadcastHandler } = await import("./server-session-events.js");
const { createLifecycleEventBroadcastHandler, createTranscriptUpdateBroadcastHandler } =
await import("./server-session-events.js");
function createActiveRun(projectSessionActive: boolean): ChatAbortControllerEntry {
return {
@@ -167,3 +168,33 @@ describe("createTranscriptUpdateBroadcastHandler", () => {
});
});
});
describe("createLifecycleEventBroadcastHandler", () => {
it("projects swarm phase and log payload fields", () => {
const broadcastToConnIds = vi.fn();
const handler = createLifecycleEventBroadcastHandler({
broadcastToConnIds,
sessionEventSubscribers: { getAll: () => new Set(["conn-1"]) },
chatAbortControllers: new Map(),
});
handler({
sessionKey: "agent:main:main",
reason: "swarm-note",
swarmGroupId: "swarm:agent:main:main:run-1",
kind: "phase",
text: "Research",
} as never);
expect(broadcastToConnIds).toHaveBeenCalledWith(
"sessions.changed",
expect.objectContaining({
swarmGroupId: "swarm:agent:main:main:run-1",
kind: "phase",
text: "Research",
}),
new Set(["conn-1"]),
{ dropIfSlow: true },
);
});
});
+12
View File
@@ -289,6 +289,11 @@ export function createLifecycleEventBroadcastHandler(params: {
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
}) {
return (event: SessionLifecycleEvent): void => {
const swarmEvent = event as SessionLifecycleEvent & {
swarmGroupId?: string;
kind?: "phase" | "log";
text?: string;
};
const connIds = params.sessionEventSubscribers.getAll();
if (connIds.size === 0) {
return;
@@ -320,6 +325,13 @@ export function createLifecycleEventBroadcastHandler(params: {
hasActiveRun: activeRunState?.active,
activeRunIds: activeRunState?.runIds,
}),
...(swarmEvent.swarmGroupId
? {
swarmGroupId: swarmEvent.swarmGroupId,
kind: swarmEvent.kind,
text: swarmEvent.text,
}
: {}),
},
connIds,
{ dropIfSlow: true },