diff --git a/.changeset/fix-web-tools-abort-signal.md b/.changeset/fix-web-tools-abort-signal.md new file mode 100644 index 0000000000..e803493a67 --- /dev/null +++ b/.changeset/fix-web-tools-abort-signal.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Honor the turn's abort signal in the WebSearch and FetchURL tools. Cancelling a turn (Ctrl-C) now aborts the in-flight HTTP request instead of leaving it running in the background: the signal is threaded through the tools to the Moonshot fetch/search providers and the local fallback fetcher's per-hop request. A caller-driven abort of the Moonshot fetch no longer retries the local fallback, so cancellation surfaces cleanly. 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 d130e3977a..cacbbe02da 100644 --- a/packages/agent-core/src/tools/builtin/web/fetch-url.ts +++ b/packages/agent-core/src/tools/builtin/web/fetch-url.ts @@ -36,7 +36,10 @@ export interface UrlFetchResult { } export interface UrlFetcher { - fetch(url: string, options?: { toolCallId?: string }): Promise; + fetch( + url: string, + options?: { toolCallId?: string; signal?: AbortSignal }, + ): Promise; } /** @@ -86,10 +89,11 @@ export class FetchURLTool implements BuiltinTool { args: FetchURLInput, { toolCallId, + signal, }: ExecutableToolContext, ): Promise { try { - const { content, kind } = await this.fetcher.fetch(args.url, { toolCallId }); + const { content, kind } = await this.fetcher.fetch(args.url, { toolCallId, signal }); if (!content) { return { 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 85b22b7cc0..cf3dac53ba 100644 --- a/packages/agent-core/src/tools/builtin/web/web-search.ts +++ b/packages/agent-core/src/tools/builtin/web/web-search.ts @@ -27,7 +27,10 @@ export interface WebSearchResult { } export interface WebSearchProvider { - search(query: string, options?: { toolCallId?: string }): Promise; + search( + query: string, + options?: { toolCallId?: string; signal?: AbortSignal }, + ): Promise; } // ── Input schema ───────────────────────────────────────────────────── @@ -62,10 +65,11 @@ export class WebSearchTool implements BuiltinTool { args: WebSearchInput, { toolCallId, + signal, }: ExecutableToolContext, ): Promise { try { - const opts: { toolCallId?: string } = { toolCallId }; + const opts: { toolCallId?: string; signal?: AbortSignal } = { toolCallId, signal }; const results = await this.provider.search(args.query, opts); const builder = new ToolResultBuilder({ maxLineLength: null }); diff --git a/packages/agent-core/src/tools/providers/local-fetch-url.ts b/packages/agent-core/src/tools/providers/local-fetch-url.ts index 1b8e50844e..6f00640036 100644 --- a/packages/agent-core/src/tools/providers/local-fetch-url.ts +++ b/packages/agent-core/src/tools/providers/local-fetch-url.ts @@ -206,12 +206,15 @@ export class LocalFetchURLProvider implements UrlFetcher { this.allowPrivateAddresses = options.allowPrivateAddresses ?? false; } - async fetch(url: string, _options?: { toolCallId?: string }): Promise { + async fetch( + url: string, + options?: { toolCallId?: string; signal?: AbortSignal }, + ): Promise { // Pinned Agents are created per redirect hop and closed once the final // body is consumed, so keep-alive sockets never linger. const dispatchers: Dispatcher[] = []; try { - const response = await this.requestWithValidatedRedirects(url, dispatchers); + const response = await this.requestWithValidatedRedirects(url, dispatchers, options?.signal); return await this.readResponse(response); } finally { await Promise.all( @@ -280,6 +283,7 @@ export class LocalFetchURLProvider implements UrlFetcher { private async requestWithValidatedRedirects( url: string, dispatchers: Dispatcher[], + signal: AbortSignal | undefined, ): Promise { let currentUrl = url; let redirects = 0; @@ -289,6 +293,7 @@ export class LocalFetchURLProvider implements UrlFetcher { method: 'GET', headers: { 'User-Agent': this.userAgent }, redirect: 'manual', + signal, // `dispatcher` is honored by undici at runtime but absent from // DOM's RequestInit type (DOM-lib consumers typecheck this source) // — hide it behind `unknown` to stay lib-agnostic. diff --git a/packages/agent-core/src/tools/providers/moonshot-fetch-url.ts b/packages/agent-core/src/tools/providers/moonshot-fetch-url.ts index 825781da48..794d3d731c 100644 --- a/packages/agent-core/src/tools/providers/moonshot-fetch-url.ts +++ b/packages/agent-core/src/tools/providers/moonshot-fetch-url.ts @@ -48,12 +48,21 @@ export class MoonshotFetchURLProvider implements UrlFetcher { this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); } - async fetch(url: string, options?: { toolCallId?: string }): Promise { + async fetch( + url: string, + options?: { toolCallId?: string; signal?: AbortSignal }, + ): Promise { try { - const content = await this.fetchViaMoonshot(url, options?.toolCallId); + const content = await this.fetchViaMoonshot(url, options?.toolCallId, options?.signal); // The service returns text it has already extracted from the page. return { content, kind: 'extracted' }; - } catch { + } catch (error) { + // A caller-driven abort is not a service failure — surface it as a + // clean abort instead of retrying the local fallback (which would run + // the whole fetch again and mask the cancellation). + if (options?.signal?.aborted === true) { + throw error; + } // Forward an explicit options object even when the caller passed // none, so downstream consumers always see a defined second arg. return this.localFallback.fetch(url, options ?? {}); @@ -63,10 +72,11 @@ export class MoonshotFetchURLProvider implements UrlFetcher { private async fetchViaMoonshot( url: string, toolCallId: string | undefined, + signal: AbortSignal | undefined, ): Promise { const bodyJson = JSON.stringify({ url }); - const response = await this.post(bodyJson, toolCallId); + const response = await this.post(bodyJson, toolCallId, signal); if (response.status !== 200) { let detail = ''; @@ -85,7 +95,11 @@ export class MoonshotFetchURLProvider implements UrlFetcher { return response.text(); } - private async post(bodyJson: string, toolCallId: string | undefined): Promise { + private async post( + bodyJson: string, + toolCallId: string | undefined, + signal: AbortSignal | undefined, + ): Promise { const accessToken = await this.resolveApiKey(); return this.fetchImpl(this.baseUrl, { method: 'POST', @@ -100,6 +114,7 @@ export class MoonshotFetchURLProvider implements UrlFetcher { ...this.customHeaders, }, body: bodyJson, + signal, }); } 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 c3db47d0db..d7f1285a5b 100644 --- a/packages/agent-core/src/tools/providers/moonshot-web-search.ts +++ b/packages/agent-core/src/tools/providers/moonshot-web-search.ts @@ -55,13 +55,13 @@ export class MoonshotWebSearchProvider implements WebSearchProvider { async search( query: string, - options?: { toolCallId?: string }, + options?: { toolCallId?: string; signal?: AbortSignal }, ): Promise { const body = { text_query: query }; const bodyJson = JSON.stringify(body); const toolCallId = options?.toolCallId; - const response = await this.post(bodyJson, toolCallId); + const response = await this.post(bodyJson, toolCallId, options?.signal); if (response.status === 401) { const detail = await safeReadText(response); @@ -92,7 +92,11 @@ export class MoonshotWebSearchProvider implements WebSearchProvider { }); } - private async post(bodyJson: string, toolCallId: string | undefined): Promise { + private async post( + bodyJson: string, + toolCallId: string | undefined, + signal: AbortSignal | undefined, + ): Promise { const accessToken = await this.resolveApiKey(); return this.fetchImpl(this.baseUrl, { method: 'POST', @@ -106,6 +110,7 @@ export class MoonshotWebSearchProvider implements WebSearchProvider { ...this.customHeaders, }, body: bodyJson, + signal, }); } diff --git a/packages/agent-core/test/tools/fetch-url.test.ts b/packages/agent-core/test/tools/fetch-url.test.ts index 06f319633e..c5776e4980 100644 --- a/packages/agent-core/test/tools/fetch-url.test.ts +++ b/packages/agent-core/test/tools/fetch-url.test.ts @@ -75,6 +75,7 @@ describe('FetchURLTool', () => { expect(toolContentString(result)).toContain('Hello, world!'); }); + it('surfaces the extraction mode in the model-visible output', async () => { const tool = new FetchURLTool(fakeFetcher('Article body', 'extracted')); const result = await executeTool(tool, { @@ -165,7 +166,7 @@ describe('FetchURLTool', () => { expect(toolContentString(result)).toContain('timeout'); }); - it('passes the tool call id to the fetcher', async () => { + it('passes the tool call id and abort signal to the fetcher', async () => { const fetcher = fakeFetcher('content'); const tool = new FetchURLTool(fetcher); await executeTool(tool, { @@ -176,6 +177,7 @@ describe('FetchURLTool', () => { }); expect(fetcher.fetch).toHaveBeenCalledWith('https://example.com', { toolCallId: 'c4', + signal, }); }); diff --git a/packages/agent-core/test/tools/providers/local-fetch-url.test.ts b/packages/agent-core/test/tools/providers/local-fetch-url.test.ts index ff23ea1d8d..5eecfc3d69 100644 --- a/packages/agent-core/test/tools/providers/local-fetch-url.test.ts +++ b/packages/agent-core/test/tools/providers/local-fetch-url.test.ts @@ -393,3 +393,18 @@ describe('LocalFetchURLProvider connection pinning', () => { expect(asUndiciAgent(dispatcher).closed).toBe(true); }); }); + +describe('LocalFetchURLProvider abort signal', () => { + it("forwards the caller's abort signal to the underlying fetch", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(htmlResponse('body', 'text/plain')); + const provider = new LocalFetchURLProvider({ fetchImpl }); + const controller = new AbortController(); + + await provider.fetch('https://example.com/page', { signal: controller.signal }); + + const init = fetchImpl.mock.calls[0]![1] as RequestInit; + expect(init.signal).toBe(controller.signal); + }); +}); diff --git a/packages/agent-core/test/tools/providers/moonshot-fetch-url.test.ts b/packages/agent-core/test/tools/providers/moonshot-fetch-url.test.ts index 59c5f224e9..9128f2e001 100644 --- a/packages/agent-core/test/tools/providers/moonshot-fetch-url.test.ts +++ b/packages/agent-core/test/tools/providers/moonshot-fetch-url.test.ts @@ -92,3 +92,44 @@ describe('MoonshotFetchURLProvider content kind', () => { expect(result).toEqual({ content: 'verbatim body', kind: 'passthrough' }); }); }); + +describe('MoonshotFetchURLProvider abort signal', () => { + it("forwards the caller's abort signal to the service request", async () => { + const getAccessToken = vi.fn<() => Promise>().mockResolvedValue('token'); + const fetchImpl = vi.fn().mockResolvedValue(new Response('ok', { status: 200 })); + const provider = new MoonshotFetchURLProvider({ + tokenProvider: { getAccessToken }, + baseUrl: 'https://fetch.example/v1', + localFallback: fakeFetcher('fallback content'), + fetchImpl, + }); + const controller = new AbortController(); + + await provider.fetch('https://example.com/page', { signal: controller.signal }); + + const init = fetchImpl.mock.calls[0]?.[1]; + expect(init?.signal).toBe(controller.signal); + }); + + it('propagates an abort instead of falling back to the local fetcher', async () => { + const getAccessToken = vi.fn<() => Promise>().mockResolvedValue('token'); + const controller = new AbortController(); + const abortError = new DOMException('The operation was aborted.', 'AbortError'); + const fetchImpl = vi.fn().mockImplementation(() => { + controller.abort(); + return Promise.reject(abortError); + }); + const localFallback = fakeFetcher('fallback content'); + const provider = new MoonshotFetchURLProvider({ + tokenProvider: { getAccessToken }, + baseUrl: 'https://fetch.example/v1', + localFallback, + fetchImpl, + }); + + await expect( + provider.fetch('https://example.com/page', { signal: controller.signal }), + ).rejects.toBe(abortError); + expect(localFallback.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/agent-core/test/tools/web-search.test.ts b/packages/agent-core/test/tools/web-search.test.ts index b8888e8c68..ad502854b7 100644 --- a/packages/agent-core/test/tools/web-search.test.ts +++ b/packages/agent-core/test/tools/web-search.test.ts @@ -67,6 +67,7 @@ describe('WebSearchTool', () => { expect(content).toContain('2024-01-01'); }); + it('renders the snippet under a "Snippet:" label consistent with the schema term', async () => { const provider = fakeProvider([ { title: 'Result 1', url: 'https://example.com/1', snippet: 'Snippet 1' }, @@ -266,7 +267,7 @@ describe('WebSearchTool', () => { expect(content).toContain('The operation was aborted'); }); - it('passes only the query and toolCallId to the provider', async () => { + it('passes the query, toolCallId, and abort signal to the provider', async () => { const provider = fakeProvider([]); const tool = new WebSearchTool(provider); await executeTool(tool, { @@ -277,6 +278,7 @@ describe('WebSearchTool', () => { }); expect(provider.search).toHaveBeenCalledWith('test', { toolCallId: 'c4', + signal, }); });