From 993fee40660178b17ec827a1f1afa13718889339 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 02:57:12 +0100 Subject: [PATCH] fix(agents): avoid empty Anthropic tool result blocks --- CHANGELOG.md | 1 + src/agents/anthropic-transport-stream.test.ts | 107 ++++++++++++++++++ src/agents/anthropic-transport-stream.ts | 40 ++++--- .../bash-tools.exec-host-node-phases.ts | 3 +- src/agents/bash-tools.exec-host-node.test.ts | 40 +++++++ src/agents/bash-tools.exec-output.ts | 10 ++ src/agents/bash-tools.exec-runtime.test.ts | 24 ++++ src/agents/bash-tools.exec-runtime.ts | 8 +- src/agents/bash-tools.exec.ts | 3 +- src/agents/transport-stream-shared.test.ts | 16 +++ src/agents/transport-stream-shared.ts | 10 ++ 11 files changed, 242 insertions(+), 20 deletions(-) create mode 100644 src/agents/bash-tools.exec-output.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 76fb0a32ce6..2126f9bdea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai - CLI/memory: skip eager context-window warmup for `openclaw memory` commands so memory search does not race unrelated model metadata discovery. Fixes #73123. Thanks @oalansilva and @neeravmakwana. - CLI/Telegram: route Telegram `message send` and poll actions through the running Gateway when available, so packaged installs use the staged `grammy` runtime deps and CLI sends return instead of hanging after the Telegram channel is active. Fixes #73140. Thanks @oalansilva. - Plugins/runtime deps: prepare staged bundled plugin dependencies before loading packaged public surfaces, so OpenClaw's Telegram runtime/test facade loads resolve `grammy` from the managed runtime-deps stage without copying dependencies into the global package root. Refs #73140. Thanks @oalansilva. +- Agents/exec: emit `(no output)` for silent exec update and node-host result blocks so Anthropic-compatible providers no longer reject empty tool-result text after quiet commands. Fixes #73117. Thanks @pfrederiksen and @Sanjays2402. - Cron/providers: preflight local Ollama and OpenAI-compatible provider endpoints before isolated cron agent turns, record unreachable local providers as skipped runs, and cache dead-endpoint probes so many jobs do not hammer the same stopped local server. Fixes #58584. Thanks @jpeghead. - Gateway/config: let config reload continue in degraded mode when invalidity is scoped to plugin entries, so incompatible plugin configs can be skipped and the Gateway restart can still pick up the rest of the config after rollbacks. Fixes #73131. Thanks @Adam-Researchh. - Doctor/channels: suppress disabled bundled-plugin blocker warnings when a trusted external plugin owns the configured channel, so Lark/Feishu installs no longer get Feishu repair noise after switching to `openclaw-lark`. Fixes #56794. Thanks @wuji-tech-dev. diff --git a/src/agents/anthropic-transport-stream.test.ts b/src/agents/anthropic-transport-stream.test.ts index ea1e7b7d74e..a07cd59ea8b 100644 --- a/src/agents/anthropic-transport-stream.test.ts +++ b/src/agents/anthropic-transport-stream.test.ts @@ -407,6 +407,113 @@ describe("anthropic transport stream", () => { ); }); + it.each([ + ["empty", ""], + ["whitespace-only", " \n\t "], + ["invalid-surrogate-only", String.fromCharCode(0xd83d)], + ])("replaces %s text-only tool results with a non-empty payload", async (_label, text) => { + await runTransportStream( + makeAnthropicTransportModel(), + { + messages: [ + { + role: "assistant", + provider: "anthropic", + api: "anthropic-messages", + model: "claude-sonnet-4-6", + stopReason: "toolUse", + timestamp: 0, + content: [{ type: "toolCall", id: "tool_1", name: "quiet", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "tool_1", + content: [{ type: "text", text }], + isError: false, + }, + ], + } as AnthropicStreamContext, + { + apiKey: "sk-ant-api", + } as AnthropicStreamOptions, + ); + + expect(latestAnthropicRequest().payload.messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: "user", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "tool_result", + tool_use_id: "tool_1", + content: "(no output)", + is_error: false, + }), + ]), + }), + ]), + ); + }); + + it("drops empty text blocks from image tool results before Anthropic payloads", async () => { + const imageData = Buffer.from("image").toString("base64"); + + await runTransportStream( + makeAnthropicTransportModel({ id: "claude-sonnet-4-6" }), + { + messages: [ + { + role: "assistant", + provider: "anthropic", + api: "anthropic-messages", + model: "claude-sonnet-4-6", + stopReason: "toolUse", + timestamp: 0, + content: [{ type: "toolCall", id: "tool_1", name: "screenshot", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "tool_1", + content: [ + { type: "text", text: "" }, + { type: "image", data: imageData, mimeType: "image/png" }, + ], + isError: false, + }, + ], + } as AnthropicStreamContext, + { + apiKey: "sk-ant-api", + } as AnthropicStreamOptions, + ); + + expect(latestAnthropicRequest().payload.messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: "user", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "tool_result", + tool_use_id: "tool_1", + content: [ + { type: "text", text: "(see attached image)" }, + { + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: imageData, + }, + }, + ], + is_error: false, + }), + ]), + }), + ]), + ); + }); + it("maps adaptive thinking effort for Claude 4.6 transport runs", async () => { const model = makeAnthropicTransportModel({ id: "claude-opus-4-6", diff --git a/src/agents/anthropic-transport-stream.ts b/src/agents/anthropic-transport-stream.ts index 1e6e090addd..a91c8ee3fbd 100644 --- a/src/agents/anthropic-transport-stream.ts +++ b/src/agents/anthropic-transport-stream.ts @@ -24,6 +24,7 @@ import { failTransportStream, finalizeTransportStream, mergeTransportHeaders, + sanitizeNonEmptyTransportPayloadText, sanitizeTransportPayloadText, } from "./transport-stream-shared.js"; @@ -50,7 +51,6 @@ const CLAUDE_CODE_TOOLS = [ const CLAUDE_CODE_TOOL_LOOKUP = new Map( CLAUDE_CODE_TOOLS.map((tool) => [normalizeLowercaseStringOrEmpty(tool), tool]), ); - type AnthropicTransportModel = Model<"anthropic-messages"> & { headers?: Record; provider: string; @@ -215,26 +215,34 @@ function convertContentBlocks( ) { const hasImages = content.some((item) => item.type === "image"); if (!hasImages) { - return sanitizeTransportPayloadText( + return sanitizeNonEmptyTransportPayloadText( content.map((item) => ("text" in item ? item.text : "")).join("\n"), ); } - const blocks = content.map((block) => { + const blocks: Array< + | { type: "text"; text: string } + | { + type: "image"; + source: { type: "base64"; media_type: string; data: string }; + } + > = []; + for (const block of content) { if (block.type === "text") { - return { - type: "text", - text: sanitizeTransportPayloadText(block.text), - }; + const text = sanitizeTransportPayloadText(block.text); + if (text.trim().length > 0) { + blocks.push({ type: "text", text }); + } + } else { + blocks.push({ + type: "image" as const, + source: { + type: "base64", + media_type: block.mimeType, + data: block.data, + }, + }); } - return { - type: "image", - source: { - type: "base64", - media_type: block.mimeType, - data: block.data, - }, - }; - }); + } if (!blocks.some((block) => block.type === "text")) { blocks.unshift({ type: "text", diff --git a/src/agents/bash-tools.exec-host-node-phases.ts b/src/agents/bash-tools.exec-host-node-phases.ts index 0053cb9a5a6..08bd0fd5144 100644 --- a/src/agents/bash-tools.exec-host-node-phases.ts +++ b/src/agents/bash-tools.exec-host-node-phases.ts @@ -18,6 +18,7 @@ import { parsePreparedSystemRunPayload } from "../infra/system-run-approval-cont import { formatExecCommand, resolveSystemRunCommandRequest } from "../infra/system-run-command.js"; import { normalizeNullableString } from "../shared/string-coerce.js"; import type { ExecuteNodeHostCommandParams } from "./bash-tools.exec-host-node.types.js"; +import { renderExecOutputText } from "./bash-tools.exec-output.js"; import type { ExecToolDetails } from "./bash-tools.exec-types.js"; import { callGatewayTool } from "./tools/gateway.js"; import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js"; @@ -78,7 +79,7 @@ export function formatNodeRunToolResult(params: { content: [ { type: "text", - text: stdout || stderr || errorText || "", + text: renderExecOutputText(stdout || stderr || errorText), }, ], details: { diff --git a/src/agents/bash-tools.exec-host-node.test.ts b/src/agents/bash-tools.exec-host-node.test.ts index 80485226c9e..08e9696d34e 100644 --- a/src/agents/bash-tools.exec-host-node.test.ts +++ b/src/agents/bash-tools.exec-host-node.test.ts @@ -399,6 +399,46 @@ describe("executeNodeHostCommand", () => { ); }); + it("returns a non-empty placeholder for silent node exec results", async () => { + callGatewayToolMock.mockImplementationOnce( + async (method: string, _options: unknown, params: MockNodeInvokeParams | undefined) => { + if (method === "node.invoke" && params?.command === "system.run") { + return { + payload: { + success: true, + stdout: "", + stderr: "", + exitCode: 0, + timedOut: false, + }, + }; + } + throw new Error(`unexpected node invoke command: ${String(params?.command)}`); + }, + ); + + const result = await executeNodeHostCommand({ + command: "mkdir /tmp/quiet", + workdir: "/tmp/work", + env: {}, + security: "full", + ask: "off", + defaultTimeoutSec: 30, + approvalRunningNoticeMs: 0, + warnings: [], + agentId: "requested-agent", + sessionKey: "requested-session", + }); + + expect(result.content).toEqual([{ type: "text", text: "(no output)" }]); + expect(result.details).toMatchObject({ + status: "completed", + exitCode: 0, + aggregated: "", + cwd: "/tmp/work", + }); + }); + it("forwards explicit timeouts to node system.run", async () => { await executeNodeHostCommand({ command: "bun ./script.ts", diff --git a/src/agents/bash-tools.exec-output.ts b/src/agents/bash-tools.exec-output.ts new file mode 100644 index 00000000000..b20b80f0c88 --- /dev/null +++ b/src/agents/bash-tools.exec-output.ts @@ -0,0 +1,10 @@ +export const EXEC_NO_OUTPUT_PLACEHOLDER = "(no output)"; + +export function renderExecOutputText(value: string | undefined): string { + return value || EXEC_NO_OUTPUT_PLACEHOLDER; +} + +export function renderExecUpdateText(params: { tailText?: string; warnings: string[] }): string { + const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : ""; + return warningText + renderExecOutputText(params.tailText); +} diff --git a/src/agents/bash-tools.exec-runtime.test.ts b/src/agents/bash-tools.exec-runtime.test.ts index b2c96c3ae7b..8f61bfc0e8d 100644 --- a/src/agents/bash-tools.exec-runtime.test.ts +++ b/src/agents/bash-tools.exec-runtime.test.ts @@ -25,6 +25,7 @@ let buildExecExitOutcome: typeof import("./bash-tools.exec-runtime.js").buildExe let detectCursorKeyMode: typeof import("./bash-tools.exec-runtime.js").detectCursorKeyMode; let emitExecSystemEvent: typeof import("./bash-tools.exec-runtime.js").emitExecSystemEvent; let formatExecFailureReason: typeof import("./bash-tools.exec-runtime.js").formatExecFailureReason; +let renderExecUpdateText: typeof import("./bash-tools.exec-runtime.js").renderExecUpdateText; let resolveExecTarget: typeof import("./bash-tools.exec-runtime.js").resolveExecTarget; let runExecProcess: typeof import("./bash-tools.exec-runtime.js").runExecProcess; @@ -35,6 +36,7 @@ beforeAll(async () => { detectCursorKeyMode, emitExecSystemEvent, formatExecFailureReason, + renderExecUpdateText, resolveExecTarget, runExecProcess, } = await import("./bash-tools.exec-runtime.js")); @@ -314,6 +316,28 @@ describe("resolveExecTarget", () => { }); }); +describe("renderExecUpdateText", () => { + it("uses a non-empty placeholder when an exec update has no output", () => { + expect(renderExecUpdateText({ tailText: "", warnings: [] })).toBe("(no output)"); + }); + + it("preserves non-empty exec output", () => { + expect(renderExecUpdateText({ tailText: "hello", warnings: [] })).toBe("hello"); + }); + + it("keeps warnings while still avoiding empty output text", () => { + expect(renderExecUpdateText({ tailText: "", warnings: ["Warning: retrying"] })).toBe( + "Warning: retrying\n\n(no output)", + ); + }); + + it("combines warnings with non-empty output", () => { + expect(renderExecUpdateText({ tailText: "hello", warnings: ["Warning: retrying"] })).toBe( + "Warning: retrying\n\nhello", + ); + }); +}); + describe("exec notifyOnExit suppression", () => { async function runBackgroundedExit(params: { reason: "manual-cancel" | "overall-timeout"; diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 2b66c11d028..2ea8c9788e5 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -38,6 +38,7 @@ import { markExited, tail, } from "./bash-process-registry.js"; +import { renderExecUpdateText } from "./bash-tools.exec-output.js"; import { buildDockerExecArgs, chunkString, @@ -426,6 +427,8 @@ export function emitExecSystemEvent( ); } +export { renderExecUpdateText } from "./bash-tools.exec-output.js"; + function joinExecFailureOutput(aggregated: string, reason: string) { return aggregated ? `${aggregated}\n\n${reason}` : reason; } @@ -615,7 +618,6 @@ export async function runExecProcess(opts: { return; } const tailText = session.tail || session.aggregated; - const warningText = opts.warnings.length ? `${opts.warnings.join("\n")}\n\n` : ""; // Note: opts.onUpdate() is provided by pi-agent-core's agent-loop and // internally pushes Promise.resolve(emit(event)) into an updateEvents // array. Because emit → processEvents is async, any failure (e.g. @@ -626,7 +628,9 @@ export async function runExecProcess(opts: { // signal (Layer 2) — both of which prevent this call from ever being // reached after the agent run has ended. opts.onUpdate({ - content: [{ type: "text", text: warningText + (tailText || "") }], + content: [ + { type: "text", text: renderExecUpdateText({ tailText, warnings: opts.warnings }) }, + ], details: { status: "running", sessionId, diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 196326ef1ec..871fcb947f0 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -28,6 +28,7 @@ import { markBackgrounded } from "./bash-process-registry.js"; import { describeExecTool } from "./bash-tools.descriptions.js"; import { processGatewayAllowlist } from "./bash-tools.exec-host-gateway.js"; import { executeNodeHostCommand } from "./bash-tools.exec-host-node.js"; +import { renderExecOutputText } from "./bash-tools.exec-output.js"; import { DEFAULT_MAX_OUTPUT, DEFAULT_PATH, @@ -84,7 +85,7 @@ function buildExecForegroundResult(params: { cwd: params.cwd, }); } - return textResult(`${warningText}${params.outcome.aggregated || "(no output)"}`, { + return textResult(`${warningText}${renderExecOutputText(params.outcome.aggregated)}`, { status: "completed", exitCode: params.outcome.exitCode, durationMs: params.outcome.durationMs, diff --git a/src/agents/transport-stream-shared.test.ts b/src/agents/transport-stream-shared.test.ts index bb6fdf28fcb..f22c2dbf864 100644 --- a/src/agents/transport-stream-shared.test.ts +++ b/src/agents/transport-stream-shared.test.ts @@ -3,6 +3,7 @@ import { failTransportStream, finalizeTransportStream, mergeTransportHeaders, + sanitizeNonEmptyTransportPayloadText, sanitizeTransportPayloadText, } from "./transport-stream-shared.js"; @@ -16,6 +17,21 @@ describe("transport stream shared helpers", () => { expect(sanitizeTransportPayloadText("emoji 🙈 ok")).toBe("emoji 🙈 ok"); }); + it.each([ + ["empty", ""], + ["whitespace-only", " \n\t "], + ["invalid-surrogate-only", String.fromCharCode(0xd83d)], + ])("falls back for %s tool payload text", (_label, value) => { + expect(sanitizeNonEmptyTransportPayloadText(value)).toBe("(no output)"); + }); + + it("preserves non-empty sanitized tool payload text", () => { + expect(sanitizeNonEmptyTransportPayloadText(" ok ")).toBe(" ok "); + expect(sanitizeNonEmptyTransportPayloadText(`left${String.fromCharCode(0xd83d)}right`)).toBe( + "leftright", + ); + }); + it("merges transport headers in source order", () => { expect( mergeTransportHeaders( diff --git a/src/agents/transport-stream-shared.ts b/src/agents/transport-stream-shared.ts index 7b5508d004e..e0b87d2bc17 100644 --- a/src/agents/transport-stream-shared.ts +++ b/src/agents/transport-stream-shared.ts @@ -19,6 +19,8 @@ type TransportOutputShape = { errorMessage?: string; }; +export const EMPTY_TOOL_RESULT_TEXT = "(no output)"; + export function sanitizeTransportPayloadText(text: string): string { return text.replace( /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? 0 ? sanitized : fallback; +} + export function coerceTransportToolCallArguments(argumentsValue: unknown): Record { if (argumentsValue && typeof argumentsValue === "object" && !Array.isArray(argumentsValue)) { return argumentsValue as Record;