From d9cadf97377812e53b87a15082c74efc67e5e662 Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Sat, 31 Jan 2026 18:13:22 +0100 Subject: [PATCH] Agents: add nodes invoke action --- src/agents/openclaw-tools.camera.test.ts | 49 ++++++++++++++++++++++++ src/agents/tools/nodes-tool.ts | 31 ++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/agents/openclaw-tools.camera.test.ts b/src/agents/openclaw-tools.camera.test.ts index 802a8c662..86f6bed4d 100644 --- a/src/agents/openclaw-tools.camera.test.ts +++ b/src/agents/openclaw-tools.camera.test.ts @@ -133,3 +133,52 @@ describe("nodes run", () => { }); }); }); + +describe("nodes invoke", () => { + beforeEach(() => { + callGateway.mockReset(); + }); + + it("invokes arbitrary commands with params JSON", async () => { + callGateway.mockImplementation(async ({ method, params }) => { + if (method === "node.list") { + return { nodes: [{ nodeId: "ios-1" }] }; + } + if (method === "node.invoke") { + expect(params).toMatchObject({ + nodeId: "ios-1", + command: "device.info", + params: { includeBattery: true }, + timeoutMs: 12_000, + }); + return { + ok: true, + nodeId: "ios-1", + command: "device.info", + payload: { deviceName: "iPhone" }, + }; + } + throw new Error(`unexpected method: ${String(method)}`); + }); + + const tool = createOpenClawTools().find((candidate) => candidate.name === "nodes"); + if (!tool) { + throw new Error("missing nodes tool"); + } + + const result = await tool.execute("call1", { + action: "invoke", + node: "ios-1", + invokeCommand: "device.info", + invokeParamsJson: JSON.stringify({ includeBattery: true }), + invokeTimeoutMs: 12_000, + }); + + expect(result.details).toMatchObject({ + ok: true, + nodeId: "ios-1", + command: "device.info", + payload: { deviceName: "iPhone" }, + }); + }); +}); diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index 4fce2a7f3..1528726b8 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -37,6 +37,7 @@ const NODES_TOOL_ACTIONS = [ "screen_record", "location_get", "run", + "invoke", ] as const; const NOTIFY_PRIORITIES = ["passive", "active", "timeSensitive"] as const; @@ -84,6 +85,9 @@ const NodesToolSchema = Type.Object({ commandTimeoutMs: Type.Optional(Type.Number()), invokeTimeoutMs: Type.Optional(Type.Number()), needsScreenRecording: Type.Optional(Type.Boolean()), + // invoke + invokeCommand: Type.Optional(Type.String()), + invokeParamsJson: Type.Optional(Type.String()), }); export function createNodesTool(options?: { @@ -99,7 +103,7 @@ export function createNodesTool(options?: { label: "Nodes", name: "nodes", description: - "Discover and control paired nodes (status/describe/pairing/notify/camera/screen/location/run).", + "Discover and control paired nodes (status/describe/pairing/notify/camera/screen/location/run/invoke).", parameters: NodesToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; @@ -438,6 +442,31 @@ export function createNodesTool(options?: { }); return jsonResult(raw?.payload ?? {}); } + case "invoke": { + const node = readStringParam(params, "node", { required: true }); + const nodeId = await resolveNodeId(gatewayOpts, node); + const invokeCommand = readStringParam(params, "invokeCommand", { required: true }); + const invokeParamsJson = + typeof params.invokeParamsJson === "string" ? params.invokeParamsJson.trim() : ""; + let invokeParams: unknown = {}; + if (invokeParamsJson) { + try { + invokeParams = JSON.parse(invokeParamsJson); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`invokeParamsJson must be valid JSON: ${message}`); + } + } + const invokeTimeoutMs = parseTimeoutMs(params.invokeTimeoutMs); + const raw = await callGatewayTool("node.invoke", gatewayOpts, { + nodeId, + command: invokeCommand, + params: invokeParams, + timeoutMs: invokeTimeoutMs, + idempotencyKey: crypto.randomUUID(), + }); + return jsonResult(raw ?? {}); + } default: throw new Error(`Unknown action: ${action}`); }