From 2706cbd6d7323a55f5bead846579469f84a929b1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 21 Feb 2026 13:16:23 +0000 Subject: [PATCH] fix(agents): include filenames in image resize logs --- CHANGELOG.md | 1 + src/agents/tool-images.log.test.ts | 66 +++++++++++++++++++++ src/agents/tool-images.ts | 95 +++++++++++++++++++++++++++++- 3 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 src/agents/tool-images.log.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cbecea3879..c24316adefe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai ### Fixes - WhatsApp/Security: enforce allowlist JID authorization for reaction actions so authenticated callers cannot target non-allowlisted chats by forging `chatJid` + valid `messageId` pairs. This ships in the next npm release. Thanks @aether-ai-agent for reporting. +- Agents/Tool images: include source filenames in `agents/tool-images` resize logs so compression events can be traced back to specific files. - ACP/Security: escape control and delimiter characters in ACP `resource_link` title/URI metadata before prompt interpolation to prevent metadata-driven prompt injection through resource links. This ships in the next npm release. Thanks @aether-ai-agent for reporting. - TTS/Security: make model-driven provider switching opt-in by default (`messages.tts.modelOverrides.allowProvider=false` unless explicitly enabled), while keeping voice/style overrides available, to reduce prompt-injection-driven provider hops and unexpected TTS cost escalation. This ships in the next npm release. Thanks @aether-ai-agent for reporting. - Security/Agents: keep overflow compaction retry budgeting global across tool-result truncation recovery so successful truncation cannot reset the overflow retry counter and amplify retry/cost cycles. This ships in the next npm release. Thanks @aether-ai-agent for reporting. diff --git a/src/agents/tool-images.log.test.ts b/src/agents/tool-images.log.test.ts new file mode 100644 index 00000000000..e89192b7fb6 --- /dev/null +++ b/src/agents/tool-images.log.test.ts @@ -0,0 +1,66 @@ +import sharp from "sharp"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { infoMock, warnMock } = vi.hoisted(() => ({ + infoMock: vi.fn(), + warnMock: vi.fn(), +})); + +vi.mock("../logging/subsystem.js", () => { + const makeLogger = () => ({ + subsystem: "agents/tool-images", + isEnabled: () => true, + trace: vi.fn(), + debug: vi.fn(), + info: infoMock, + warn: warnMock, + error: vi.fn(), + fatal: vi.fn(), + raw: vi.fn(), + child: () => makeLogger(), + }); + return { createSubsystemLogger: () => makeLogger() }; +}); + +import { sanitizeContentBlocksImages } from "./tool-images.js"; + +async function createLargePng(): Promise { + const width = 2400; + const height = 680; + const raw = Buffer.alloc(width * height * 3, 0x7f); + return await sharp(raw, { + raw: { width, height, channels: 3 }, + }) + .png({ compressionLevel: 0 }) + .toBuffer(); +} + +describe("tool-images log context", () => { + beforeEach(() => { + infoMock.mockClear(); + warnMock.mockClear(); + }); + + it("includes filename from MEDIA text", async () => { + const png = await createLargePng(); + const blocks = [ + { type: "text" as const, text: "MEDIA:/tmp/snapshots/camera-front.png" }, + { type: "image" as const, data: png.toString("base64"), mimeType: "image/png" }, + ]; + await sanitizeContentBlocksImages(blocks, "nodes:camera_snap"); + const message = infoMock.mock.calls[0]?.[0]; + expect(typeof message).toBe("string"); + expect(String(message)).toContain("camera-front.png"); + }); + + it("includes filename from read label", async () => { + const png = await createLargePng(); + const blocks = [ + { type: "image" as const, data: png.toString("base64"), mimeType: "image/png" }, + ]; + await sanitizeContentBlocksImages(blocks, "read:/tmp/images/sample-diagram.png"); + const message = infoMock.mock.calls[0]?.[0]; + expect(typeof message).toBe("string"); + expect(String(message)).toContain("sample-diagram.png"); + }); +}); diff --git a/src/agents/tool-images.ts b/src/agents/tool-images.ts index e209a9e6f1d..a72fed30c28 100644 --- a/src/agents/tool-images.ts +++ b/src/agents/tool-images.ts @@ -70,12 +70,87 @@ function formatBytesShort(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(2)}MB`; } +function parseMediaPathFromText(text: string): string | undefined { + for (const line of text.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (!trimmed.startsWith("MEDIA:")) { + continue; + } + const raw = trimmed.slice("MEDIA:".length).trim(); + if (!raw) { + continue; + } + const backtickWrapped = raw.match(/^`([^`]+)`$/u); + return (backtickWrapped?.[1] ?? raw).trim(); + } + return undefined; +} + +function fileNameFromPathLike(pathLike: string): string | undefined { + const value = pathLike.trim(); + if (!value) { + return undefined; + } + + try { + const url = new URL(value); + const candidate = url.pathname.split("/").filter(Boolean).at(-1); + return candidate && candidate.length > 0 ? candidate : undefined; + } catch { + // Not a URL; continue with path-like parsing. + } + + const normalized = value.replaceAll("\\", "/"); + const candidate = normalized.split("/").filter(Boolean).at(-1); + return candidate && candidate.length > 0 ? candidate : undefined; +} + +function inferImageFileName(params: { + block: ImageContentBlock; + label?: string; + mediaPathHint?: string; +}): string | undefined { + const rec = params.block as unknown as Record; + const explicitKeys = ["fileName", "filename", "path", "url"] as const; + for (const key of explicitKeys) { + const raw = rec[key]; + if (typeof raw !== "string" || raw.trim().length === 0) { + continue; + } + const candidate = fileNameFromPathLike(raw); + if (candidate) { + return candidate; + } + } + + if (typeof rec.name === "string" && rec.name.trim().length > 0) { + return rec.name.trim(); + } + + if (params.mediaPathHint) { + const candidate = fileNameFromPathLike(params.mediaPathHint); + if (candidate) { + return candidate; + } + } + + if (typeof params.label === "string" && params.label.startsWith("read:")) { + const candidate = fileNameFromPathLike(params.label.slice("read:".length)); + if (candidate) { + return candidate; + } + } + + return undefined; +} + async function resizeImageBase64IfNeeded(params: { base64: string; mimeType: string; maxDimensionPx: number; maxBytes: number; label?: string; + fileName?: string; }): Promise<{ base64: string; mimeType: string; @@ -127,14 +202,18 @@ async function resizeImageBase64IfNeeded(params: { typeof width === "number" && typeof height === "number" ? `${width}x${height}px` : "unknown"; + const sourceWithFile = params.fileName + ? `${params.fileName} ${sourcePixels}` + : sourcePixels; const byteReductionPct = buf.byteLength > 0 ? Number((((buf.byteLength - out.byteLength) / buf.byteLength) * 100).toFixed(1)) : 0; log.info( - `Image resized to fit limits: ${sourcePixels} ${formatBytesShort(buf.byteLength)} -> ${formatBytesShort(out.byteLength)} (-${byteReductionPct}%)`, + `Image resized to fit limits: ${sourceWithFile} ${formatBytesShort(buf.byteLength)} -> ${formatBytesShort(out.byteLength)} (-${byteReductionPct}%)`, { label: params.label, + fileName: params.fileName, sourceMimeType: params.mimeType, sourceWidth: width, sourceHeight: height, @@ -166,10 +245,12 @@ async function resizeImageBase64IfNeeded(params: { const gotMb = (best.byteLength / (1024 * 1024)).toFixed(2); const sourcePixels = typeof width === "number" && typeof height === "number" ? `${width}x${height}px` : "unknown"; + const sourceWithFile = params.fileName ? `${params.fileName} ${sourcePixels}` : sourcePixels; log.warn( - `Image resize failed to fit limits: ${sourcePixels} best=${formatBytesShort(best.byteLength)} limit=${formatBytesShort(params.maxBytes)}`, + `Image resize failed to fit limits: ${sourceWithFile} best=${formatBytesShort(best.byteLength)} limit=${formatBytesShort(params.maxBytes)}`, { label: params.label, + fileName: params.fileName, sourceMimeType: params.mimeType, sourceWidth: width, sourceHeight: height, @@ -192,8 +273,16 @@ export async function sanitizeContentBlocksImages( const maxDimensionPx = Math.max(opts.maxDimensionPx ?? MAX_IMAGE_DIMENSION_PX, 1); const maxBytes = Math.max(opts.maxBytes ?? MAX_IMAGE_BYTES, 1); const out: ToolContentBlock[] = []; + let mediaPathHint: string | undefined; for (const block of blocks) { + if (isTextBlock(block)) { + const mediaPath = parseMediaPathFromText(block.text); + if (mediaPath) { + mediaPathHint = mediaPath; + } + } + if (!isImageBlock(block)) { out.push(block); continue; @@ -211,12 +300,14 @@ export async function sanitizeContentBlocksImages( try { const inferredMimeType = inferMimeTypeFromBase64(data); const mimeType = inferredMimeType ?? block.mimeType; + const fileName = inferImageFileName({ block, label, mediaPathHint }); const resized = await resizeImageBase64IfNeeded({ base64: data, mimeType, maxDimensionPx, maxBytes, label, + fileName, }); out.push({ ...block,