diff --git a/.changeset/web-search-query-only.md b/.changeset/web-search-query-only.md new file mode 100644 index 000000000..adcdee0ab --- /dev/null +++ b/.changeset/web-search-query-only.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": minor +"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. diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index 7de63bd32..b62743859 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -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. diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 1a9ae9972..575ef7545 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -53,7 +53,7 @@ | `WebSearch` | 自动放行 | 网络搜索 | | `FetchURL` | 自动放行 | 获取指定 URL 的内容 | -**`WebSearch`** 接受 `query`(搜索词)和可选的 `limit`(返回结果数,1–20,默认 5)及 `include_content`(是否返回网页正文,默认 false)。需要宿主提供搜索实现,未注入时不会出现在工具列表中。 +**`WebSearch`** 接受 `query`(搜索词)。需要宿主提供搜索实现,未注入时不会出现在工具列表中。 **`FetchURL`** 接受单个 `url` 参数,返回页面内容。对 HTML 页面,宿主会提取正文而非返回完整 HTML;纯文本或 Markdown 页面直接透传。同样需要宿主注入实现。 diff --git a/packages/agent-core/src/tools/builtin/web/fetch-url.ts b/packages/agent-core/src/tools/builtin/web/fetch-url.ts index 38631a9a4..d130e3977 100644 --- a/packages/agent-core/src/tools/builtin/web/fetch-url.ts +++ b/packages/agent-core/src/tools/builtin/web/fetch-url.ts @@ -100,15 +100,18 @@ export class FetchURLTool implements BuiltinTool { 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); diff --git a/packages/agent-core/src/tools/builtin/web/web-search.md b/packages/agent-core/src/tools/builtin/web/web-search.md index 80e6929c2..ad1f4cff1 100644 --- a/packages/agent-core/src/tools/builtin/web/web-search.md +++ b/packages/agent-core/src/tools/builtin/web/web-search.md @@ -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. diff --git a/packages/agent-core/src/tools/builtin/web/web-search.ts b/packages/agent-core/src/tools/builtin/web/web-search.ts index 8129fc5d7..85b22b7cc 100644 --- a/packages/agent-core/src/tools/builtin/web/web-search.ts +++ b/packages/agent-core/src/tools/builtin/web/web-search.ts @@ -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; + search(query: string, options?: { toolCallId?: string }): Promise; } // ── 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; @@ -85,11 +65,7 @@ export class WebSearchTool implements BuiltinTool { }: ExecutableToolContext, ): Promise { 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 }); @@ -104,12 +80,19 @@ export class WebSearchTool implements BuiltinTool { 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 { diff --git a/packages/agent-core/src/tools/providers/moonshot-web-search.ts b/packages/agent-core/src/tools/providers/moonshot-web-search.ts index f1ef6c18b..c3db47d0d 100644 --- a/packages/agent-core/src/tools/providers/moonshot-web-search.ts +++ b/packages/agent-core/src/tools/providers/moonshot-web-search.ts @@ -55,14 +55,9 @@ export class MoonshotWebSearchProvider implements WebSearchProvider { async search( query: string, - options?: { limit?: number; includeContent?: boolean; toolCallId?: string }, + options?: { toolCallId?: string }, ): Promise { - 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; @@ -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; }); } diff --git a/packages/agent-core/test/tools/fetch-url.test.ts b/packages/agent-core/test/tools/fetch-url.test.ts index 19d80d168..06f319633 100644 --- a/packages/agent-core/test/tools/fetch-url.test.ts +++ b/packages/agent-core/test/tools/fetch-url.test.ts @@ -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')), diff --git a/packages/agent-core/test/tools/web-search.test.ts b/packages/agent-core/test/tools/web-search.test.ts index d81589182..b8888e8c6 100644 --- a/packages/agent-core/test/tools/web-search.test.ts +++ b/packages/agent-core/test/tools/web-search.test.ts @@ -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 }) - .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 } - ).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 }).properties; + expect(Object.keys(properties)).toEqual(['query']); }); it('returns formatted results from provider', async () => { @@ -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', () => { @@ -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, { @@ -141,8 +170,7 @@ describe('WebSearchTool', () => { { title: 'Large result', url: 'https://example.com/large', - snippet: 'Large snippet', - content: 'x'.repeat(60_000), + snippet: 'x'.repeat(60_000), }, ]), ); @@ -150,7 +178,7 @@ describe('WebSearchTool', () => { const result = await executeTool(tool, { turnId: 't1', toolCallId: 'c-large', - args: { query: 'large', include_content: true }, + args: { query: 'large' }, signal, }); @@ -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', }); }); @@ -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().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() + .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