mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 17:44:57 +00:00
fix(agents): include filenames in image resize logs
This commit is contained in:
@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
### Fixes
|
### 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
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`;
|
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: {
|
async function resizeImageBase64IfNeeded(params: {
|
||||||
base64: string;
|
base64: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
maxDimensionPx: number;
|
maxDimensionPx: number;
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
fileName?: string;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
base64: string;
|
base64: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
@@ -127,14 +202,18 @@ async function resizeImageBase64IfNeeded(params: {
|
|||||||
typeof width === "number" && typeof height === "number"
|
typeof width === "number" && typeof height === "number"
|
||||||
? `${width}x${height}px`
|
? `${width}x${height}px`
|
||||||
: "unknown";
|
: "unknown";
|
||||||
|
const sourceWithFile = params.fileName
|
||||||
|
? `${params.fileName} ${sourcePixels}`
|
||||||
|
: sourcePixels;
|
||||||
const byteReductionPct =
|
const byteReductionPct =
|
||||||
buf.byteLength > 0
|
buf.byteLength > 0
|
||||||
? Number((((buf.byteLength - out.byteLength) / buf.byteLength) * 100).toFixed(1))
|
? Number((((buf.byteLength - out.byteLength) / buf.byteLength) * 100).toFixed(1))
|
||||||
: 0;
|
: 0;
|
||||||
log.info(
|
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,
|
label: params.label,
|
||||||
|
fileName: params.fileName,
|
||||||
sourceMimeType: params.mimeType,
|
sourceMimeType: params.mimeType,
|
||||||
sourceWidth: width,
|
sourceWidth: width,
|
||||||
sourceHeight: height,
|
sourceHeight: height,
|
||||||
@@ -166,10 +245,12 @@ async function resizeImageBase64IfNeeded(params: {
|
|||||||
const gotMb = (best.byteLength / (1024 * 1024)).toFixed(2);
|
const gotMb = (best.byteLength / (1024 * 1024)).toFixed(2);
|
||||||
const sourcePixels =
|
const sourcePixels =
|
||||||
typeof width === "number" && typeof height === "number" ? `${width}x${height}px` : "unknown";
|
typeof width === "number" && typeof height === "number" ? `${width}x${height}px` : "unknown";
|
||||||
|
const sourceWithFile = params.fileName ? `${params.fileName} ${sourcePixels}` : sourcePixels;
|
||||||
log.warn(
|
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,
|
label: params.label,
|
||||||
|
fileName: params.fileName,
|
||||||
sourceMimeType: params.mimeType,
|
sourceMimeType: params.mimeType,
|
||||||
sourceWidth: width,
|
sourceWidth: width,
|
||||||
sourceHeight: height,
|
sourceHeight: height,
|
||||||
@@ -192,8 +273,16 @@ export async function sanitizeContentBlocksImages(
|
|||||||
const maxDimensionPx = Math.max(opts.maxDimensionPx ?? MAX_IMAGE_DIMENSION_PX, 1);
|
const maxDimensionPx = Math.max(opts.maxDimensionPx ?? MAX_IMAGE_DIMENSION_PX, 1);
|
||||||
const maxBytes = Math.max(opts.maxBytes ?? MAX_IMAGE_BYTES, 1);
|
const maxBytes = Math.max(opts.maxBytes ?? MAX_IMAGE_BYTES, 1);
|
||||||
const out: ToolContentBlock[] = [];
|
const out: ToolContentBlock[] = [];
|
||||||
|
let mediaPathHint: string | undefined;
|
||||||
|
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
|
if (isTextBlock(block)) {
|
||||||
|
const mediaPath = parseMediaPathFromText(block.text);
|
||||||
|
if (mediaPath) {
|
||||||
|
mediaPathHint = mediaPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!isImageBlock(block)) {
|
if (!isImageBlock(block)) {
|
||||||
out.push(block);
|
out.push(block);
|
||||||
continue;
|
continue;
|
||||||
@@ -211,12 +300,14 @@ export async function sanitizeContentBlocksImages(
|
|||||||
try {
|
try {
|
||||||
const inferredMimeType = inferMimeTypeFromBase64(data);
|
const inferredMimeType = inferMimeTypeFromBase64(data);
|
||||||
const mimeType = inferredMimeType ?? block.mimeType;
|
const mimeType = inferredMimeType ?? block.mimeType;
|
||||||
|
const fileName = inferImageFileName({ block, label, mediaPathHint });
|
||||||
const resized = await resizeImageBase64IfNeeded({
|
const resized = await resizeImageBase64IfNeeded({
|
||||||
base64: data,
|
base64: data,
|
||||||
mimeType,
|
mimeType,
|
||||||
maxDimensionPx,
|
maxDimensionPx,
|
||||||
maxBytes,
|
maxBytes,
|
||||||
label,
|
label,
|
||||||
|
fileName,
|
||||||
});
|
});
|
||||||
out.push({
|
out.push({
|
||||||
...block,
|
...block,
|
||||||
|
|||||||
Reference in New Issue
Block a user