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
114 changes: 114 additions & 0 deletions src/__tests__/integration/tools/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,56 @@ describe('viewImageTool', () => {
});
});

it('can expose a reference-only schema that does not advertise or accept local paths', async () => {
const resourceResolver = vi.fn();
const tool = createViewImageTool({
resourceResolver,
sourcePolicy: 'references-only',
});

expect(tool.description).toContain('host-authorized opaque image references');
expect(tool.description).not.toContain('local image paths');
expect(tool.parameters).toMatchObject({
properties: {
reference: expect.any(Object),
references: expect.any(Object),
prompt: expect.any(Object),
},
anyOf: [
{ required: ['reference'] },
{ required: ['references'] },
],
});
expect((tool.parameters.properties as Record<string, unknown>).path).toBeUndefined();
expect((tool.parameters.properties as Record<string, unknown>).paths).toBeUndefined();

await expect(tool.execute({ path: 'screen.png' })).resolves.toEqual({
ok: false,
error: 'Invalid input for view_image. Required field: reference or references. Optional field: prompt.',
});
expect(tool.inputSchema?.safeParse({ path: 'screen.png' }).success).toBe(false);
expect(resourceResolver).not.toHaveBeenCalled();
});

it('requires a resource resolver when source policy enables references', () => {
expect(() => createViewImageTool({ sourcePolicy: 'references-only' })).toThrow(
'view_image sourcePolicy requires a resourceResolver when references are enabled.',
);
expect(() => createViewImageTool({ sourcePolicy: 'paths-and-references' })).toThrow(
'view_image sourcePolicy requires a resourceResolver when references are enabled.',
);
});

it('keeps path-only behavior when a host resolver is omitted', async () => {
const tool = createViewImageTool();

expect((tool.parameters.properties as Record<string, unknown>).reference).toBeUndefined();
await expect(tool.execute({ reference: 'image-123' })).resolves.toEqual({
ok: false,
error: 'Invalid input for view_image. Required field: path or paths. Optional field: prompt.',
});
});

it('fails closed when a host does not authorize an opaque image reference', async () => {
const resourceResolver = vi.fn().mockResolvedValue(null);
const tool = createViewImageTool({ resourceResolver });
Expand Down Expand Up @@ -868,6 +918,70 @@ describe('viewImageTool', () => {
expect(resourceResolver).not.toHaveBeenCalled();
});

it('uses a host-specific default prompt when view_image input omits prompt', async () => {
const bytes = Buffer.from('host-owned-image-bytes');
const credential = {
type: 'oauth-access-token',
provider: 'openai',
accessToken: 'request-access-token',
expiresAt: Date.now() + 120_000,
accountId: 'account-123',
} as const;
const requests: Array<{ body: string }> = [];
vi.stubGlobal('fetch', vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
requests.push({ body: String(init?.body ?? '') });
return new Response([
'event: response.output_text.done',
'data: {"type":"response.output_text.done","text":"Stored project image.","content_index":0,"item_id":"msg_1","output_index":0,"sequence_number":1}',
'',
'event: response.completed',
'data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","model":"gpt-5.4","output_text":"Stored project image.","output":[]}}',
'',
].join('\n'), {
status: 200,
headers: { 'content-type': 'text/event-stream' },
});
}));
const tool = createViewImageTool({
model: 'gpt-5.4',
credential,
defaultPrompt: 'Describe visible project evidence only.',
providerCredentialSource: {
type: 'oauth-access-token',
provider: 'openai',
expiresAt: credential.expiresAt,
accountId: credential.accountId,
},
resourceResolver: async () => ({
bytes,
mediaType: 'image/png',
}),
});

await expect(tool.execute({ reference: 'image-123' })).resolves.toEqual({
ok: true,
output: {
provider: 'openai',
model: 'gpt-5.4',
reference: 'image-123',
summary: 'Stored project image.',
},
});
const body = JSON.parse(requests[0]?.body ?? '{}') as {
input?: Array<{ content?: Array<{ text?: string }> }>;
};
expect(body.input?.[0]?.content?.[0]?.text).toBe('Describe visible project evidence only.');
});

it('rejects invalid host-specific default prompts at construction time', () => {
expect(() => createViewImageTool({ defaultPrompt: '' })).toThrow(
'view_image defaultPrompt must be a non-empty string up to 2000 characters.',
);
expect(() => createViewImageTool({ defaultPrompt: 'x'.repeat(2_001) })).toThrow(
'view_image defaultPrompt must be a non-empty string up to 2000 characters.',
);
});

