diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 4ce306db7b1..366f2b874bd 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/packages/agent-core/src/agent-loop.test.ts b/packages/agent-core/src/agent-loop.test.ts index bb084f8348a..15296c95b85 100644 --- a/packages/agent-core/src/agent-loop.test.ts +++ b/packages/agent-core/src/agent-loop.test.ts @@ -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 = () => { diff --git a/packages/agent-core/src/agent-loop.ts b/packages/agent-core/src/agent-loop.ts index de6e94a7db9..c2583f88b56 100644 --- a/packages/agent-core/src/agent-loop.ts +++ b/packages/agent-core/src/agent-loop.ts @@ -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 { @@ -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); diff --git a/packages/agent-core/src/tool-execution-context.ts b/packages/agent-core/src/tool-execution-context.ts new file mode 100644 index 00000000000..b05d94f7e05 --- /dev/null +++ b/packages/agent-core/src/tool-execution-context.ts @@ -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(); + +export function getAgentToolExecutionContext(): AgentToolExecutionContext | undefined { + return activeToolExecution.getStore(); +} + +export function runWithAgentToolExecutionContext( + context: AgentToolExecutionContext, + run: () => T, +): T { + return activeToolExecution.run(context, run); +} diff --git a/packages/llm-core/src/types.ts b/packages/llm-core/src/types.ts index b58e193fecc..3664959c3c0 100644 --- a/packages/llm-core/src/types.ts +++ b/packages/llm-core/src/types.ts @@ -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; diff --git a/src/agents/code-mode-headless.test.ts b/src/agents/code-mode-headless.test.ts index f0ac8ec0a79..abc125a877c 100644 --- a/src/agents/code-mode-headless.test.ts +++ b/src/agents/code-mode-headless.test.ts @@ -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({ diff --git a/src/agents/code-mode-namespaces.ts b/src/agents/code-mode-namespaces.ts index ffc99e67122..aae2898e103 100644 --- a/src/agents/code-mode-namespaces.ts +++ b/src/agents/code-mode-namespaces.ts @@ -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; + +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; + run(prompt: string, options: AgentRunOptions & { schema: AgentJsonSchema }): Promise; +} + +/** Spawn collector agents concurrently. */ +declare const agents: Readonly; +/** 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({ diff --git a/src/agents/code-mode-swarm-controller-source.ts b/src/agents/code-mode-swarm-controller-source.ts new file mode 100644 index 00000000000..f2b7b85e5d7 --- /dev/null +++ b/src/agents/code-mode-swarm-controller-source.ts @@ -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; + } +`; diff --git a/src/agents/code-mode-swarm.test.ts b/src/agents/code-mode-swarm.test.ts new file mode 100644 index 00000000000..a42b7d7fa37 --- /dev/null +++ b/src/agents/code-mode-swarm.test.ts @@ -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>, { 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>, +): asserts result is Extract { + 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 | undefined; + let replayId = ""; + const callExactId = vi.fn(async (_id: string, input: Record) => { + 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>(); + const callExactId = vi.fn(async (_id: string, input: Record) => { + 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) => ({ + 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 } = {}; + 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; + 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) => ({ + 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 } = {}; + 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; + 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(); + }); +}); diff --git a/src/agents/code-mode-worker-types.ts b/src/agents/code-mode-worker-types.ts new file mode 100644 index 00000000000..cfa4c239a7d --- /dev/null +++ b/src/agents/code-mode-worker-types.ts @@ -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; + +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[]; + }; diff --git a/src/agents/code-mode.test-support.ts b/src/agents/code-mode.test-support.ts index eded96e6307..66e331db2d5 100644 --- a/src/agents/code-mode.test-support.ts +++ b/src/agents/code-mode.test-support.ts @@ -36,8 +36,34 @@ type CodeModeWorkerResult = | { status: "failed"; error: string; code: CodeModeFailureCode; output: unknown[] }; type CodeModeTestApi = { - activeRuns: Map; + activeRuns: Map< + string, + { + config: CodeModeConfig; + expiresAt: number; + replayId?: string; + agentWaitRetainUntil?: number; + pending: Array<{ + id: string; + method: string; + args: unknown[]; + promise: Promise; + settled?: unknown; + cancel?: () => void; + }>; + } + >; resumingRunIds: Set; + codeModeReplayIdForToolCall( + ctx: ToolSearchToolContext, + toolCallId: string, + code: string, + assistantTurnId?: string, + ): string; + removeExpiredRuns(now?: number): void; + runBridgeRequest( + params: Record, + ): 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 | null; setTypescriptRuntimeForTest(runtime: typeof import("typescript") | null): void; + setSwarmDepsForTest(overrides?: { + emitSessionLifecycleEvent?: (event: Record) => void; + getSwarmRunByLaunchReplayKey?: (key: string, requesterSessionKey?: string) => unknown; + initSubagentRegistry?: () => void; + waitForCollectorCompletion?: (params: Record) => Promise; + }): void; }; function getTestApi(): CodeModeTestApi { diff --git a/src/agents/code-mode.test.ts b/src/agents/code-mode.test.ts index ffd12f1b05f..4c5cd34fedc 100644 --- a/src/agents/code-mode.test.ts +++ b/src/agents/code-mode.test.ts @@ -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", }); }); diff --git a/src/agents/code-mode.ts b/src/agents/code-mode.ts index 9785595d841..ba7c81b3ac8 100644 --- a/src/agents/code-mode.ts +++ b/src/agents/code-mode.ts @@ -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; type PendingBridgeState = PendingBridgeRequest & { promise: Promise; 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 | 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, + 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 = { + 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 { + 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[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 { 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[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 = overrides + ? { ...defaultCodeModeSwarmDeps, ...overrides } + : defaultCodeModeSwarmDeps; + }, }; if (process.env.VITEST || process.env.NODE_ENV === "test") { diff --git a/src/agents/code-mode.worker.ts b/src/agents/code-mode.worker.ts index 0f142f1adc0..bdc7496c06b 100644 --- a/src/agents/code-mode.worker.ts +++ b/src/agents/code-mode.worker.ts @@ -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 | 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; - -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["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 { @@ -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) { 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 { namespaces: Array.isArray(input.namespaces) ? (input.namespaces as CodeModeNamespaceDescriptor[]) : [], + swarmEnabled: input.swarmEnabled === true, }); } if (input.kind === "resume" && input.snapshotBytes instanceof Uint8Array) { diff --git a/src/agents/subagent-registry-run-manager.ts b/src/agents/subagent-registry-run-manager.ts index 480cb6fa27b..60ba800ba76 100644 --- a/src/agents/subagent-registry-run-manager.ts +++ b/src/agents/subagent-registry-run-manager.ts @@ -229,6 +229,8 @@ export type RegisterSubagentRunParams = { collect?: boolean; swarmRequesterSessionKey?: string; swarmLaunchIdempotencyKey?: string; + swarmLaunchReplayKey?: string; + swarmLaunchRequestFingerprint?: string; groupId?: string; outputSchema?: Record; 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, diff --git a/src/agents/subagent-registry-state.test.ts b/src/agents/subagent-registry-state.test.ts index 9882414b53e..b6f2c997b72 100644 --- a/src/agents/subagent-registry-state.test.ts +++ b/src/agents/subagent-registry-state.test.ts @@ -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(); + }); }); diff --git a/src/agents/subagent-registry-state.ts b/src/agents/subagent-registry-state.ts index 8474bb0378f..2d21e65aaa3 100644 --- a/src/agents/subagent-registry-state.ts +++ b/src/agents/subagent-registry-state.ts @@ -18,6 +18,28 @@ let persistedSubagentRunsReadCache: } | undefined; +type SubagentRegistryPersistListener = () => void; + +const SUBAGENT_REGISTRY_PERSIST_LISTENERS = new Set(); + +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, ): Map { @@ -56,15 +78,19 @@ export function clearSubagentRunsReadCacheForTest(): void { export function persistSubagentRunsToDisk(runs: Map) { 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) { saveSubagentRegistryToSqlite(runs); rememberPersistedSubagentRunsSnapshot(runs); + emitSubagentRegistryPersisted(); } export function restoreSubagentRunsFromDisk(params: { diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index c6f603cbd13..0df20dbb934 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -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), diff --git a/src/agents/subagent-registry.types.ts b/src/agents/subagent-registry.types.ts index c1bf8a7d40f..d5b79e0e401 100644 --- a/src/agents/subagent-registry.types.ts +++ b/src/agents/subagent-registry.types.ts @@ -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; diff --git a/src/agents/subagent-spawn.test.ts b/src/agents/subagent-spawn.test.ts index 7bf29f2f5d7..8cbf2eaf823 100644 --- a/src/agents/subagent-spawn.test.ts +++ b/src/agents/subagent-spawn.test.ts @@ -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); diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 8d635329243..7ab1914ae6c 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -165,6 +165,10 @@ type SpawnSubagentParams = { collect?: boolean; outputSchema?: Record; 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: diff --git a/src/agents/swarm-code-mode.ts b/src/agents/swarm-code-mode.ts new file mode 100644 index 00000000000..dd962dc7bd8 --- /dev/null +++ b/src/agents/swarm-code-mode.ts @@ -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", +); diff --git a/src/agents/tools/agents-wait-tool.test.ts b/src/agents/tools/agents-wait-tool.test.ts index 1e865716340..53500647719 100644 --- a/src/agents/tools/agents-wait-tool.test.ts +++ b/src/agents/tools/agents-wait-tool.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { SubagentRunRecord } from "../subagent-registry.types.js"; const records = new Map(); +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"); diff --git a/src/agents/tools/agents-wait-tool.ts b/src/agents/tools/agents-wait-tool.ts index 7f3b425c76b..068c634a007 100644 --- a/src/agents/tools/agents-wait-tool.ts +++ b/src/agents/tools/agents-wait-tool.ts @@ -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>; + +/** Park one host bridge until its collector completes; registry writes wake it without polling. */ +export async function waitForCollectorCompletion(params: { + runId: string; + currentSessionKeys: ReadonlySet; + signal?: AbortSignal; +}): Promise { + 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((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) { const targets: WaitTarget[] = []; const errors: WaitError[] = []; diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index 60b913b79bd..8020d131dbe 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -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 = { 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; + 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", diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 332537eecb2..72661ac1b11 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -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; + const params = args as Record; 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, diff --git a/src/gateway/server-session-events.test.ts b/src/gateway/server-session-events.test.ts index 8faeb060bbf..bce109e78e1 100644 --- a/src/gateway/server-session-events.test.ts +++ b/src/gateway/server-session-events.test.ts @@ -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 }, + ); + }); +}); diff --git a/src/gateway/server-session-events.ts b/src/gateway/server-session-events.ts index 428a4954f08..bfebab8599a 100644 --- a/src/gateway/server-session-events.ts +++ b/src/gateway/server-session-events.ts @@ -289,6 +289,11 @@ export function createLifecycleEventBroadcastHandler(params: { chatAbortControllers: Map; }) { 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 },