mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:10:43 +00:00
fix(agents): avoid empty Anthropic tool result blocks
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, string>;
|
||||
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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
10
src/agents/bash-tools.exec-output.ts
Normal file
10
src/agents/bash-tools.exec-output.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g,
|
||||
@@ -26,6 +28,14 @@ export function sanitizeTransportPayloadText(text: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
export function sanitizeNonEmptyTransportPayloadText(
|
||||
text: string,
|
||||
fallback = EMPTY_TOOL_RESULT_TEXT,
|
||||
): string {
|
||||
const sanitized = sanitizeTransportPayloadText(text);
|
||||
return sanitized.trim().length > 0 ? sanitized : fallback;
|
||||
}
|
||||
|
||||
export function coerceTransportToolCallArguments(argumentsValue: unknown): Record<string, unknown> {
|
||||
if (argumentsValue && typeof argumentsValue === "object" && !Array.isArray(argumentsValue)) {
|
||||
return argumentsValue as Record<string, unknown>;
|
||||
|
||||
Reference in New Issue
Block a user