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
6 changes: 6 additions & 0 deletions .changeset/web-search-query-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": minor

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 Include the CLI package in this changeset

This WebSearch behavior change is implemented in agent-core but is visible through the shipped CLI; .agents/skills/gen-changesets/SKILL.md says internal package source changes that enter the CLI bundle must manually list @moonshot-ai/kimi-code because changesets will not propagate them through CLI devDependencies. With only @moonshot-ai/agent-core listed here, the CLI release/changelog can miss the user-visible built-in tool contract change, so add an appropriate @moonshot-ai/kimi-code entry or split the changeset accordingly.

Useful? React with 👍 / 👎.

"kimi-code-docs": patch
---

WebSearch now sends only the query and returns lightweight result summaries (title, source site, date, URL, snippet) instead of inlined page content; fetch a result's full page content on demand with FetchURL. Both tools now include a citation reminder in their results.
2 changes: 1 addition & 1 deletion docs/en/reference/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Foreground mode blocks the current turn until the command completes or times out
| `WebSearch` | Auto-allow | Web search |
| `FetchURL` | Auto-allow | Fetch the content of a specified URL |

**`WebSearch`** accepts `query` (search terms) and optional `limit` (number of results to return, 1–20; defaults to 5) and `include_content` (whether to return the page body; defaults to false). Requires the host to provide a search implementation; when not injected, the tool does not appear in the tool list.
**`WebSearch`** accepts `query` (search terms). Requires the host to provide a search implementation; when not injected, the tool does not appear in the tool list.

**`FetchURL`** accepts a single `url` parameter and returns the page content. For HTML pages, the host extracts the body text rather than returning the full HTML; plain text or Markdown pages are passed through directly. Also requires a host-provided implementation.

Expand Down
2 changes: 1 addition & 1 deletion docs/zh/reference/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
| `WebSearch` | 自动放行 | 网络搜索 |
| `FetchURL` | 自动放行 | 获取指定 URL 的内容 |

**`WebSearch`** 接受 `query`(搜索词)和可选的 `limit`(返回结果数,1–20,默认 5)及 `include_content`(是否返回网页正文,默认 false)。需要宿主提供搜索实现,未注入时不会出现在工具列表中。
**`WebSearch`** 接受 `query`(搜索词)。需要宿主提供搜索实现,未注入时不会出现在工具列表中。

**`FetchURL`** 接受单个 `url` 参数,返回页面内容。对 HTML 页面,宿主会提取正文而非返回完整 HTML;纯文本或 Markdown 页面直接透传。同样需要宿主注入实现。

