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