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. Returns the main text content extracted from the page. Use this when you need to read a specific web page.
Fetch content from a URL. Returns the main text content extracted from the page, or the image data if the URL points to an image file. Use this when you need to read a specific web page or image.

Only public `http`/`https` URLs are supported. Requests to private, loopback, or link-local addresses are refused, and responses larger than 10 MiB are rejected.
39 changes: 31 additions & 8 deletions packages/agent-core/src/tools/builtin/web/fetch-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import type { BuiltinTool } from '../../../agent/tool';
import { ToolAccesses } from '../../../loop/tool-access';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types';
import type { ContentPart, ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types';

Check failure on line 13 in packages/agent-core/src/tools/builtin/web/fetch-url.ts

View workflow job for this annotation

GitHub Actions / typecheck

Module '"../../../loop/types"' declares 'ContentPart' locally, but it is not exported.
import { toInputJsonSchema } from '../../support/input-schema';
import { literalRulePattern, matchesGlobRuleSubject } from '../../support/rule-match';
import { ToolResultBuilder } from '../../support/result-builder';
Expand All @@ -25,14 +25,29 @@
* returned verbatim, in full.
* - `extracted` — the body was an HTML page; only the main article text
* was extracted and returned.
* - `image` — the body is an image file; the binary data is returned
* as base64-encoded content for multimodal model input.
*/
export type UrlFetchKind = 'passthrough' | 'extracted';
export type UrlFetchKind = 'passthrough' | 'extracted' | 'image';

export interface PageMetadata {
/** The URL that was fetched. */
url: string;
/** The title of the page, if available. */
title?: string;
/** The MIME type of the response. */
mime?: string;
}

export interface UrlFetchResult {
/** The text handed to the LLM. */
/** The text handed to the LLM, or empty string for image content. */
content: string;
/** Whether `content` is a verbatim passthrough or extracted main text. */
/** Whether content is a verbatim passthrough, extracted main text, or image data. */
kind: UrlFetchKind;
/** Image data as base64, when kind is 'image'. */
image?: { mimeType: string; base64: string };
/** Page metadata for the fetched resource, aligning with internal API shape. */
page?: PageMetadata;
}

export interface UrlFetcher {
Expand Down Expand Up @@ -84,12 +99,20 @@

private async execution(
args: FetchURLInput,
{
toolCallId,
}: ExecutableToolContext,
{ toolCallId }: ExecutableToolContext,
): Promise<ExecutableToolResult> {
try {
const { content, kind } = await this.fetcher.fetch(args.url, { toolCallId });
const { content, kind, image } = await this.fetcher.fetch(args.url, { toolCallId });

if (image) {
const output: ContentPart[] = [
{
type: 'image_url',
imageUrl: { url: `data:${image.mimeType};base64,${image.base64}`, id: args.url },
},
];
return { output, isError: false };
}

if (!content) {
return {
Expand Down
28 changes: 24 additions & 4 deletions packages/agent-core/src/tools/providers/local-fetch-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
* 3. Reject responses larger than `maxBytes` (content-length first,
* then measured body length as a defensive second check).
* 4. `text/plain` / `text/markdown` → passthrough verbatim.
* 5. Otherwise (assumed HTML) → run Readability over a linkedom
* 5. `image/*` → download binary, encode as base64, return as image kind.
* 6. Otherwise (assumed HTML) → run Readability over a linkedom
* document. Return `# ${title}\n\n${text}` (title omitted when
* absent). If extraction yields no meaningful text, fall back to
* common content containers (`<article>` / `<main>` / `<body>`)
Expand Down Expand Up @@ -172,6 +173,26 @@ export class LocalFetchURLProvider implements UrlFetcher {
}
}

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

// Handle image content types
if (contentType.startsWith('image/')) {
const arrayBuffer = await response.arrayBuffer();
const actualBytes = arrayBuffer.byteLength;
if (actualBytes > this.maxBytes) {
throw new Error(
`Response body too large: ${String(actualBytes)} bytes exceeds maxBytes (${String(this.maxBytes)}).`,
);
}
const base64 = Buffer.from(arrayBuffer).toString('base64');
return {
content: '',
kind: 'image',
image: { mimeType: contentType, base64 },

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 Restrict fetched images to supported MIME types

This accepts every image/* content type and stores the full header as the MIME type. For URLs returning image/svg+xml, image/avif, or a header with parameters such as image/png; charset=binary, FetchURLTool emits a data: image URL that our Anthropic converter rejects because it only allows image/png, image/jpeg, image/gif, and image/webp (packages/kosong/src/providers/anthropic.ts:418-430). Restrict or sanitize the MIME type before returning kind: 'image', otherwise a successful fetch can make the following model request fail.

Useful? React with 👍 / 👎.

page: { url, mime: contentType },
};
}

const body = await response.text();

// Servers may omit content-length — measure again defensively.
Expand All @@ -182,12 +203,11 @@ 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' };
return { content: body, kind: 'passthrough', page: { url, mime: contentType } };
}

return { content: this.extractMainContent(body), kind: 'extracted' };
return { content: this.extractMainContent(body), kind: 'extracted', page: { url, mime: contentType } };
}

private extractMainContent(html: string): string {
Expand Down
33 changes: 31 additions & 2 deletions packages/agent-core/test/tools/fetch-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
HttpFetchError,
type UrlFetcher,
} from '../../src/tools/builtin/web/fetch-url';
import type { ImageURLPart } from '@moonshot-ai/kosong';
import { MoonshotFetchURLProvider } from '../../src/tools/providers/moonshot-fetch-url';
import { toolContentString } from './fixtures/fake-kaos';
import { executeTool } from './fixtures/execute-tool';
Expand All @@ -20,9 +21,11 @@ const signal = new AbortController().signal;

function fakeFetcher(
content = '',
kind: 'passthrough' | 'extracted' = 'extracted',
kind: 'passthrough' | 'extracted' | 'image' = 'extracted',
image?: { mimeType: string; base64: string },
page?: { url: string; mime?: string; title?: string },
): UrlFetcher {
return { fetch: vi.fn().mockResolvedValue({ content, kind }) };
return { fetch: vi.fn().mockResolvedValue({ content, kind, image, page }) };
}

describe('FetchURLTool', () => {
Expand Down Expand Up @@ -119,6 +122,32 @@ describe('FetchURLTool', () => {
expect((result as { message?: string }).message).toContain('Output is truncated');
});

it('returns image_url content part for image kind results', async () => {
const fetcher: UrlFetcher = {
fetch: vi.fn().mockResolvedValue({
content: '',
kind: 'image',
image: { mimeType: 'image/png', base64: 'base64data' },
page: { url: 'https://example.com/image.png', mime: 'image/png' },
}),
};
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);
const output = (result as { output?: ContentPart[] }).output;
expect(output).toHaveLength(1);
expect(output?.[0]?.type).toBe('image_url');
expect((output?.[0] as ImageURLPart).imageUrl.url).toBe('data:image/png;base64,base64data');
expect((output?.[0] as ImageURLPart).imageUrl.id).toBe('https://example.com/image.png');
});

it('returns error when fetcher throws', async () => {
const fetcher: UrlFetcher = {
fetch: vi.fn().mockRejectedValue(new Error('timeout')),
Expand Down
27 changes: 25 additions & 2 deletions packages/agent-core/test/tools/providers/local-fetch-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,30 @@ function htmlResponse(body: string, contentType: string): Response {
});
}

function imageResponse(data: Uint8Array, contentType: string): Response {
return new Response(data, {
status: 200,
headers: { 'content-type': contentType },
});
}

describe('LocalFetchURLProvider content kind', () => {
it('reports image content as image kind with base64 data', async () => {
const imageData = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValue(imageResponse(imageData, 'image/png'));
const provider = new LocalFetchURLProvider({ fetchImpl });

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

expect(result.kind).toBe('image');
expect(result.image).toBeDefined();
expect(result.image?.mimeType).toBe('image/png');
expect(result.image?.base64).toBeTruthy();
expect(result.page).toEqual({ url: 'https://example.com/image.png', mime: 'image/png' });
});

it('reports text/plain bodies as a verbatim passthrough', async () => {
const fetchImpl = vi
.fn<typeof fetch>()
Expand All @@ -26,7 +49,7 @@ describe('LocalFetchURLProvider content kind', () => {

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

expect(result).toEqual({ content: 'plain body', kind: 'passthrough' });
expect(result).toEqual({ content: 'plain body', kind: 'passthrough', page: { url: 'https://example.com/file.txt', mime: 'text/plain; charset=utf-8' } });
});

it('reports text/markdown bodies as a verbatim passthrough', async () => {
Expand All @@ -37,7 +60,7 @@ describe('LocalFetchURLProvider content kind', () => {

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

expect(result).toEqual({ content: '# Title\n\nbody', kind: 'passthrough' });
expect(result).toEqual({ content: '# Title\n\nbody', kind: 'passthrough', page: { url: 'https://example.com/readme.md', mime: 'text/markdown' } });
});

it('reports HTML bodies as extracted main content', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import { MoonshotFetchURLProvider } from '../../../src/tools/providers/moonshot-

function fakeFetcher(
content = '',
kind: 'passthrough' | 'extracted' = 'extracted',
kind: 'passthrough' | 'extracted' | 'image' = 'extracted',
image?: { mimeType: string; base64: string },
page?: { url: string; mime?: string; title?: string },
): UrlFetcher {
return { fetch: vi.fn().mockResolvedValue({ content, kind }) };
return { fetch: vi.fn().mockResolvedValue({ content, kind, image, page }) };
}

describe('MoonshotFetchURLProvider auth fallback', () => {
Expand Down
Loading