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. - Exec approvals: support wildcard agent allowlists (`*`) across all agents.
### Fixes ### 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. - UI: remove the chat stop button and keep the composer aligned to the bottom edge.
## 2026.1.20 ## 2026.1.20

View File

@@ -112,334 +112,349 @@ export function createNodesTool(options?: {
timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : undefined, timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
}; };
switch (action) { try {
case "status": switch (action) {
return jsonResult(await callGatewayTool("node.list", gatewayOpts, {})); case "status":
case "describe": { return jsonResult(await callGatewayTool("node.list", gatewayOpts, {}));
const node = readStringParam(params, "node", { required: true }); case "describe": {
const nodeId = await resolveNodeId(gatewayOpts, node); const node = readStringParam(params, "node", { required: true });
return jsonResult(await callGatewayTool("node.describe", gatewayOpts, { nodeId })); 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");
} }
const nodeId = await resolveNodeId(gatewayOpts, node); case "pending":
await callGatewayTool("node.invoke", gatewayOpts, { return jsonResult(await callGatewayTool("node.pair.list", gatewayOpts, {}));
nodeId, case "approve": {
command: "system.notify", const requestId = readStringParam(params, "requestId", {
params: { required: true,
title: title.trim() || undefined, });
body: body.trim() || undefined, return jsonResult(
sound: typeof params.sound === "string" ? params.sound : undefined, await callGatewayTool("node.pair.approve", gatewayOpts, {
priority: typeof params.priority === "string" ? params.priority : undefined, requestId,
delivery: typeof params.delivery === "string" ? params.delivery : undefined, }),
}, );
idempotencyKey: crypto.randomUUID(), }
}); case "reject": {
return jsonResult({ ok: true }); const requestId = readStringParam(params, "requestId", {
} required: true,
case "camera_snap": { });
const node = readStringParam(params, "node", { required: true }); return jsonResult(
const nodeId = await resolveNodeId(gatewayOpts, node); await callGatewayTool("node.pair.reject", gatewayOpts, {
const facingRaw = requestId,
typeof params.facing === "string" ? params.facing.toLowerCase() : "both"; }),
const facings: CameraFacing[] = );
facingRaw === "both" }
? ["front", "back"] case "notify": {
: facingRaw === "front" || facingRaw === "back" const node = readStringParam(params, "node", { required: true });
? [facingRaw] const title = typeof params.title === "string" ? params.title : "";
: (() => { const body = typeof params.body === "string" ? params.body : "";
throw new Error("invalid facing (front|back|both)"); if (!title.trim() && !body.trim()) {
})(); throw new Error("title or body required");
const maxWidth = }
typeof params.maxWidth === "number" && Number.isFinite(params.maxWidth) const nodeId = await resolveNodeId(gatewayOpts, node);
? params.maxWidth await callGatewayTool("node.invoke", gatewayOpts, {
: undefined; nodeId,
const quality = command: "system.notify",
typeof params.quality === "number" && Number.isFinite(params.quality) params: {
? params.quality title: title.trim() || undefined,
: undefined; body: body.trim() || undefined,
const delayMs = sound: typeof params.sound === "string" ? params.sound : undefined,
typeof params.delayMs === "number" && Number.isFinite(params.delayMs) priority: typeof params.priority === "string" ? params.priority : undefined,
? params.delayMs delivery: typeof params.delivery === "string" ? params.delivery : undefined,
: undefined; },
const deviceId = idempotencyKey: crypto.randomUUID(),
typeof params.deviceId === "string" && params.deviceId.trim() });
? params.deviceId.trim() return jsonResult({ ok: true });
: undefined; }
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 content: AgentToolResult<unknown>["content"] = [];
const details: Array<Record<string, unknown>> = []; 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, { const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId, 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: { params: {
facing, facing,
maxWidth, durationMs,
quality, includeAudio,
format: "jpg", format: "mp4",
delayMs,
deviceId, deviceId,
}, },
idempotencyKey: crypto.randomUUID(), idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown }; })) as { payload?: unknown };
const payload = parseCameraSnapPayload(raw?.payload); const payload = parseCameraClipPayload(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({ const filePath = cameraTempPath({
kind: "snap", kind: "clip",
facing, facing,
ext: isJpeg ? "jpg" : "png", ext: payload.format,
}); });
await writeBase64ToFile(filePath, payload.base64); await writeBase64ToFile(filePath, payload.base64);
content.push({ type: "text", text: `MEDIA:${filePath}` }); return {
content.push({ content: [{ type: "text", text: `FILE:${filePath}` }],
type: "image", details: {
data: payload.base64, facing,
mimeType: path: filePath,
imageMimeFromFormat(payload.format) ?? (isJpeg ? "image/jpeg" : "image/png"), durationMs: payload.durationMs,
}); hasAudio: payload.hasAudio,
details.push({ },
facing, };
path: filePath,
width: payload.width,
height: payload.height,
});
} }
case "screen_record": {
const result: AgentToolResult<unknown> = { content, details }; const node = readStringParam(params, "node", { required: true });
return await sanitizeToolResultImages(result, "nodes:camera_snap"); 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": { } catch (err) {
const node = readStringParam(params, "node", { required: true }); const nodeLabel =
const nodeId = await resolveNodeId(gatewayOpts, node); typeof params.node === "string" && params.node.trim() ? params.node.trim() : "auto";
const raw = (await callGatewayTool("node.invoke", gatewayOpts, { const gatewayLabel =
nodeId, gatewayOpts.gatewayUrl && gatewayOpts.gatewayUrl.trim()
command: "camera.list", ? gatewayOpts.gatewayUrl.trim()
params: {}, : "default";
idempotencyKey: crypto.randomUUID(), const agentLabel = agentId ?? "unknown";
})) as { payload?: unknown }; const message = err instanceof Error ? err.message : String(err);
const payload = throw new Error(
raw && typeof raw.payload === "object" && raw.payload !== null ? raw.payload : {}; `agent=${agentLabel} node=${nodeLabel} gateway=${gatewayLabel} action=${action}: ${message}`,
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}`);
} }
}, },
}; };