Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/agent-core/src/tools/builtin/web/fetch-url.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
Fetch content from a URL. The content is returned either as the main text extracted from the page, or as the full response body verbatim; a note at the top of the result states which of the two you received, so you can judge how complete it is. Use this when you need to read a specific web page.
Fetch content from a URL. The content is returned either as the main text extracted from the page, as the full response body verbatim, or (for image URLs) as the image itself; a note at the top of the result states which of the two you received, so you can judge how complete it is. Use this when you need to read a specific web page or fetch an image from the internet.

Only fully-formed public `http`/`https` URLs are supported; other schemes and private or loopback addresses are not fetched. Very large pages may be truncated or refused. The fetch carries no login or session for the target site, so pages behind authentication (private repositories, internal dashboards) return a login page or an error instead of the real content — if the text you get back looks like a generic landing or sign-in page, treat that as the login wall, not the answer, and reach the content through a credentialed route (an authenticated CLI or MCP tool) instead.
25 changes: 22 additions & 3 deletions packages/agent-core/src/tools/builtin/web/fetch-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,18 @@ import DESCRIPTION from './fetch-url.md?raw';
* returned verbatim, in full.
* - `extracted` — the body was an HTML page; only the main article text
* was extracted and returned.
* - `image` — the body was an image file; returned as a base64 data URL
* for direct model consumption.
*/
export type UrlFetchKind = 'passthrough' | 'extracted';
export type UrlFetchKind = 'passthrough' | 'extracted' | 'image';

export interface UrlFetchResult {
/** The text handed to the LLM. */
/** The text handed to the LLM (for passthrough / extracted). */
content: string;
/** Whether `content` is a verbatim passthrough or extracted main text. */
kind: UrlFetchKind;
/** Present when `kind === 'image'` — the image as a base64 data URL. */
imageData?: { mimeType: string; base64: string };
}

