Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 36 additions & 17 deletions packages/core/src/tools/read-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import sharp from 'sharp';
import type { Config } from '../config/config.js';
import { Storage } from '../config/storage.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
Expand Down Expand Up @@ -545,25 +546,37 @@ describe('ReadFileTool', () => {

it('should handle image file and return appropriate content', async () => {
const imagePath = path.join(tempRootDir, 'image.png');
// Minimal PNG header
const pngHeader = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
await fsp.writeFile(imagePath, pngHeader);
await sharp({
create: {
width: 20,
height: 10,
channels: 3,
background: '#306090',
},
})
.png()
.toFile(imagePath);
const params: ReadFileToolParams = { file_path: imagePath };
const invocation = tool.build(params) as ToolInvocation<
ReadFileToolParams,
ToolResult
>;

const result = await invocation.execute(abortSignal);
expect(result.llmContent).toEqual({
inlineData: {
data: pngHeader.toString('base64'),
mimeType: 'image/png',
displayName: 'image.png',
expect(result.llmContent).toEqual([
{
text: expect.stringMatching(
/Image overview: 20x10; oriented source: 20x10.*tool_search.*zoom_image.*0 to 1000/,
),
},
});
{
inlineData: {
data: expect.any(String),
mimeType: 'image/jpeg',
displayName: 'image.png',
},
},
]);
expect(result.returnDisplay).toBe('Read image file: image.png');
});

Expand Down Expand Up @@ -1526,17 +1539,23 @@ describe('ReadFileTool', () => {

it('does not return the placeholder for image files', async () => {
const imagePath = path.join(tempRootDir, 'pic.png');
const pngHeader = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
await fsp.writeFile(imagePath, pngHeader);
await sharp({
create: {
width: 8,
height: 8,
channels: 3,
background: '#306090',
},
})
.png()
.toFile(imagePath);

const first = await read({ file_path: imagePath });
// Image returns a Part, not a string.
// Image returns Parts, not a string.
expect(typeof first.llmContent).not.toBe('string');

const second = await read({ file_path: imagePath });
// Must remain a Part — never collapsed to a string placeholder.
// Must remain Parts — never collapsed to a string placeholder.
expect(typeof second.llmContent).not.toBe('string');
});

Expand Down
216 changes: 32 additions & 184 deletions packages/core/src/tools/zoom-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,24 @@
*/

import path from 'node:path';
import fs from 'node:fs/promises';
import type { Part } from '@google/genai';
import type { Metadata } from 'sharp';
import type { Config } from '../config/config.js';
import type { PermissionDecision } from '../permissions/types.js';
import { logFileOperation } from '../telemetry/loggers.js';
import { FileOperation } from '../telemetry/metrics.js';
import { FileOperationEvent } from '../telemetry/types.js';
import { getSpecificMimeType } from '../utils/fileUtils.js';
import {
ImageViewError,
renderNormalizedImageCrop,
} from '../utils/image-view.js';
import { makeRelative, shortenPath, unescapePath } from '../utils/paths.js';
import { getFileReadDefaultPermission } from './file-read-permission.js';
import { ToolErrorType } from './tool-error.js';
import { ToolDisplayNames, ToolNames } from './tool-names.js';
import type { ToolInvocation, ToolLocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';

const IMAGE_VIEW_MAX_EDGE = 1568;
const IMAGE_VIEW_MAX_PATCHES = 1568;
const IMAGE_PATCH_SIZE = 28;
const IMAGE_MAX_UPSCALE = 8;
const IMAGE_JPEG_QUALITY = 92;
const IMAGE_MAX_SOURCE_BYTES = 100 * 1024 * 1024;
const IMAGE_MAX_OUTPUT_BYTES = 9 * 1024 * 1024;
const SUPPORTED_IMAGE_FORMATS = new Set(['jpeg', 'png', 'webp']);

export interface ZoomImageParams {
file_path: string;
x1: number;
Expand All @@ -38,11 +31,6 @@ export interface ZoomImageParams {
y2: number;
}

interface ImageSize {
width: number;
height: number;
}

function failureResult(message: string, type: ToolErrorType): ToolResult {
return {
llmContent: message,
Expand All @@ -51,48 +39,6 @@ function failureResult(message: string, type: ToolErrorType): ToolResult {
};
}

function fitsVisualBudget({ width, height }: ImageSize): boolean {
return (
width <= IMAGE_VIEW_MAX_EDGE &&
height <= IMAGE_VIEW_MAX_EDGE &&
Math.ceil(width / IMAGE_PATCH_SIZE) *
Math.ceil(height / IMAGE_PATCH_SIZE) <=
IMAGE_VIEW_MAX_PATCHES
);
}

function magnifiedSize(width: number, height: number): ImageSize {
const widthIsLongEdge = width >= height;
const maxLongEdge = Math.min(
IMAGE_VIEW_MAX_EDGE,
Math.max(width, height) * IMAGE_MAX_UPSCALE,
);
let low = 1;
let high = maxLongEdge;
let best: ImageSize = { width: 1, height: 1 };

while (low <= high) {
const longEdge = Math.floor((low + high) / 2);
const candidate = widthIsLongEdge
? {
width: longEdge,
height: Math.max(1, Math.round((height / width) * longEdge)),
}
: {
width: Math.max(1, Math.round((width / height) * longEdge)),
height: longEdge,
};
if (fitsVisualBudget(candidate)) {
best = candidate;
low = longEdge + 1;
} else {
high = longEdge - 1;
}
}

return best;
}

class ZoomImageInvocation extends BaseToolInvocation<
ZoomImageParams,
ToolResult
Expand Down Expand Up @@ -130,145 +76,47 @@ class ZoomImageInvocation extends BaseToolInvocation<
ToolErrorType.READ_CONTENT_FAILURE,
);
}
let sharp: typeof import('sharp');
let view: Awaited<ReturnType<typeof renderNormalizedImageCrop>>;
try {
// sharp is a CJS `export =` module: at runtime the dynamic-import
// namespace carries the callable on `.default`, which the NodeNext types
// collapse away, so unwrap it explicitly (cf. utils/iconvHelper.ts).
sharp = (
(await import('sharp')) as unknown as {
default: typeof import('sharp');
}
).default;
} catch {
return failureResult(
'zoom_image is unavailable because the "sharp" image module could not be loaded.',
ToolErrorType.READ_CONTENT_FAILURE,
view = await renderNormalizedImageCrop(
this.params.file_path,
this.params,
signal,
);
}
let stats: Awaited<ReturnType<typeof fs.stat>>;
try {
stats = await fs.stat(this.params.file_path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return failureResult(
`Image file not found: ${this.params.file_path}`,
ToolErrorType.FILE_NOT_FOUND,
);
}
throw error;
}
if (stats.isDirectory()) {
return failureResult(
`Image path is a directory: ${this.params.file_path}`,
ToolErrorType.TARGET_IS_DIRECTORY,
);
}
if (!stats.isFile()) {
return failureResult(
`Image path is not a regular file: ${this.params.file_path}`,
ToolErrorType.TARGET_NOT_REGULAR_FILE,
);
}
if (stats.size > IMAGE_MAX_SOURCE_BYTES) {
return failureResult(
`Image file exceeds the 100 MB source limit: ${this.params.file_path}`,
ToolErrorType.FILE_TOO_LARGE,
);
}
let metadata: Metadata;
try {
metadata = await sharp(this.params.file_path, {
failOn: 'error',
limitInputPixels: true,
}).metadata();
} catch {
return failureResult(
`Unsupported image. zoom_image accepts static PNG, JPEG, or WebP files: ${this.params.file_path}`,
ToolErrorType.READ_CONTENT_FAILURE,
);
}
signal.throwIfAborted();
if (!SUPPORTED_IMAGE_FORMATS.has(metadata.format)) {
return failureResult(
`Unsupported image. zoom_image accepts static PNG, JPEG, or WebP files: ${this.params.file_path}`,
ToolErrorType.READ_CONTENT_FAILURE,
);
}
if ((metadata.pages ?? 1) > 1) {
return failureResult(
`zoom_image accepts static images only: ${this.params.file_path}`,
ToolErrorType.READ_CONTENT_FAILURE,
);
}

const sourceWidth = metadata.autoOrient.width;
const sourceHeight = metadata.autoOrient.height;
const left = Math.min(
sourceWidth - 1,
Math.max(0, Math.floor((this.params.x1 / 1000) * sourceWidth)),
);
const top = Math.min(
sourceHeight - 1,
Math.max(0, Math.floor((this.params.y1 / 1000) * sourceHeight)),
);
const right = Math.min(
sourceWidth,
Math.max(left + 1, Math.ceil((this.params.x2 / 1000) * sourceWidth)),
);
const bottom = Math.min(
sourceHeight,
Math.max(top + 1, Math.ceil((this.params.y2 / 1000) * sourceHeight)),
);
const cropWidth = right - left;
const cropHeight = bottom - top;
const outputSize = magnifiedSize(cropWidth, cropHeight);

let output: Buffer;
try {
output = await sharp(this.params.file_path, {
autoOrient: true,
failOn: 'error',
limitInputPixels: true,
})
.extract({ left, top, width: cropWidth, height: cropHeight })
.resize(outputSize.width, outputSize.height, {
fit: 'fill',
kernel: sharp.kernel.lanczos3,
})
.flatten({ background: '#ffffff' })
.jpeg({
quality: IMAGE_JPEG_QUALITY,
chromaSubsampling: '4:4:4',
})
.toBuffer();
} catch {
signal.throwIfAborted();
return failureResult(
`Failed to decode image: ${this.params.file_path}`,
ToolErrorType.READ_CONTENT_FAILURE,
);
}
signal.throwIfAborted();
if (output.length > IMAGE_MAX_OUTPUT_BYTES) {
return failureResult(
`Zoomed image exceeds the 9 MB output limit: ${this.params.file_path}`,
ToolErrorType.FILE_TOO_LARGE,
);
const message =
error instanceof Error ? error.message : 'Failed to decode image.';
let errorType = ToolErrorType.READ_CONTENT_FAILURE;
if (error instanceof ImageViewError) {
if (error.code === 'file_not_found') {
errorType = ToolErrorType.FILE_NOT_FOUND;
} else if (error.code === 'target_is_directory') {
errorType = ToolErrorType.TARGET_IS_DIRECTORY;
} else if (error.code === 'target_not_regular_file') {
errorType = ToolErrorType.TARGET_NOT_REGULAR_FILE;
} else if (
error.code === 'source_too_large' ||
error.code === 'output_too_large'
) {
errorType = ToolErrorType.FILE_TOO_LARGE;
}
}
return failureResult(message, errorType);
}

const text =
`Zoomed normalized region (${this.params.x1},${this.params.y1})-` +
`(${this.params.x2},${this.params.y2}) from ${this.params.file_path}. ` +
`Oriented source: ${sourceWidth}x${sourceHeight}; source crop: ` +
`${cropWidth}x${cropHeight}; returned view: ` +
`${outputSize.width}x${outputSize.height}.`;
`Oriented source: ${view.sourceWidth}x${view.sourceHeight}; source crop: ` +
`${view.selectedWidth}x${view.selectedHeight}; returned view: ` +
`${view.outputWidth}x${view.outputHeight}.`;
const llmContent: Part[] = [
{ text },
{
inlineData: {
mimeType: 'image/jpeg',
data: output.toString('base64'),
mimeType: view.mimeType,
data: view.bytes.toString('base64'),
},
},
];
Expand Down
Loading
Loading