feat(code-mode): first-class nodes API (#114877)

* feat(agents): add nodes to code mode

* fix(agents): add code mode nodes discovery hint
This commit is contained in:
Peter Steinberger
2026-07-27 23:03:23 -04:00
committed by GitHub
parent b244adf430
commit ea707382fd
8 changed files with 464 additions and 0 deletions

View File

@@ -565,6 +565,23 @@ type ToolCatalog = {
};
```
Paired Gateway nodes are available through the `nodes` global:
```typescript
const available = await nodes.list();
const node = await nodes.get(available[0].id);
const status = await node.invoke("device.status");
```
`nodes.list()` returns paired node ids, names, platforms, connection state, and
advertised commands. `nodes.get(idOrName)` resolves an exact id before a display
name and returns a handle with `id`, `name`, and `invoke(command, params?)`.
Invocation uses the normal `nodes` tool path, so pairing, command policy, scopes,
approvals, timeouts, hooks, and telemetry are unchanged. A handle includes
`listDir(path)` only when the node advertises `fs.listDir`. It does not include
`exec`: the generic nodes surface reserves `system.run` for the normal shell
`exec` tool with a node host.
Convenience tool functions are installed only for unambiguous safe names:
```typescript

View File

@@ -1,6 +1,9 @@
import { createHash, randomUUID } from "node:crypto";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { NODE_FS_LIST_DIR_COMMAND } from "../infra/node-commands.js";
import { emitSessionLifecycleEvent } from "../sessions/session-lifecycle-events.js";
import { parseNodeList } from "../shared/node-list-parse.js";
import type { NodeListNode } from "../shared/node-list-types.js";
import { toCodeModeJsonSafe } from "./code-mode-json.js";
import type { CodeModeNamespaceRuntime } from "./code-mode-namespaces.js";
import {
@@ -23,6 +26,7 @@ import {
type CollectorCompletionResult,
} from "./tools/agents-wait-tool.js";
import { ToolInputError } from "./tools/common.js";
import { resolveEligibleNodeFromList } from "./tools/nodes-utils.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js";
type CodeModeSwarmDeps = {
@@ -39,6 +43,124 @@ const defaultCodeModeSwarmDeps: CodeModeSwarmDeps = {
waitForCollectorCompletion,
};
const CODE_MODE_NODES_TOOL_ID = "openclaw:core:nodes";
type CodeModeNode = {
id: string;
name: string;
platform?: string;
connected: boolean;
commands: string[];
};
function projectCodeModeNode(node: NodeListNode): CodeModeNode {
return {
id: node.nodeId,
name: node.displayName?.trim() || node.nodeId,
...(node.platform ? { platform: node.platform } : {}),
connected: node.connected === true,
commands: Array.isArray(node.commands)
? node.commands.filter((command): command is string => typeof command === "string")
: [],
};
}
async function callNodesTool(params: {
runtime: ToolSearchRuntime;
parentToolCallId: string;
signal?: AbortSignal;
onUpdate?: AgentToolUpdateCallback;
input: Record<string, unknown>;
}): Promise<unknown> {
return await params.runtime.callValue(CODE_MODE_NODES_TOOL_ID, params.input, {
includeMcp: false,
parentToolCallId: params.parentToolCallId,
signal: params.signal,
onUpdate: params.onUpdate,
recoverySurface: "tools",
});
}
async function listCodeModeNodes(params: {
runtime: ToolSearchRuntime;
parentToolCallId: string;
signal?: AbortSignal;
onUpdate?: AgentToolUpdateCallback;
}): Promise<NodeListNode[]> {
return parseNodeList(
await callNodesTool({
...params,
input: { action: "status" },
}),
);
}
async function runNodesBridge(params: {
runtime: ToolSearchRuntime;
parentToolCallId: string;
request: PendingBridgeRequest;
signal?: AbortSignal;
onUpdate?: AgentToolUpdateCallback;
}): Promise<unknown> {
const values = params.request.args;
const action = values[0];
if (action === "list") {
return (await listCodeModeNodes(params))
.filter((node) => node.paired === true)
.map(projectCodeModeNode);
}
if (action === "get") {
const query = values[1];
if (typeof query !== "string" || !query.trim()) {
throw new ToolInputError("nodes.get id or name must be a non-empty string.");
}
const node = resolveEligibleNodeFromList(
await listCodeModeNodes(params),
query,
(candidate) => candidate.paired === true,
{
ineligibleExact: (id, eligibleIds) =>
`node "${id}" is not paired (paired node ids: ${eligibleIds})`,
nameResolveFailed: (reason, eligibleIds) => `${reason} (paired node ids: ${eligibleIds})`,
noneEligible: () => "no paired nodes",
multipleEligible: (eligible) =>
`multiple nodes paired: ${eligible
.map((candidate) => candidate.nodeId)
.toSorted()
.join(", ")}`,
},
);
const projected = projectCodeModeNode(node);
return {
id: projected.id,
name: projected.name,
...(projected.commands.includes(NODE_FS_LIST_DIR_COMMAND)
? { listDirCommand: NODE_FS_LIST_DIR_COMMAND }
: {}),
};
}
if (action === "invoke") {
const node = values[1];
const command = values[2];
if (typeof node !== "string" || !node.trim()) {
throw new ToolInputError("nodes.invoke node id must be a non-empty string.");
}
if (typeof command !== "string" || !command.trim()) {
throw new ToolInputError("nodes.invoke command must be a non-empty string.");
}
return await callNodesTool({
...params,
input: {
action: "invoke",
node,
invokeCommand: command,
invokeParamsJson: JSON.stringify(values[3] ?? {}),
},
});
}
throw new ToolInputError("unsupported nodes bridge action.");
}
let codeModeSwarmDeps = defaultCodeModeSwarmDeps;
export function codeModeReplayIdForToolCall(
@@ -324,6 +446,10 @@ export async function runBridgeRequest(params: {
});
break;
}
case "nodes": {
value = await runNodesBridge(params);
break;
}
case "yield": {
value = { status: "yielded", reason: values[0] ?? null };
break;

View File

@@ -0,0 +1,284 @@
/** Tests the first-class Code Mode nodes API through the real generic nodes tool. */
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ToolSearchCatalogToolExecutor } from "./tool-search.js";
import type { AnyAgentTool } from "./tools/common.js";
const gatewayMocks = vi.hoisted(() => ({
callGatewayTool: vi.fn(),
readGatewayCallOptions: vi.fn(() => ({})),
}));
vi.mock("./tools/gateway.js", () => ({
callGatewayTool: gatewayMocks.callGatewayTool,
readGatewayCallOptions: gatewayMocks.readGatewayCallOptions,
}));
const nodeSnapshot = [
{
nodeId: "node-1",
displayName: "Desk",
platform: "darwin",
paired: true,
connected: true,
commands: ["device.status", "fs.listDir", "computer.act", "danger.read"],
},
{
nodeId: "shadow-id",
displayName: "Offline",
platform: "linux",
paired: false,
connected: false,
commands: ["device.status"],
},
{
nodeId: "node-2",
displayName: "shadow-id",
paired: true,
connected: false,
commands: ["device.status"],
},
];
let applyCodeModeCatalog: typeof import("./code-mode.js").applyCodeModeCatalog;
let createCodeModeTools: typeof import("./code-mode.js").createCodeModeTools;
let createToolSearchCatalogRef: typeof import("./tool-search.js").createToolSearchCatalogRef;
let createNodesTool: typeof import("./tools/nodes-tool.js").createNodesTool;
let testing: typeof import("./code-mode.test-support.js").testing;
function resultDetails(result: { details?: unknown }): Record<string, unknown> {
expect(result.details).toBeDefined();
expect(typeof result.details).toBe("object");
return result.details as Record<string, unknown>;
}
function createHarness() {
const catalogRef = createToolSearchCatalogRef();
const config = { tools: { codeMode: true } } as never;
const nestedCalls: Parameters<ToolSearchCatalogToolExecutor>[0][] = [];
const executeTool: ToolSearchCatalogToolExecutor = async (params) => {
nestedCalls.push(params);
const result = await params.tool.execute(
params.toolCallId,
params.input,
params.signal,
params.onUpdate,
undefined as never,
);
return await params.acceptResultBeforeProjection(result);
};
const ctx = {
config,
runtimeConfig: config,
sessionId: "session-code-mode-nodes",
sessionKey: "agent:main:main",
runId: "run-code-mode-nodes",
catalogRef,
executeTool,
};
const codeModeTools = createCodeModeTools(ctx);
const nodesTool = createNodesTool({ agentSessionKey: ctx.sessionKey });
applyCodeModeCatalog({
tools: [...codeModeTools, nodesTool],
config,
sessionId: ctx.sessionId,
sessionKey: ctx.sessionKey,
runId: ctx.runId,
catalogRef,
});
return {
execTool: expectDefined(codeModeTools[0], "code mode exec tool"),
waitTool: expectDefined(codeModeTools[1], "code mode wait tool"),
nestedCalls,
};
}
async function runUntilCompleted(params: {
execTool: AnyAgentTool;
waitTool: AnyAgentTool;
code: string;
}): Promise<Record<string, unknown>> {
let details = resultDetails(
await params.execTool.execute("code-nodes-call", { code: params.code }),
);
for (let index = 0; index < 8 && details.status === "waiting"; index += 1) {
details = resultDetails(
await params.waitTool.execute(`code-nodes-wait-${index}`, { runId: details.runId }),
);
}
return details;
}
describe("Code Mode nodes", () => {
beforeAll(async () => {
vi.resetModules();
({ applyCodeModeCatalog, createCodeModeTools } = await import("./code-mode.js"));
({ createToolSearchCatalogRef } = await import("./tool-search.js"));
({ createNodesTool } = await import("./tools/nodes-tool.js"));
({ testing } = await import("./code-mode.test-support.js"));
});
beforeEach(() => {
gatewayMocks.callGatewayTool.mockReset();
gatewayMocks.readGatewayCallOptions.mockReset();
gatewayMocks.readGatewayCallOptions.mockReturnValue({});
gatewayMocks.callGatewayTool.mockImplementation(async (method, _options, input) => {
if (method === "node.list") {
return { nodes: nodeSnapshot };
}
if (method === "node.invoke") {
const invocation = input as {
nodeId: string;
command: string;
params: unknown;
};
if (invocation.command === "danger.read") {
throw new Error(
'node command not allowed: "danger.read" is blocked by gateway.nodes.commands.deny',
);
}
return {
payload: {
nodeId: invocation.nodeId,
command: invocation.command,
params: invocation.params,
},
};
}
throw new Error(`unexpected gateway method: ${String(method)}`);
});
});
afterEach(() => {
testing.activeRuns.clear();
testing.resumingRunIds.clear();
});
it("lists nodes and returns a callable handle with conditional directory sugar", async () => {
const harness = createHarness();
const details = await runUntilCompleted({
...harness,
code: `
const listed = await nodes.list();
const node = await nodes.get("Desk");
const invoked = await node.invoke("device.status", { detail: true });
const directory = await node.listDir("/tmp");
return {
listed,
id: node.id,
name: node.name,
methods: Object.keys(node).sort(),
invoked,
directory,
hasExec: "exec" in node,
};
`,
});
expect(details.status).toBe("completed");
expect(details.value).toEqual({
listed: [
{
id: "node-1",
name: "Desk",
platform: "darwin",
connected: true,
commands: ["device.status", "fs.listDir", "computer.act", "danger.read"],
},
{
id: "node-2",
name: "shadow-id",
connected: false,
commands: ["device.status"],
},
],
id: "node-1",
name: "Desk",
methods: ["id", "invoke", "listDir", "name"],
invoked: {
payload: { nodeId: "node-1", command: "device.status", params: { detail: true } },
},
directory: {
payload: { nodeId: "node-1", command: "fs.listDir", params: { path: "/tmp" } },
},
hasExec: false,
});
expect(harness.nestedCalls).not.toHaveLength(0);
expect(harness.nestedCalls.every((call) => call.parentToolCallId === "code-nodes-call")).toBe(
true,
);
});
it("omits listDir when the node does not advertise fs.listDir", async () => {
const harness = createHarness();
const details = await runUntilCompleted({
...harness,
code: `const node = await nodes.get("node-2"); return Object.keys(node).sort();`,
});
expect(details.value).toEqual(["id", "invoke", "name"]);
});
it("surfaces the generic nodes policy refusal with the command name", async () => {
const harness = createHarness();
const details = await runUntilCompleted({
...harness,
code: `
const node = await nodes.get("Desk");
try {
await node.invoke("danger.read");
return "unexpected success";
} catch (error) {
return error.message;
}
`,
});
expect(details.value).toContain('node command not allowed: "danger.read"');
expect(details.value).toContain("blocked by gateway.nodes.commands.deny");
});
it("keeps computer.act blocked by the generic nodes surface", async () => {
const harness = createHarness();
const details = await runUntilCompleted({
...harness,
code: `
const node = await nodes.get("Desk");
try {
await node.invoke("computer.act", { action: "left_click", x: 1, y: 1 });
return "unexpected success";
} catch (error) {
return error.message;
}
`,
});
expect(details.value).toContain('invokeCommand "computer.act"');
expect(details.value).toContain("use the dedicated computer tool");
expect(
gatewayMocks.callGatewayTool.mock.calls.some(
([method, , input]) =>
method === "node.invoke" &&
(input as { command?: string } | undefined)?.command === "computer.act",
),
).toBe(false);
});
it("rejects an ineligible exact id before matching an eligible node name", async () => {
const harness = createHarness();
const details = await runUntilCompleted({
...harness,
code: `
try {
await nodes.get("shadow-id");
return "unexpected success";
} catch (error) {
return error.message;
}
`,
});
expect(details.value).toBe('node "shadow-id" is not paired (paired node ids: node-1, node-2)');
});
});

View File

@@ -55,6 +55,7 @@ type CodeModeBridgeMethod =
| "describe"
| "call"
| "callValue"
| "nodes"
| "yield"
| "namespace"
| "agentSpawn"

View File

@@ -6,6 +6,7 @@ type CodeModeBridgeMethod =
| "describe"
| "call"
| "callValue"
| "nodes"
| "yield"
| "namespace"
| "agentSpawn"

View File

@@ -439,6 +439,12 @@ describe("Code Mode", () => {
expect(execTool.description).toContain('"javascript" or "typescript"');
expect(execTool.description).toContain("never a shell command");
expect(execTool.description).toContain("do not retry failed shell source");
const nodesGuidance =
"- nodes: paired Gateway nodes; nodes.list(), (await nodes.get(id)).invoke(command, params)";
expect(execTool.description).toContain(nodesGuidance);
expect(execTool.description.indexOf(nodesGuidance)).toBe(
execTool.description.lastIndexOf(nodesGuidance),
);
expect(parameters.properties?.code?.description).toContain(
"`tools.search` takes a query string, not an object",

View File

@@ -144,12 +144,15 @@ function createCodeModeExecDescription(
const swarmGuidance = swarmEnabled
? " Swarm globals `agents.run`, `phase`, and `log` are available; read `agents.d.ts` for types and orchestration idioms."
: "";
const nodesGuidance =
"\n- nodes: paired Gateway nodes; nodes.list(), (await nodes.get(id)).invoke(command, params)\n";
const catalogIndex = catalog ? formatCodeModeCatalogIndex(catalog) : "";
return (
"Run JavaScript or TypeScript in OpenClaw code mode. Use `return` to pass the final value back; otherwise the result is `null`. Quick-index arrows show trusted declared output hints; `-> ?` means never guess result field names. For declared fields, process them in the first exec; do not spend another exec inspecting them. Perform dependent reads, checks, and follow-up calls in order; parallelize independent work only. For an unknown output, including a final dependent call after declared-output calls, return the raw tool value unchanged; do not wrap it in the requested answer shape or guess fields; filter or map it only in a later exec. Nested calls enforce normal tool policy and approvals. `ALL_TOOLS` is the complete compact catalog. Select exact ids directly or with `tools.search(query: string, options?)`; use `tools.describe(id: string)` only when needed. Never invent or transform a tool id. `tools.callValue(id: string, args?)` returns its JSON value directly; `tools.call(id: string, args?)` preserves `{ tool, result }`. 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; use enabled catalog tools allowed by policy for shell, file, network, or external actions." +
apiGuidance +
mcpGuidance +
swarmGuidance +
nodesGuidance +
' The `language` field accepts only "javascript" or "typescript"; do not pass "bash", "shell", or other values.' +
" Both `code` and `command` contain JavaScript or TypeScript, never a shell command. " +
"For shell or file operations, call the exact catalog tool from guest JavaScript; do not retry failed shell source." +

View File

@@ -188,6 +188,30 @@ const CONTROLLER_SOURCE = String.raw`
return true;
}
function nodeHandle(descriptor) {
const handle = Object.create(null);
Object.defineProperties(handle, {
id: { value: descriptor.id, enumerable: true },
name: { value: descriptor.name, enumerable: true },
invoke: {
value: (command, params) => request("nodes", ["invoke", descriptor.id, command, params]),
enumerable: true,
},
});
if (typeof descriptor.listDirCommand === "string") {
Object.defineProperty(handle, "listDir", {
value: (path) => request("nodes", ["invoke", descriptor.id, descriptor.listDirCommand, { path }]),
enumerable: true,
});
}
return Object.freeze(handle);
}
const nodes = Object.freeze({
list: () => request("nodes", ["list"]),
get: async (idOrName) => nodeHandle(await request("nodes", ["get", idOrName])),
});
const baseTools = Object.create(null);
Object.defineProperties(baseTools, {
search: { value: (query, options) => request("search", [query, options]), enumerable: true },
@@ -291,6 +315,7 @@ const CONTROLLER_SOURCE = String.raw`
Object.defineProperties(globalThis, {
ALL_TOOLS: { value: Object.freeze(catalog.slice()), enumerable: true },
API: { value: api, enumerable: true },
nodes: { value: nodes, enumerable: true },
namespaces: { value: Object.freeze(namespaceGlobals), enumerable: true },
tools: { value: Object.freeze(baseTools), enumerable: true },
text: { value: (value) => output.push({ type: "text", text: asText(value) }), enumerable: true },
@@ -326,6 +351,7 @@ function createHostRequestHandler(params: {
method !== "describe" &&
method !== "call" &&
method !== "callValue" &&
method !== "nodes" &&
method !== "yield" &&
method !== "namespace" &&
method !== "agentSpawn" &&