Skip to content

Commit 8d168c8

Browse files
snowzlmbotPeter Steinberger
andauthored
fix(agents): preserve structured tool result visible text (#97268)
* fix(agents): preserve structured tool result visible text * fix(agents): address structured tool result review blockers * fix(agents): cover real structured tool result shapes * fix(agents): preserve canonical tool error statuses * fix(agents): harden structured result rendering * fix(agents): preserve typed structured results * fix(agents): bound provider result media --------- Co-authored-by: snowzlmbot <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent d17b970 commit 8d168c8

3 files changed

Lines changed: 309 additions & 6 deletions

File tree

src/agents/embedded-agent-subscribe.tools.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
44
import * as loggingConfigModule from "../logging/config.js";
55
import {
66
buildToolLifecycleErrorResult,
7+
extractToolResultText,
78
extractToolErrorCode,
89
extractToolErrorMessage,
910
isToolResultError,
@@ -27,6 +28,12 @@ describe("extractToolErrorMessage", () => {
2728
it("keeps error-like status values", () => {
2829
expect(extractToolErrorMessage({ details: { status: "failed" } })).toBe("failed");
2930
expect(extractToolErrorMessage({ details: { status: "timeout" } })).toBe("timeout");
31+
expect(
32+
extractToolErrorMessage({
33+
content: [{ type: "text", text: "Approval is unavailable." }],
34+
details: { status: "approval-unavailable" },
35+
}),
36+
).toBe("Approval is unavailable.");
3037
});
3138

3239
it("prefers node-host aggregated denial text over generic failed status", () => {
@@ -388,3 +395,151 @@ describe("sanitizeToolArgs", () => {
388395
});
389396
});
390397
});
398+
399+
describe("extractToolResultText", () => {
400+
it("serializes structured non-image tool result blocks for visible output", () => {
401+
const text = extractToolResultText({
402+
content: [
403+
{ type: "json", data: { status: "ok", value: 42 } },
404+
{ type: "resource", resource: { uri: "file:///tmp/result.json", text: "payload" } },
405+
],
406+
});
407+
408+
expect(text).toContain('"type":"json"');
409+
expect(text).toContain('"status":"ok"');
410+
expect(text).toContain('"type":"resource"');
411+
expect(text).not.toContain("see attached image");
412+
});
413+
414+
it("normalizes top-level CLI result arrays and objects", () => {
415+
expect(
416+
extractToolResultText([
417+
{ type: "web_search_result", title: "OpenClaw", url: "https://example.com" },
418+
]),
419+
).toContain('"title":"OpenClaw"');
420+
expect(extractToolResultText([{ type: "text", text: "hello" }])).toBe("hello");
421+
expect(
422+
extractToolResultText({ type: "web_search_tool_result_error", error_code: "unavailable" }),
423+
).toContain('"error_code":"unavailable"');
424+
expect(
425+
extractToolResultText({
426+
type: "code_execution_result",
427+
content: [],
428+
return_code: 0,
429+
stderr: "",
430+
stdout: "command output",
431+
}),
432+
).toContain('"stdout":"command output"');
433+
});
434+
435+
it("keeps existing text blocks and skips image blocks", () => {
436+
const text = extractToolResultText({
437+
content: [
438+
{ type: "text", text: "hello" },
439+
{ type: "image", data: "abc", mimeType: "image/png" },
440+
],
441+
});
442+
443+
expect(text).toBe("hello");
444+
});
445+
446+
it("keeps existing text output before structured fallback", () => {
447+
const text = extractToolResultText({
448+
content: [
449+
{ type: "text", text: "hello" },
450+
{ type: "json", data: { status: "ok" } },
451+
],
452+
});
453+
454+
expect(text).toBe("hello");
455+
});
456+
457+
it("caps top-level text arrays", () => {
458+
const text = extractToolResultText([{ type: "text", text: "x".repeat(9000) }]);
459+
460+
expect(text).toContain("…(truncated)…");
461+
expect(text?.length).toBeLessThanOrEqual(8020);
462+
});
463+
464+
it("redacts whole data URI values without rewriting ordinary data substrings", () => {
465+
const text = extractToolResultText({
466+
content: [
467+
{
468+
type: "json",
469+
note: "metadata:foo",
470+
uri: "data:text/plain;base64,abcdefghijklmnopqrstuvwxyz0123456789",
471+
},
472+
],
473+
});
474+
475+
expect(text).toContain('"note":"metadata:foo"');
476+
expect(text).toContain('"uri":"[inline data URI:');
477+
expect(text).not.toContain("abcdefghijklmnopqrstuvwxyz0123456789");
478+
});
479+
480+
it("suppresses MCP binary fields and structured secrets", () => {
481+
const text = extractToolResultText({
482+
content: [
483+
{ type: "audio", data: "audio-base64-secret", mimeType: "audio/mpeg" },
484+
{
485+
type: "document",
486+
source: {
487+
type: "base64",
488+
media_type: "application/pdf",
489+
data: "document-base64-secret",
490+
},
491+
},
492+
{
493+
type: "resource",
494+
apiKey: "sk-structured-secret-1234567890",
495+
resource: {
496+
uri: "blob://result",
497+
blob: "resource-base64-secret",
498+
mimeType: "application/pdf",
499+
},
500+
},
501+
],
502+
});
503+
504+
expect(text).toContain('"uri":"blob://result"');
505+
expect(text).toContain('"blob":"[binary omitted:');
506+
expect(text).not.toContain("audio-base64-secret");
507+
expect(text).not.toContain("document-base64-secret");
508+
expect(text).not.toContain("resource-base64-secret");
509+
expect(text).not.toContain("sk-structured-secret-1234567890");
510+
});
511+
512+
it("redacts structured headers and omits opaque CLI payloads before the output cap", () => {
513+
const text = extractToolResultText([
514+
{
515+
type: "web_search_result",
516+
encrypted_content: "opaque-search-ciphertext".repeat(500),
517+
encrypted_stdout: "opaque-command-ciphertext".repeat(500),
518+
apiKey: ["array-valued-api-secret"],
519+
headers: {
520+
cookie: ["session=structured-cookie-secret"],
521+
"set-cookie": ["sid=structured-set-cookie-secret; HttpOnly"],
522+
},
523+
title: "Useful result",
524+
},
525+
]);
526+
527+
expect(text).toContain('"encrypted_content":"[opaque data omitted:');
528+
expect(text).toContain('"encrypted_stdout":"[opaque data omitted:');
529+
expect(text).toContain('"title":"Useful result"');
530+
expect(text).not.toContain("opaque-search-ciphertext");
531+
expect(text).not.toContain("opaque-command-ciphertext");
532+
expect(text).not.toContain("array-valued-api-secret");
533+
expect(text).not.toContain("structured-cookie-secret");
534+
expect(text).not.toContain("structured-set-cookie-secret");
535+
});
536+
537+
it("caps structured fallback output", () => {
538+
const text = extractToolResultText({
539+
content: [{ type: "json", data: "x".repeat(9000) }],
540+
});
541+
542+
expect(text).toContain("…(truncated)…");
543+
expect(text?.length).toBeLessThanOrEqual(8020);
544+
});
545+
});

src/agents/embedded-agent-subscribe.tools.ts

Lines changed: 136 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,26 @@ import type {
2323
MessagingToolSourceReplyPayload,
2424
} from "./embedded-agent-messaging.types.js";
2525
import { normalizeToolName } from "./tool-policy.js";
26-
import { readToolResultDetails, readToolResultStatus } from "./tool-result-error.js";
26+
import {
27+
isToolResultError,
28+
readToolResultDetails,
29+
readToolResultStatus,
30+
} from "./tool-result-error.js";
2731

28-
export { isToolResultError } from "./tool-result-error.js";
32+
export { isToolResultError };
2933

3034
const TOOL_RESULT_MAX_CHARS = 8000;
3135
const TOOL_ERROR_MAX_CHARS = 400;
3236
const TOOL_DENIAL_ERROR_CODES = ["SYSTEM_RUN_DENIED", "INVALID_REQUEST"] as const;
37+
const OPAQUE_STRUCTURED_RESULT_FIELDS = new Set(["encrypted_content", "encrypted_stdout"]);
38+
const SENSITIVE_STRUCTURED_HEADER_FIELDS = new Set([
39+
"authorization",
40+
"proxy-authorization",
41+
"cookie",
42+
"set-cookie",
43+
"x-api-key",
44+
"x-auth-token",
45+
]);
3346

3447
function truncateToolText(text: string): string {
3548
if (text.length <= TOOL_RESULT_MAX_CHARS) {
@@ -267,21 +280,134 @@ export function sanitizeToolResult(result: unknown): unknown {
267280
return out;
268281
}
269282

283+
function redactInlineDataUriValue(value: string): string {
284+
const trimmed = value.trimStart();
285+
if (!trimmed.toLowerCase().startsWith("data:")) {
286+
return value;
287+
}
288+
return `[inline data URI: ${value.length} chars]`;
289+
}
290+
291+
function carriesBinaryData(record: Record<string, unknown>): boolean {
292+
const type = normalizeOptionalLowercaseString(record.type);
293+
if (type === "audio" || type === "image" || type === "base64") {
294+
return true;
295+
}
296+
const mediaType = normalizeOptionalLowercaseString(record.media_type ?? record.mimeType);
297+
return (
298+
mediaType?.startsWith("image/") === true ||
299+
mediaType?.startsWith("audio/") === true ||
300+
mediaType?.startsWith("video/") === true ||
301+
mediaType === "application/pdf"
302+
);
303+
}
304+
305+
function sanitizeStructuredToolResultValue(
306+
value: unknown,
307+
key = "",
308+
parentCarriesBinaryData = false,
309+
seen = new WeakSet<object>(),
310+
): unknown {
311+
if (typeof value === "string") {
312+
if (SENSITIVE_STRUCTURED_HEADER_FIELDS.has(key.toLowerCase())) {
313+
return "***";
314+
}
315+
if (key === "blob" || (key === "data" && parentCarriesBinaryData)) {
316+
return `[binary omitted: ${value.length} chars]`;
317+
}
318+
// Claude CLI result blocks carry replay-only ciphertext that is not useful display text.
319+
if (OPAQUE_STRUCTURED_RESULT_FIELDS.has(key)) {
320+
return `[opaque data omitted: ${value.length} chars]`;
321+
}
322+
return truncateToolText(redactInlineDataUriValue(redactSensitiveFieldValue(key, value)));
323+
}
324+
if (typeof value === "bigint") {
325+
return value.toString();
326+
}
327+
if (!value || typeof value !== "object") {
328+
return value;
329+
}
330+
if (seen.has(value)) {
331+
return "[Circular]";
332+
}
333+
seen.add(value);
334+
if (Array.isArray(value)) {
335+
// Keep the owning key so arrays of credentials inherit the same redaction policy.
336+
return value.map((item) =>
337+
sanitizeStructuredToolResultValue(item, key, parentCarriesBinaryData, seen),
338+
);
339+
}
340+
const record = value as Record<string, unknown>;
341+
const hasBinaryData = carriesBinaryData(record);
342+
return Object.fromEntries(
343+
Object.entries(record).map(([childKey, child]) => [
344+
childKey,
345+
sanitizeStructuredToolResultValue(child, childKey, hasBinaryData, seen),
346+
]),
347+
);
348+
}
349+
350+
function stringifyStructuredToolResultContent(block: unknown): string | undefined {
351+
if (!block || typeof block !== "object") {
352+
return undefined;
353+
}
354+
const record = block as Record<string, unknown>;
355+
const type = readStringValue(record.type);
356+
if (type === "text" || type === "image" || type === "image_url" || type === "audio") {
357+
return undefined;
358+
}
359+
try {
360+
const serialized = JSON.stringify(sanitizeStructuredToolResultValue(record));
361+
const redacted = serialized ? redactToolPayloadText(serialized) : serialized;
362+
return redacted && redacted !== "{}" ? redacted : undefined;
363+
} catch {
364+
return undefined;
365+
}
366+
}
367+
368+
function resolveToolResultContentBlocks(result: object): unknown[] {
369+
if (Array.isArray(result)) {
370+
return result;
371+
}
372+
const record = result as Record<string, unknown>;
373+
// Typed provider blocks own their `content`; only untyped tool-result envelopes unwrap it.
374+
if (readStringValue(record.type)) {
375+
return [record];
376+
}
377+
if (Array.isArray(record.content)) {
378+
return record.content;
379+
}
380+
if (record.content && typeof record.content === "object") {
381+
return [record.content];
382+
}
383+
return [record];
384+
}
385+
270386
export function extractToolResultText(result: unknown): string | undefined {
271387
if (!result || typeof result !== "object") {
272388
return undefined;
273389
}
274-
const record = result as Record<string, unknown>;
275-
const texts = collectTextContentBlocks(record.content)
390+
const content = resolveToolResultContentBlocks(result);
391+
const texts = collectTextContentBlocks(content)
276392
.map((item) => {
277393
const trimmed = item.trim();
278394
return trimmed ? trimmed : undefined;
279395
})
280396
.filter((value): value is string => Boolean(value));
281-
if (texts.length === 0) {
397+
if (texts.length > 0) {
398+
return truncateToolText(texts.join("\n"));
399+
}
400+
const structuredTexts: string[] = [];
401+
for (const item of content) {
402+
const structured = stringifyStructuredToolResultContent(item);
403+
if (structured) {
404+
structuredTexts.push(structured);
405+
}
406+
}
407+
if (structuredTexts.length === 0) {
282408
return undefined;
283409
}
284-
return texts.join("\n");
410+
return truncateToolText(structuredTexts.join("\n"));
285411
}
286412

287413
function pushUniqueMessagingMediaUrl(urls: string[], seen: Set<string>, value: unknown): void {
@@ -726,6 +852,10 @@ export function extractToolErrorMessage(result: unknown): string | undefined {
726852
if (fromRootStatus) {
727853
return fromRootStatus;
728854
}
855+
const status = readToolResultStatus(result);
856+
if (status && !isToolResultError(result)) {
857+
return undefined;
858+
}
729859
return text ? normalizeToolErrorText(text) : undefined;
730860
}
731861

src/auto-reply/reply/agent-runner-cli-dispatch.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,24 @@ describe("createCliToolSummaryTracker", () => {
263263
expect(payload.text).toContain("Wed Jun 10 2026");
264264
});
265265

266+
it("renders top-level structured CLI results in full verbose output", async () => {
267+
const deliver = vi.fn();
268+
const tracker = createCliToolSummaryTracker({
269+
shouldEmitToolResult: () => true,
270+
shouldEmitToolOutput: () => true,
271+
deliver,
272+
});
273+
await tracker.noteToolEvent(startEvent);
274+
await tracker.noteToolEvent({
275+
...resultEvent,
276+
result: [{ type: "web_search_result", title: "OpenClaw", url: "https://example.com" }],
277+
});
278+
279+
const payload = deliver.mock.calls[0]?.[0] as { text: string };
280+
expect(payload.text).toContain('"type":"web_search_result"');
281+
expect(payload.text).toContain('"title":"OpenClaw"');
282+
});
283+
266284
it("emits nothing while tool summaries are disabled", async () => {
267285
const deliver = vi.fn();
268286
const tracker = createCliToolSummaryTracker({

0 commit comments

Comments
 (0)