mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 03:00:21 +00:00
fix(agents): include filenames in image resize logs
This commit is contained in:
66
src/agents/tool-images.log.test.ts
Normal file
66
src/agents/tool-images.log.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user