fix(agents): include filenames in image resize logs

This commit is contained in:
Peter Steinberger
2026-02-21 13:16:23 +00:00
parent 3cfb402bda
commit 2706cbd6d7
3 changed files with 160 additions and 2 deletions

View File

@@ -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<Buffer> {
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");
});
});

View File

@@ -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<string, unknown>;
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,