Expand Down
13 changes: 8 additions & 5 deletions packages/agent-core/src/tools/builtin/web/fetch-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,18 @@ export class FetchURLTool implements BuiltinTool<FetchURLInput> {

const builder = new ToolResultBuilder({ maxLineLength: null });
// Tell the LLM whether it received the whole body or only the extracted
// article text, so it can judge how complete the content is. This note
// must ride in `output`: the result's `message` field is dropped from the
// transcript, so `output` is the only place the model can read it. Put it
// at the front so it survives any downstream truncation of the body.
// article text, so it can judge how complete the content is, and remind it
// to cite this page when it uses the content. Both notes must ride in
// `output`: the result's `message` field is dropped from the transcript, so
// `output` is the only place the model can read them. Put them at the front
// so they survive any downstream truncation of the body.
const note =
kind === 'passthrough'
? 'The returned content is the full response body, returned verbatim.'
: 'The returned content is the main text extracted from the page.';
builder.write(`${note}\n\n${content}`);
const citeReminder =
'If you use it in your answer, cite this page as a markdown link, e.g. [title](url).';
builder.write(`${note} ${citeReminder}\n\n${content}`);
return builder.ok();
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/tools/builtin/web/web-search.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
Search the web for information. Use this when you need up-to-date information from the internet.

Each result includes its title, a publication date when available, its URL, and a snippet. When `include_content` is enabled, the full page content—when available—is appended after the snippet.
Each result includes its title, its source site, a publication date when available, its URL, and a snippet. Results are short summaries, not full pages — when a result looks relevant, call the FetchURL tool on its URL to read the full page content. Fetch only the few URLs you actually need. Prefer specific queries, and refine the query if the results don't contain what you need.

When you rely on a result in your answer, cite its source URL so the user can verify it.
41 changes: 12 additions & 29 deletions packages/agent-core/src/tools/builtin/web/web-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,38 +22,18 @@ export interface WebSearchResult {
title: string;
url: string;
snippet: string;
date?: string | undefined;
content?: string | undefined;
date?: string;
siteName?: string;
}

export interface WebSearchProvider {
search(
query: string,
options?: { limit?: number; includeContent?: boolean; toolCallId?: string },
): Promise<WebSearchResult[]>;
search(query: string, options?: { toolCallId?: string }): Promise<WebSearchResult[]>;
}

// ── Input schema ─────────────────────────────────────────────────────

export const WebSearchInputSchema = z.object({
query: z.string().describe('The query text to search for.'),
limit: z
.number()
.int()
.min(1)
.max(20)
.default(5)
.describe(
'The number of results to return. Typically you do not need to set this value. When the results do not contain what you need, you probably want to give a more concrete query.',
)
.optional(),
include_content: z
.boolean()
.default(false)
.describe(
'Whether to include the content of the web pages in the results. It can consume a large amount of tokens when this is set to true. You should avoid enabling this when `limit` is set to a large value.',
)
.optional(),
});

export type WebSearchInput = z.Infer<typeof WebSearchInputSchema>;
Expand Down Expand Up @@ -85,11 +65,7 @@ export class WebSearchTool implements BuiltinTool<WebSearchInput> {
}: ExecutableToolContext,
): Promise<ExecutableToolResult> {
try {
const opts: { limit?: number; includeContent?: boolean; toolCallId?: string } = {
toolCallId,
};
if (args.limit !== undefined) opts.limit = args.limit;
if (args.include_content !== undefined) opts.includeContent = args.include_content;
const opts: { toolCallId?: string } = { toolCallId };
const results = await this.provider.search(args.query, opts);
const builder = new ToolResultBuilder({ maxLineLength: null });

Expand All @@ -104,12 +80,19 @@ export class WebSearchTool implements BuiltinTool<WebSearchInput> {
first = false;

builder.write(`Title: ${result.title}\n`);
if (result.siteName) builder.write(`Site: ${result.siteName}\n`);
if (result.date) builder.write(`Date: ${result.date}\n`);
builder.write(`URL: ${result.url}\n`);
builder.write(`Snippet: ${result.snippet}\n\n`);
if (result.content) builder.write(`${result.content}\n\n`);
}

// Keep the citation reminder next to the data (not just in the static tool
// description), so it is present on every search. Cite the page actually
// relied on — after a FetchURL follow-up, that is the fetched page.
builder.write(
'When you rely on a result in your answer, cite it inline as a markdown link, e.g. [title](url).',
);

return builder.ok();
} catch (error) {
return {
Expand Down
11 changes: 3 additions & 8 deletions packages/agent-core/src/tools/providers/moonshot-web-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,9 @@ export class MoonshotWebSearchProvider implements WebSearchProvider {

async search(
query: string,
options?: { limit?: number; includeContent?: boolean; toolCallId?: string },
options?: { toolCallId?: string },
): Promise<WebSearchResult[]> {
const body = {
text_query: query,
limit: options?.limit ?? 5,
enable_page_crawling: options?.includeContent ?? false,
timeout_seconds: 30,
};
const body = { text_query: query };
const bodyJson = JSON.stringify(body);

const toolCallId = options?.toolCallId;
Expand Down Expand Up @@ -92,7 +87,7 @@ export class MoonshotWebSearchProvider implements WebSearchProvider {
snippet: r.snippet ?? '',
};
if (typeof r.date === 'string' && r.date.length > 0) out.date = r.date;
if (typeof r.content === 'string' && r.content.length > 0) out.content = r.content;
if (typeof r.site_name === 'string' && r.site_name.length > 0) out.siteName = r.site_name;
return out;
});
}
Expand Down
16 changes: 16 additions & 0 deletions packages/agent-core/test/tools/fetch-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,22 @@ describe('FetchURLTool', () => {
expect((result as { message?: string }).message).toContain('Output is truncated');
});

it('keeps the citation reminder at the front so truncation cannot drop it', async () => {
const tool = new FetchURLTool(fakeFetcher('x'.repeat(60_000)));
const result = await executeTool(tool, {
turnId: 't1',
toolCallId: 'c-cite',
args: { url: 'https://example.com/large' },
signal,
});
const out = toolContentString(result);
// Body was truncated, yet the reminder — which rides in the front note —
// must survive.
expect(out).toContain('[...truncated]');
expect(out).toContain('cite');
expect(out).toContain('[title](url)');
});

it('returns error when fetcher throws', async () => {
const fetcher: UrlFetcher = {
fetch: vi.fn().mockRejectedValue(new Error('timeout')),
Expand Down
140 changes: 110 additions & 30 deletions packages/agent-core/test/tools/web-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,24 +41,10 @@ describe('WebSearchTool', () => {
});
});

it('limit description guides toward refining the query instead of raising limit', () => {
it('parameters expose only the query field', () => {
const tool = new WebSearchTool(fakeProvider());
const limit = (tool.parameters as { properties: Record<string, { description?: string }> })
.properties['limit'];
expect(limit?.description).toContain('Typically you do not need to set this value');
expect(limit?.description).toContain('more concrete query');
});

it('include_content description warns about token cost at large limits', () => {
const tool = new WebSearchTool(fakeProvider());
const includeContent = (
tool.parameters as { properties: Record<string, { description?: string }> }
).properties['include_content'];
expect(includeContent?.description).toContain('consume a large amount of tokens');
expect(includeContent?.description).toContain('avoid enabling this when `limit` is set');
// Use the TS/JSON boolean literal, not Python's capitalized `True`.
expect(includeContent?.description).toContain('set to true');
expect(includeContent?.description).not.toContain('True');
const properties = (tool.parameters as { properties: Record<string, unknown> }).properties;
expect(Object.keys(properties)).toEqual(['query']);
});

it('returns formatted results from provider', async () => {
Expand Down Expand Up @@ -97,23 +83,25 @@ describe('WebSearchTool', () => {
expect(content).not.toContain('Summary:');
});

it('describes every returned field (date and content) in the tool description', () => {
it('describes the returned fields and points to FetchURL for full pages', () => {
const tool = new WebSearchTool(fakeProvider());
const description = tool.description.toLowerCase();
expect(description).toContain('title');
expect(description).toContain('url');
expect(description).toContain('snippet');
expect(description).toContain('date');
expect(description).toContain('content');
expect(description).toContain('site');
expect(description).toContain('fetchurl');
});

it('does not promise page content unconditionally for every result', () => {
// Page content is rendered only when the provider returns it (`include_content`
// is merely forwarded to the provider). The description must not claim it is
// appended for every result, or it repeats the overpromise this PR fixes.
it('frames results as summaries rather than inlined full pages', () => {
// Search results no longer carry page content; full content is fetched on
// demand via FetchURL. The description must not claim full content is
// inlined for every result.
const tool = new WebSearchTool(fakeProvider());
const description = tool.description.toLowerCase();
expect(description).not.toContain('for each result');
expect(description).toContain('summaries');
});

it('instructs the model to cite source URLs in its description', () => {
Expand All @@ -123,6 +111,47 @@ describe('WebSearchTool', () => {
expect(description).toContain('source');
});

it('renders the source site when the provider returns it', async () => {
const provider = fakeProvider([
{ title: 'Result 1', url: 'https://example.com/1', snippet: 'Snippet 1', siteName: 'Example Co' },
]);
const tool = new WebSearchTool(provider);
const result = await executeTool(tool, {
turnId: 't1',
toolCallId: 'c-site',
args: { query: 'test query' },
signal,
});
expect(toolContentString(result)).toContain('Site: Example Co');
});

it('appends a citation reminder after the results', async () => {
const provider = fakeProvider([
{ title: 'Result 1', url: 'https://example.com/1', snippet: 'Snippet 1' },
]);
const tool = new WebSearchTool(provider);
const result = await executeTool(tool, {
turnId: 't1',
toolCallId: 'c-cite',
args: { query: 'test query' },
signal,
});
const content = toolContentString(result);
expect(content).toContain('cite');
expect(content).toContain('[title](url)');
});

it('does not append the citation reminder when there are no results', async () => {
const tool = new WebSearchTool(fakeProvider([]));
const result = await executeTool(tool, {
turnId: 't1',
toolCallId: 'c-cite-empty',
args: { query: 'nothing' },
signal,
});
expect(toolContentString(result)).not.toContain('[title](url)');
});

it('returns no results message when provider returns empty', async () => {
const tool = new WebSearchTool(fakeProvider([]));
const result = await executeTool(tool, {
Expand All @@ -141,16 +170,15 @@ describe('WebSearchTool', () => {
{
title: 'Large result',
url: 'https://example.com/large',
snippet: 'Large snippet',
content: 'x'.repeat(60_000),
snippet: 'x'.repeat(60_000),
},
]),
);

const result = await executeTool(tool, {
turnId: 't1',
toolCallId: 'c-large',
args: { query: 'large', include_content: true },
args: { query: 'large' },
signal,
});

Expand Down Expand Up @@ -238,18 +266,16 @@ describe('WebSearchTool', () => {
expect(content).toContain('The operation was aborted');
});

it('passes limit and includeContent to provider', async () => {
it('passes only the query and toolCallId to the provider', async () => {
const provider = fakeProvider([]);
const tool = new WebSearchTool(provider);
await executeTool(tool, {
turnId: 't1',
toolCallId: 'c4',
args: { query: 'test', limit: 10, include_content: true },
args: { query: 'test' },
signal,
});
expect(provider.search).toHaveBeenCalledWith('test', {
limit: 10,
includeContent: true,
toolCallId: 'c4',
});
});
Expand All @@ -273,6 +299,60 @@ describe('WebSearchTool', () => {
});

describe('MoonshotWebSearchProvider', () => {
it('maps site_name to siteName and does not forward page content', async () => {
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(
new Response(
JSON.stringify({
search_results: [
{
title: 'T',
url: 'https://e.com',
snippet: 'S',
site_name: 'E Co',
date: '2026-01-01',
content: 'FULL PAGE BODY',
},
],
}),
{ status: 200 },
),
);
const provider = new MoonshotWebSearchProvider({
apiKey: 'k',
baseUrl: 'https://search.example/v1',
fetchImpl,
});

const [r] = await provider.search('q');

expect(r).toEqual({
title: 'T',
url: 'https://e.com',
snippet: 'S',
siteName: 'E Co',
date: '2026-01-01',
});
expect(r).not.toHaveProperty('content');
});

it('sends only text_query in the request body (no limit/crawling/timeout)', async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValue(
new Response(JSON.stringify({ search_results: [] }), { status: 200 }),
);
const provider = new MoonshotWebSearchProvider({
apiKey: 'k',
baseUrl: 'https://search.example/v1',
fetchImpl,
});

await provider.search('hello');

const sent = fetchImpl.mock.calls[0]?.[1]?.body;
expect(sent).toBe(JSON.stringify({ text_query: 'hello' }));
});

it('does not force-refresh request auth after a 401 response', async () => {
const getAccessToken = vi.fn().mockResolvedValue('fresh-token');
const fetchImpl = vi
Expand Down
Loading