chore: webSearch & FetchUrl Sync #1260

This commit is contained in:
qer 2026-07-08 20:40:31 +08:00
parent 1be4e0b100
commit 6bdad1328e
6 changed files with 45 additions and 49 deletions

1
.gitignore vendored
View file

@ -19,6 +19,7 @@ plugins/cdn/
.worktrees/
.kimi-code/local.toml
.kimi-sandbox/
.vscode/
Dockerfile
docker-compose.yml

View file

@ -48,18 +48,11 @@ export class MoonshotWebSearchProvider implements WebSearchProvider {
async search(
query: string,
options?: {
limit?: number;
includeContent?: boolean;
toolCallId?: string;
signal?: AbortSignal;
},
): 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;
@ -90,7 +83,6 @@ export class MoonshotWebSearchProvider implements WebSearchProvider {
};
if (typeof r.date === 'string' && r.date.length > 0) out.date = r.date;
if (typeof r.site_name === 'string' && r.site_name.length > 0) out.siteName = r.site_name;
if (typeof r.content === 'string' && r.content.length > 0) out.content = r.content;
return out;
});
}

View file

@ -36,15 +36,12 @@ export interface WebSearchResult {
snippet: string;
date?: string;
siteName?: string;
content?: string;
}
export interface WebSearchProvider {
search(
query: string,
options?: {
limit?: number;
includeContent?: boolean;
toolCallId?: string;
signal?: AbortSignal;
},
@ -55,23 +52,6 @@ export interface WebSearchProvider {
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>;
@ -102,18 +82,7 @@ export class WebSearchTool implements BuiltinTool<WebSearchInput> {
{ toolCallId, signal }: ExecutableToolContext,
): Promise<ExecutableToolResult> {
try {
const opts: {
limit?: number;
includeContent?: boolean;
toolCallId?: string;
signal?: AbortSignal;
} = {
toolCallId,
signal,
};
if (args.limit !== undefined) opts.limit = args.limit;
if (args.include_content !== undefined) opts.includeContent = args.include_content;
const results = await this.provider.search(args.query, opts);
const results = await this.provider.search(args.query, { toolCallId, signal });
const builder = new ToolResultBuilder({ maxLineLength: null });
if (results.length === 0) {
@ -131,7 +100,6 @@ export class WebSearchTool implements BuiltinTool<WebSearchInput> {
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

View file

@ -70,15 +70,20 @@ export class FetchURLTool implements BuiltinTool<FetchURLInput> {
}
const builder = new ToolResultBuilder({ maxLineLength: null });
builder.write(content);
// Tell the LLM whether it received the whole body or only the
// extracted article text, so it can judge how complete the
// content is.
const message =
// Tell the LLM whether it received the whole body or only the extracted
// 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.';
return builder.ok(message);
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) {
// An in-flight abort rejects the signal-aware fetch promptly. Re-throw
// so the executor can classify it (including user cancellation) and

View file

@ -78,6 +78,35 @@ describe('FetchURLTool abort signal', () => {
});
});
describe('FetchURLTool output note', () => {
async function runKind(kind: UrlFetchResult['kind']): Promise<string> {
const fetch = vi
.fn<UrlFetcher['fetch']>()
.mockResolvedValue({ content: 'BODY', kind } satisfies UrlFetchResult);
const tool = new FetchURLTool({ fetch });
const result = await execute(tool, 'https://example.com', new AbortController().signal);
expect(result.isError).toBe(false);
if (typeof result.output !== 'string') throw new Error('expected string output');
return result.output;
}
it('puts the passthrough note and citation reminder at the front of output', async () => {
const output = await runKind('passthrough');
expect(output).toBe(
'The returned content is the full response body, returned verbatim. ' +
'If you use it in your answer, cite this page as a markdown link, e.g. [title](url).\n\nBODY',
);
});
it('puts the extracted note and citation reminder at the front of output', async () => {
const output = await runKind('extracted');
expect(output).toBe(
'The returned content is the main text extracted from the page. ' +
'If you use it in your answer, cite this page as a markdown link, e.g. [title](url).\n\nBODY',
);
});
});
describe('LocalFetchURLProvider abort signal', () => {
it('passes the signal through to fetchImpl', async () => {
const controller = new AbortController();

View file

@ -678,7 +678,7 @@ describe('WebSearchProviderService', () => {
const provider = createService().getWebSearchProvider();
expect(provider).not.toBeUndefined();
const results = await provider!.search('hello', { limit: 2 });
const results = await provider!.search('hello');
expect(results).toEqual([
{ title: 'Title', url: 'https://example.com', snippet: 'Snippet' },
@ -689,6 +689,7 @@ describe('WebSearchProviderService', () => {
const headers = init.headers as Record<string, string>;
expect(headers['Authorization']).toBe('Bearer access-token');
expect(headers['X-Custom']).toBe('yes');
expect(JSON.parse(init.body as string)).toEqual({ text_query: 'hello' });
});
});