export interface UrlFetcher {
Expand Down Expand Up @@ -89,7 +93,22 @@ export class FetchURLTool implements BuiltinTool<FetchURLInput> {
}: ExecutableToolContext,
): Promise<ExecutableToolResult> {
try {
const { content, kind } = await this.fetcher.fetch(args.url, { toolCallId });
const { content, kind, imageData } = await this.fetcher.fetch(args.url, { toolCallId });

// Image response: return as multimodal ContentPart[] so the model
// can see the image directly.
if (kind === 'image' && imageData !== undefined) {
return {
output: [
{ type: 'text', text: `Fetched image from ${args.url}` },
{
type: 'image_url',
imageUrl: { url: `data:${imageData.mimeType};base64,${imageData.base64}` },
},
Comment on lines +100 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Port image fetch support to agent-core-v2

Implementing this only in packages/agent-core misses the shipped kap-server path: I checked packages/kap-server/package.json:26 (depends on @moonshot-ai/agent-core-v2) and packages/agent-core-v2/src/app/web/tools/fetch-url-types.ts:13-18 / fetch-url.ts:61-78, where UrlFetchKind is still only passthrough | extracted and the tool still renders text. As a result, kimi web/kap-server users fetching an image URL still get the old HTML/text extraction behavior, so this feature won't work in the default CLI/server environment unless the v2 FetchURL provider/tool is updated too.

Useful? React with 👍 / 👎.

],
isError: false,
};
}

if (!content) {
return {
Expand Down
21 changes: 20 additions & 1 deletion packages/agent-core/src/tools/providers/local-fetch-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,26 @@ export class LocalFetchURLProvider implements UrlFetcher {
);
}

const contentType = (response.headers.get('content-type') ?? '').toLowerCase();

// Image responses: read binary body and return as base64 for direct
// model consumption.
if (contentType.startsWith('image/')) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Gate fetched image MIME before persisting

When the fetched URL returns any image/* such as image/svg+xml, image/avif, image/heic, or even the alias image/jpg, this branch marks it as an image and FetchURLTool later persists a data image_url with that MIME. The repo's shared image policy says only PNG/JPEG/GIF/WebP are allowed and that unsupported inline images make later provider requests fail (packages/agent-core/src/tools/support/image-format-policy.ts:4-8 and :169-175), while MCP results already refuse these at packages/agent-core/src/mcp/output.ts:146. Please run the same MIME normalization/sniffing gate, or return a text notice, before returning imageData.

Useful? React with 👍 / 👎.

const arrayBuffer = await response.arrayBuffer();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check image size before buffering

For image/* responses with Content-Length greater than maxBytes, this line buffers the entire body with arrayBuffer() before the size check runs. That bypasses the pre-buffer rejection that still exists for text responses below, so a large image can consume memory/bandwidth or OOM the process before being rejected; move the existing content-length guard above the image branch or duplicate it here.

Useful? React with 👍 / 👎.

const bytes = Buffer.from(arrayBuffer);
if (bytes.length > this.maxBytes) {
throw new Error(
`Response body too large: ${String(bytes.length)} bytes exceeds maxBytes (${String(this.maxBytes)}).`,
);
}
const base64 = bytes.toString('base64');
return {
content: `Image fetched (${contentType}, ${String(bytes.length)} bytes)`,
kind: 'image',
imageData: { mimeType: contentType.split(';')[0]!.trim(), base64 },
};
}

// Reject oversized responses before buffering the full body.
const contentLengthRaw = response.headers.get('content-length');
if (contentLengthRaw !== null) {
Expand All @@ -263,7 +283,6 @@ export class LocalFetchURLProvider implements UrlFetcher {
);
}

const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
if (contentType.startsWith('text/plain') || contentType.startsWith('text/markdown')) {
return { content: body, kind: 'passthrough' };
}
Expand Down
26 changes: 26 additions & 0 deletions packages/agent-core/test/tools/fetch-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,32 @@ describe('FetchURLTool', () => {
expect(out).toContain('# Raw markdown');
});

it('returns image data as multimodal ContentPart[] when fetcher returns an image', async () => {
const fetcher: UrlFetcher = {
fetch: vi.fn().mockResolvedValue({
content: 'Image fetched (image/png, 8 bytes)',
kind: 'image',
imageData: { mimeType: 'image/png', base64: 'dGVzdA==' },
}),
};
const tool = new FetchURLTool(fetcher);
const result = await executeTool(tool, {
turnId: 't1',
toolCallId: 'c-img',
args: { url: 'https://example.com/image.png' },
signal,
});
expect(result.isError).toBe(false);
expect(Array.isArray(result.output)).toBe(true);
const parts = result.output as Array<{ type: string }>;
expect(parts).toHaveLength(2);
expect(parts[0]).toMatchObject({ type: 'text' });
expect(parts[1]).toMatchObject({
type: 'image_url',
imageUrl: { url: 'data:image/png;base64,dGVzdA==' },
});
});

it('returns empty message when fetcher returns empty string', async () => {
const tool = new FetchURLTool(fakeFetcher(''));
const result = await executeTool(tool, {
Expand Down
34 changes: 34 additions & 0 deletions packages/agent-core/test/tools/providers/local-fetch-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,40 @@ describe('LocalFetchURLProvider content kind', () => {
expect(result.kind).toBe('extracted');
expect(result.content).toContain('quick brown fox');
});

it('reports image/* bodies as image kind with base64 data', async () => {
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(
new Response(pngBytes, {
status: 200,
headers: { 'content-type': 'image/png' },
}),
);
const provider = new LocalFetchURLProvider({ fetchImpl });

const result = await provider.fetch('https://example.com/image.png');

expect(result.kind).toBe('image');
expect(result.imageData).toBeDefined();
expect(result.imageData!.mimeType).toBe('image/png');
expect(result.imageData!.base64).toBe(pngBytes.toString('base64'));
expect(result.content).toContain('Image fetched');
});

it('rejects oversized image responses', async () => {
const bigBuffer = Buffer.alloc(11 * 1024 * 1024, 0xff);
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(
new Response(bigBuffer, {
status: 200,
headers: { 'content-type': 'image/png' },
}),
);
const provider = new LocalFetchURLProvider({ fetchImpl });

await expect(provider.fetch('https://example.com/huge.png')).rejects.toThrow(
'Response body too large',
);
});
});

describe('LocalFetchURLProvider SSRF guard', () => {
Expand Down
Loading