it('inspects host-resolved bytes directly with request-scoped credentials', async () => {
const bytes = Buffer.from('host-owned-image-bytes');
const credential = {
Expand Down
3 changes: 2 additions & 1 deletion src/__tests__/unit/tools/external-context-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ describe('external-context public contracts', () => {

expect(search.inputSchema).toBe(WebSearchInputSchema);
expect(search.outputSchema).toBe(WebSearchOutputSchema);
expect(image.inputSchema).toBe(ViewImageInputSchema);
expect(image.inputSchema?.safeParse({ path: 'screen.png' }).success).toBe(true);
expect(image.inputSchema?.safeParse({ reference: 'host-image' }).success).toBe(false);
expect(image.outputSchema).toBe(ViewImageOutputSchema);
});

Expand Down
1 change: 1 addition & 0 deletions src/advanced.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export {
export type {
ViewImageResource,
ViewImageResourceResolver,
ViewImageSourcePolicy,
ViewImageToolDefinition,
ViewImageToolOptions,
} from './core/tools/toolkits/external-context/view-image.js';
Expand Down
183 changes: 134 additions & 49 deletions src/core/tools/toolkits/external-context/view-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
type ResolvedProviderCredential,
} from '../../../runtime/credentials/index.js';
import {
MAX_EXTERNAL_CONTEXT_PROMPT_LENGTH,
ViewImageInputSchema,
ViewImageOutputSchema,
type ViewImageInput,
Expand All @@ -55,6 +56,8 @@ export type ViewImageResourceResolver = (
context: ToolExecutionContext,
) => Promise<ViewImageResource | null | undefined>;

export type ViewImageSourcePolicy = 'paths-only' | 'references-only' | 'paths-and-references';

export type ViewImageToolOptions = {
model?: string;
provider?: LlmProvider;
Expand All @@ -64,7 +67,18 @@ export type ViewImageToolOptions = {
credentialStorePath?: string;
workspaceRoot?: string;
resourceResolver?: ViewImageResourceResolver;
/**
* Controls which image locator kinds the model-visible schema advertises and
* execution accepts. Omit to keep today's local-path behavior unless a host
* resolver opts into `paths-and-references` explicitly.
*/
sourcePolicy?: ViewImageSourcePolicy;
maxImageBytes?: number;
/**
* Host-specific fallback used when the model omits `prompt` in a view_image
* call. Omit to preserve Heddle's coding-assistant default prompt.
*/
defaultPrompt?: string;
};

const DEFAULT_IMAGE_PROMPT =
Expand All @@ -77,61 +91,21 @@ export const viewImageTool: ViewImageToolDefinition = createViewImageTool();

export function createViewImageTool(options: ViewImageToolOptions = {}): ViewImageToolDefinition {
const maxImageBytes = resolveMaxImageBytes(options.maxImageBytes);
const supportsReferences = Boolean(options.resourceResolver);
const defaultPrompt = resolveDefaultImagePrompt(options.defaultPrompt);
const sourcePolicy = resolveSourcePolicy(options);
const inputSchema = createViewImageInputSchema(sourcePolicy);
return {
name: 'view_image',
description:
supportsReferences ?
'Inspect one or more local image paths or host-authorized opaque image references when visual contents are needed. Input examples: { "path": "/absolute/path/to/screenshot.png" } or { "reference": "host-image-reference" }. Optional field: prompt for a more specific visual question. Returns a concise text description.'
: 'Inspect one or more local image files when the user references screenshots, diagrams, or other visual file paths and the image contents are actually needed. Use this only after the user has provided or implied concrete image paths. Input examples: { "path": "/absolute/path/to/screenshot.png" } or { "paths": ["/absolute/path/to/a.png", "/absolute/path/to/b.png"] }. Optional field: prompt for a more specific visual question. Returns a concise text description of the image contents.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
path: {
type: 'string',
description: 'Path to the local image file.',
},
paths: {
type: 'array',
items: { type: 'string' },
description: 'Paths to local image files.',
},
...(supportsReferences ? {
reference: {
type: 'string',
description: 'Opaque host-authorized image reference.',
},
references: {
type: 'array',
items: { type: 'string' },
description: 'Opaque host-authorized image references.',
},
} : {}),
prompt: {
type: 'string',
description: 'Optional focused instruction for what to extract from the image.',
},
},
anyOf: [
{ required: ['path'] },
{ required: ['paths'] },
...(supportsReferences ? [
{ required: ['reference'] },
{ required: ['references'] },
] : []),
],
},
inputSchema: ViewImageInputSchema,
description: describeViewImageTool(sourcePolicy),
parameters: createViewImageParameters(sourcePolicy),
inputSchema,
outputSchema: ViewImageOutputSchema,
async execute(raw: unknown, context?: ToolExecutionContext): Promise<ToolResult<ViewImageOutput>> {
const parsed = ViewImageInputSchema.safeParse(raw);
const parsed = inputSchema.safeParse(raw);
if (!parsed.success) {
return {
ok: false,
error: supportsReferences ?
'Invalid input for view_image. Required field: path, paths, reference, or references. Optional field: prompt.'
: 'Invalid input for view_image. Required field: path or paths. Optional field: prompt.',
error: invalidInputMessage(sourcePolicy),
};
}

Expand All @@ -153,7 +127,7 @@ export function createViewImageTool(options: ViewImageToolOptions = {}): ViewIma
}

const provider = options.provider ?? LlmAdapterService.inferProvider(options.model ?? DEFAULT_OPENAI_MODEL);
const prompt = input.prompt || DEFAULT_IMAGE_PROMPT;
const prompt = input.prompt || defaultPrompt;

try {
const files = await resolveImageViewFiles({
Expand Down Expand Up @@ -198,6 +172,104 @@ export function createViewImageTool(options: ViewImageToolOptions = {}): ViewIma
};
}

function resolveSourcePolicy(options: ViewImageToolOptions): ViewImageSourcePolicy {
const sourcePolicy = options.sourcePolicy ?? (options.resourceResolver ? 'paths-and-references' : 'paths-only');
if (
(sourcePolicy === 'references-only' || sourcePolicy === 'paths-and-references')
&& !options.resourceResolver
) {
throw new Error('view_image sourcePolicy requires a resourceResolver when references are enabled.');
}
return sourcePolicy;
}

function createViewImageInputSchema(sourcePolicy: ViewImageSourcePolicy) {
return ViewImageInputSchema.superRefine((input, context) => {
const paths = normalizeImagePaths(input);
const references = normalizeImageReferences(input);
if (sourcePolicy === 'references-only' && paths.length > 0) {
context.addIssue({
code: 'custom',
message: 'Local image paths are disabled for this view_image tool.',
});
}
if (sourcePolicy === 'paths-only' && references.length > 0) {
context.addIssue({
code: 'custom',
message: 'Opaque image references are disabled for this view_image tool.',
});
}
});
}

function describeViewImageTool(sourcePolicy: ViewImageSourcePolicy): string {
switch (sourcePolicy) {
case 'references-only':
return 'Inspect one or more host-authorized opaque image references when visual contents are needed. Input examples: { "reference": "host-image-reference" } or { "references": ["host-image-reference"] }. Optional field: prompt for a more specific visual question. Returns a concise text description.';
case 'paths-and-references':
return 'Inspect one or more local image paths or host-authorized opaque image references when visual contents are needed. Input examples: { "path": "/absolute/path/to/screenshot.png" } or { "reference": "host-image-reference" }. Optional field: prompt for a more specific visual question. Returns a concise text description.';
case 'paths-only':
return 'Inspect one or more local image files when the user references screenshots, diagrams, or other visual file paths and the image contents are actually needed. Use this only after the user has provided or implied concrete image paths. Input examples: { "path": "/absolute/path/to/screenshot.png" } or { "paths": ["/absolute/path/to/a.png", "/absolute/path/to/b.png"] }. Optional field: prompt for a more specific visual question. Returns a concise text description of the image contents.';
}
}

function createViewImageParameters(sourcePolicy: ViewImageSourcePolicy): Record<string, unknown> {
const pathProperties = sourcePolicy === 'references-only' ? {} : {
path: {
type: 'string',
description: 'Path to the local image file.',
},
paths: {
type: 'array',
items: { type: 'string' },
description: 'Paths to local image files.',
},
};
const referenceProperties = sourcePolicy === 'paths-only' ? {} : {
reference: {
type: 'string',
description: 'Opaque host-authorized image reference.',
},
references: {
type: 'array',
items: { type: 'string' },
description: 'Opaque host-authorized image references.',
},
};
const pathRequirements = sourcePolicy === 'references-only' ? [] : [
{ required: ['path'] },
{ required: ['paths'] },
];
const referenceRequirements = sourcePolicy === 'paths-only' ? [] : [
{ required: ['reference'] },
{ required: ['references'] },
];
return {
type: 'object',
additionalProperties: false,
properties: {
...pathProperties,
...referenceProperties,
prompt: {
type: 'string',
description: 'Optional focused instruction for what to extract from the image.',
},
},
anyOf: [...pathRequirements, ...referenceRequirements],
};
}

function invalidInputMessage(sourcePolicy: ViewImageSourcePolicy): string {
switch (sourcePolicy) {
case 'references-only':
return 'Invalid input for view_image. Required field: reference or references. Optional field: prompt.';
case 'paths-and-references':
return 'Invalid input for view_image. Required field: path, paths, reference, or references. Optional field: prompt.';
case 'paths-only':
return 'Invalid input for view_image. Required field: path or paths. Optional field: prompt.';
}
}

async function executeOpenAiImageView(args: {
files: ImageViewFile[];
prompt: string;
Expand Down Expand Up @@ -572,6 +644,19 @@ function resolveMaxImageBytes(value: number | undefined): number {
return maxImageBytes;
}

function resolveDefaultImagePrompt(value: string | undefined): string {
if (value === undefined) {
return DEFAULT_IMAGE_PROMPT;
}
const parsed = ViewImageInputSchema.shape.prompt.safeParse(value);
if (!parsed.success || parsed.data === undefined || parsed.data.length === 0) {
throw new RangeError(
`view_image defaultPrompt must be a non-empty string up to ${MAX_EXTERNAL_CONTEXT_PROMPT_LENGTH} characters.`,
);
}
return parsed.data;
}

function formatImageOutputSources(files: ImageViewFile[]) {
const paths = files.flatMap((file) => file.source.type === 'path' ? [file.source.value] : []);
const references = files.flatMap((file) => file.source.type === 'reference' ? [file.source.value] : []);
Expand Down
Loading