fix: add node tool failure context

This commit is contained in:
Peter Steinberger
2026-01-21 09:54:58 +00:00
parent 40646c73af
commit 63d017c3af
2 changed files with 327 additions and 311 deletions

View File

@@ -9,6 +9,7 @@ Docs: https://docs.clawd.bot
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
### Fixes
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
## 2026.1.20

View File

@@ -112,334 +112,349 @@ export function createNodesTool(options?: {
timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
};
switch (action) {
case "status":
return jsonResult(await callGatewayTool("node.list", gatewayOpts, {}));
case "describe": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
return jsonResult(await callGatewayTool("node.describe", gatewayOpts, { nodeId }));
}
case "pending":
return jsonResult(await callGatewayTool("node.pair.list", gatewayOpts, {}));
case "approve": {
const requestId = readStringParam(params, "requestId", {
required: true,
});
return jsonResult(
await callGatewayTool("node.pair.approve", gatewayOpts, {
requestId,
}),
);
}
case "reject": {
const requestId = readStringParam(params, "requestId", {
required: true,
});
return jsonResult(
await callGatewayTool("node.pair.reject", gatewayOpts, {
requestId,
}),
);
}
case "notify": {
const node = readStringParam(params, "node", { required: true });
const title = typeof params.title === "string" ? params.title : "";
const body = typeof params.body === "string" ? params.body : "";
if (!title.trim() && !body.trim()) {
throw new Error("title or body required");
try {
switch (action) {
case "status":
return jsonResult(await callGatewayTool("node.list", gatewayOpts, {}));
case "describe": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
return jsonResult(await callGatewayTool("node.describe", gatewayOpts, { nodeId }));
}
const nodeId = await resolveNodeId(gatewayOpts, node);
await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "system.notify",
params: {
title: title.trim() || undefined,
body: body.trim() || undefined,
sound: typeof params.sound === "string" ? params.sound : undefined,
priority: typeof params.priority === "string" ? params.priority : undefined,
delivery: typeof params.delivery === "string" ? params.delivery : undefined,
},
idempotencyKey: crypto.randomUUID(),
});
return jsonResult({ ok: true });
}
case "camera_snap": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const facingRaw =
typeof params.facing === "string" ? params.facing.toLowerCase() : "both";
const facings: CameraFacing[] =
facingRaw === "both"
? ["front", "back"]
: facingRaw === "front" || facingRaw === "back"
? [facingRaw]
: (() => {
throw new Error("invalid facing (front|back|both)");
})();
const maxWidth =
typeof params.maxWidth === "number" && Number.isFinite(params.maxWidth)
? params.maxWidth
: undefined;
const quality =
typeof params.quality === "number" && Number.isFinite(params.quality)
? params.quality
: undefined;
const delayMs =
typeof params.delayMs === "number" && Number.isFinite(params.delayMs)
? params.delayMs
: undefined;
const deviceId =
typeof params.deviceId === "string" && params.deviceId.trim()
? params.deviceId.trim()
: undefined;
case "pending":
return jsonResult(await callGatewayTool("node.pair.list", gatewayOpts, {}));
case "approve": {
const requestId = readStringParam(params, "requestId", {
required: true,
});
return jsonResult(
await callGatewayTool("node.pair.approve", gatewayOpts, {
requestId,
}),
);
}
case "reject": {
const requestId = readStringParam(params, "requestId", {
required: true,
});
return jsonResult(
await callGatewayTool("node.pair.reject", gatewayOpts, {
requestId,
}),
);
}
case "notify": {
const node = readStringParam(params, "node", { required: true });
const title = typeof params.title === "string" ? params.title : "";
const body = typeof params.body === "string" ? params.body : "";
if (!title.trim() && !body.trim()) {
throw new Error("title or body required");
}
const nodeId = await resolveNodeId(gatewayOpts, node);
await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "system.notify",
params: {
title: title.trim() || undefined,
body: body.trim() || undefined,
sound: typeof params.sound === "string" ? params.sound : undefined,
priority: typeof params.priority === "string" ? params.priority : undefined,
delivery: typeof params.delivery === "string" ? params.delivery : undefined,
},
idempotencyKey: crypto.randomUUID(),
});
return jsonResult({ ok: true });
}
case "camera_snap": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const facingRaw =
typeof params.facing === "string" ? params.facing.toLowerCase() : "both";
const facings: CameraFacing[] =
facingRaw === "both"
? ["front", "back"]
: facingRaw === "front" || facingRaw === "back"
? [facingRaw]
: (() => {
throw new Error("invalid facing (front|back|both)");
})();
const maxWidth =
typeof params.maxWidth === "number" && Number.isFinite(params.maxWidth)
? params.maxWidth
: undefined;
const quality =
typeof params.quality === "number" && Number.isFinite(params.quality)
? params.quality
: undefined;
const delayMs =
typeof params.delayMs === "number" && Number.isFinite(params.delayMs)
? params.delayMs
: undefined;
const deviceId =
typeof params.deviceId === "string" && params.deviceId.trim()
? params.deviceId.trim()
: undefined;
const content: AgentToolResult<unknown>["content"] = [];
const details: Array<Record<string, unknown>> = [];
const content: AgentToolResult<unknown>["content"] = [];
const details: Array<Record<string, unknown>> = [];
for (const facing of facings) {
for (const facing of facings) {
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "camera.snap",
params: {
facing,
maxWidth,
quality,
format: "jpg",
delayMs,
deviceId,
},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
const payload = parseCameraSnapPayload(raw?.payload);
const normalizedFormat = payload.format.toLowerCase();
if (
normalizedFormat !== "jpg" &&
normalizedFormat !== "jpeg" &&
normalizedFormat !== "png"
) {
throw new Error(`unsupported camera.snap format: ${payload.format}`);
}
const isJpeg = normalizedFormat === "jpg" || normalizedFormat === "jpeg";
const filePath = cameraTempPath({
kind: "snap",
facing,
ext: isJpeg ? "jpg" : "png",
});
await writeBase64ToFile(filePath, payload.base64);
content.push({ type: "text", text: `MEDIA:${filePath}` });
content.push({
type: "image",
data: payload.base64,
mimeType:
imageMimeFromFormat(payload.format) ?? (isJpeg ? "image/jpeg" : "image/png"),
});
details.push({
facing,
path: filePath,
width: payload.width,
height: payload.height,
});
}
const result: AgentToolResult<unknown> = { content, details };
return await sanitizeToolResultImages(result, "nodes:camera_snap");
}
case "camera_list": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "camera.snap",
command: "camera.list",
params: {},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
const payload =
raw && typeof raw.payload === "object" && raw.payload !== null ? raw.payload : {};
return jsonResult(payload);
}
case "camera_clip": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const facing =
typeof params.facing === "string" ? params.facing.toLowerCase() : "front";
if (facing !== "front" && facing !== "back") {
throw new Error("invalid facing (front|back)");
}
const durationMs =
typeof params.durationMs === "number" && Number.isFinite(params.durationMs)
? params.durationMs
: typeof params.duration === "string"
? parseDurationMs(params.duration)
: 3000;
const includeAudio =
typeof params.includeAudio === "boolean" ? params.includeAudio : true;
const deviceId =
typeof params.deviceId === "string" && params.deviceId.trim()
? params.deviceId.trim()
: undefined;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "camera.clip",
params: {
facing,
maxWidth,
quality,
format: "jpg",
delayMs,
durationMs,
includeAudio,
format: "mp4",
deviceId,
},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
const payload = parseCameraSnapPayload(raw?.payload);
const normalizedFormat = payload.format.toLowerCase();
if (
normalizedFormat !== "jpg" &&
normalizedFormat !== "jpeg" &&
normalizedFormat !== "png"
) {
throw new Error(`unsupported camera.snap format: ${payload.format}`);
}
const isJpeg = normalizedFormat === "jpg" || normalizedFormat === "jpeg";
const payload = parseCameraClipPayload(raw?.payload);
const filePath = cameraTempPath({
kind: "snap",
kind: "clip",
facing,
ext: isJpeg ? "jpg" : "png",
ext: payload.format,
});
await writeBase64ToFile(filePath, payload.base64);
content.push({ type: "text", text: `MEDIA:${filePath}` });
content.push({
type: "image",
data: payload.base64,
mimeType:
imageMimeFromFormat(payload.format) ?? (isJpeg ? "image/jpeg" : "image/png"),
});
details.push({
facing,
path: filePath,
width: payload.width,
height: payload.height,
});
return {
content: [{ type: "text", text: `FILE:${filePath}` }],
details: {
facing,
path: filePath,
durationMs: payload.durationMs,
hasAudio: payload.hasAudio,
},
};
}
const result: AgentToolResult<unknown> = { content, details };
return await sanitizeToolResultImages(result, "nodes:camera_snap");
case "screen_record": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const durationMs =
typeof params.durationMs === "number" && Number.isFinite(params.durationMs)
? params.durationMs
: typeof params.duration === "string"
? parseDurationMs(params.duration)
: 10_000;
const fps =
typeof params.fps === "number" && Number.isFinite(params.fps) ? params.fps : 10;
const screenIndex =
typeof params.screenIndex === "number" && Number.isFinite(params.screenIndex)
? params.screenIndex
: 0;
const includeAudio =
typeof params.includeAudio === "boolean" ? params.includeAudio : true;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "screen.record",
params: {
durationMs,
screenIndex,
fps,
format: "mp4",
includeAudio,
},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
const payload = parseScreenRecordPayload(raw?.payload);
const filePath =
typeof params.outPath === "string" && params.outPath.trim()
? params.outPath.trim()
: screenRecordTempPath({ ext: payload.format || "mp4" });
const written = await writeScreenRecordToFile(filePath, payload.base64);
return {
content: [{ type: "text", text: `FILE:${written.path}` }],
details: {
path: written.path,
durationMs: payload.durationMs,
fps: payload.fps,
screenIndex: payload.screenIndex,
hasAudio: payload.hasAudio,
},
};
}
case "location_get": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const maxAgeMs =
typeof params.maxAgeMs === "number" && Number.isFinite(params.maxAgeMs)
? params.maxAgeMs
: undefined;
const desiredAccuracy =
params.desiredAccuracy === "coarse" ||
params.desiredAccuracy === "balanced" ||
params.desiredAccuracy === "precise"
? params.desiredAccuracy
: undefined;
const locationTimeoutMs =
typeof params.locationTimeoutMs === "number" &&
Number.isFinite(params.locationTimeoutMs)
? params.locationTimeoutMs
: undefined;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "location.get",
params: {
maxAgeMs,
desiredAccuracy,
timeoutMs: locationTimeoutMs,
},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
return jsonResult(raw?.payload ?? {});
}
case "run": {
const node = readStringParam(params, "node", { required: true });
const nodes = await listNodes(gatewayOpts);
if (nodes.length === 0) {
throw new Error(
"system.run requires a paired companion app or node host (no nodes available).",
);
}
const nodeId = resolveNodeIdFromList(nodes, node);
const nodeInfo = nodes.find((entry) => entry.nodeId === nodeId);
const supportsSystemRun = Array.isArray(nodeInfo?.commands)
? nodeInfo?.commands?.includes("system.run")
: false;
if (!supportsSystemRun) {
throw new Error(
"system.run requires a companion app or node host; the selected node does not support system.run.",
);
}
const commandRaw = params.command;
if (!commandRaw) {
throw new Error("command required (argv array, e.g. ['echo', 'Hello'])");
}
if (!Array.isArray(commandRaw)) {
throw new Error("command must be an array of strings (argv), e.g. ['echo', 'Hello']");
}
const command = commandRaw.map((c) => String(c));
if (command.length === 0) {
throw new Error("command must not be empty");
}
const cwd =
typeof params.cwd === "string" && params.cwd.trim() ? params.cwd.trim() : undefined;
const env = parseEnvPairs(params.env);
const commandTimeoutMs = parseTimeoutMs(params.commandTimeoutMs);
const invokeTimeoutMs = parseTimeoutMs(params.invokeTimeoutMs);
const needsScreenRecording =
typeof params.needsScreenRecording === "boolean"
? params.needsScreenRecording
: undefined;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "system.run",
params: {
command,
cwd,
env,
timeoutMs: commandTimeoutMs,
needsScreenRecording,
agentId,
sessionKey,
},
timeoutMs: invokeTimeoutMs,
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
return jsonResult(raw?.payload ?? {});
}
default:
throw new Error(`Unknown action: ${action}`);
}
case "camera_list": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "camera.list",
params: {},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
const payload =
raw && typeof raw.payload === "object" && raw.payload !== null ? raw.payload : {};
return jsonResult(payload);
}
case "camera_clip": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const facing = typeof params.facing === "string" ? params.facing.toLowerCase() : "front";
if (facing !== "front" && facing !== "back") {
throw new Error("invalid facing (front|back)");
}
const durationMs =
typeof params.durationMs === "number" && Number.isFinite(params.durationMs)
? params.durationMs
: typeof params.duration === "string"
? parseDurationMs(params.duration)
: 3000;
const includeAudio =
typeof params.includeAudio === "boolean" ? params.includeAudio : true;
const deviceId =
typeof params.deviceId === "string" && params.deviceId.trim()
? params.deviceId.trim()
: undefined;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "camera.clip",
params: {
facing,
durationMs,
includeAudio,
format: "mp4",
deviceId,
},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
const payload = parseCameraClipPayload(raw?.payload);
const filePath = cameraTempPath({
kind: "clip",
facing,
ext: payload.format,
});
await writeBase64ToFile(filePath, payload.base64);
return {
content: [{ type: "text", text: `FILE:${filePath}` }],
details: {
facing,
path: filePath,
durationMs: payload.durationMs,
hasAudio: payload.hasAudio,
},
};
}
case "screen_record": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const durationMs =
typeof params.durationMs === "number" && Number.isFinite(params.durationMs)
? params.durationMs
: typeof params.duration === "string"
? parseDurationMs(params.duration)
: 10_000;
const fps =
typeof params.fps === "number" && Number.isFinite(params.fps) ? params.fps : 10;
const screenIndex =
typeof params.screenIndex === "number" && Number.isFinite(params.screenIndex)
? params.screenIndex
: 0;
const includeAudio =
typeof params.includeAudio === "boolean" ? params.includeAudio : true;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "screen.record",
params: {
durationMs,
screenIndex,
fps,
format: "mp4",
includeAudio,
},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
const payload = parseScreenRecordPayload(raw?.payload);
const filePath =
typeof params.outPath === "string" && params.outPath.trim()
? params.outPath.trim()
: screenRecordTempPath({ ext: payload.format || "mp4" });
const written = await writeScreenRecordToFile(filePath, payload.base64);
return {
content: [{ type: "text", text: `FILE:${written.path}` }],
details: {
path: written.path,
durationMs: payload.durationMs,
fps: payload.fps,
screenIndex: payload.screenIndex,
hasAudio: payload.hasAudio,
},
};
}
case "location_get": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const maxAgeMs =
typeof params.maxAgeMs === "number" && Number.isFinite(params.maxAgeMs)
? params.maxAgeMs
: undefined;
const desiredAccuracy =
params.desiredAccuracy === "coarse" ||
params.desiredAccuracy === "balanced" ||
params.desiredAccuracy === "precise"
? params.desiredAccuracy
: undefined;
const locationTimeoutMs =
typeof params.locationTimeoutMs === "number" &&
Number.isFinite(params.locationTimeoutMs)
? params.locationTimeoutMs
: undefined;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "location.get",
params: {
maxAgeMs,
desiredAccuracy,
timeoutMs: locationTimeoutMs,
},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
return jsonResult(raw?.payload ?? {});
}
case "run": {
const node = readStringParam(params, "node", { required: true });
const nodes = await listNodes(gatewayOpts);
if (nodes.length === 0) {
throw new Error(
"system.run requires a paired companion app or node host (no nodes available).",
);
}
const nodeId = resolveNodeIdFromList(nodes, node);
const nodeInfo = nodes.find((entry) => entry.nodeId === nodeId);
const supportsSystemRun = Array.isArray(nodeInfo?.commands)
? nodeInfo?.commands?.includes("system.run")
: false;
if (!supportsSystemRun) {
throw new Error(
"system.run requires a companion app or node host; the selected node does not support system.run.",
);
}
const commandRaw = params.command;
if (!commandRaw) {
throw new Error("command required (argv array, e.g. ['echo', 'Hello'])");
}
if (!Array.isArray(commandRaw)) {
throw new Error("command must be an array of strings (argv), e.g. ['echo', 'Hello']");
}
const command = commandRaw.map((c) => String(c));
if (command.length === 0) {
throw new Error("command must not be empty");
}
const cwd =
typeof params.cwd === "string" && params.cwd.trim() ? params.cwd.trim() : undefined;
const env = parseEnvPairs(params.env);
const commandTimeoutMs = parseTimeoutMs(params.commandTimeoutMs);
const invokeTimeoutMs = parseTimeoutMs(params.invokeTimeoutMs);
const needsScreenRecording =
typeof params.needsScreenRecording === "boolean"
? params.needsScreenRecording
: undefined;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "system.run",
params: {
command,
cwd,
env,
timeoutMs: commandTimeoutMs,
needsScreenRecording,
agentId,
sessionKey,
},
timeoutMs: invokeTimeoutMs,
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
return jsonResult(raw?.payload ?? {});
}
default:
throw new Error(`Unknown action: ${action}`);
} catch (err) {
const nodeLabel =
typeof params.node === "string" && params.node.trim() ? params.node.trim() : "auto";
const gatewayLabel =
gatewayOpts.gatewayUrl && gatewayOpts.gatewayUrl.trim()
? gatewayOpts.gatewayUrl.trim()
: "default";
const agentLabel = agentId ?? "unknown";
const message = err instanceof Error ? err.message : String(err);
throw new Error(
`agent=${agentLabel} node=${nodeLabel} gateway=${gatewayLabel} action=${action}: ${message}`,
);
}
},
};