From 5b0f8d8cae50bee3d36ec05605845b70e176dc00 Mon Sep 17 00:00:00 2001 From: Caldalis <2726397382@qq.com> Date: Thu, 25 Jun 2026 23:08:07 +0800 Subject: [PATCH 1/2] fix(agent-core): honor abort signal in WebSearch and FetchURL tools The web tools dropped the per-call AbortSignal: FetchURLTool and WebSearchTool forwarded only toolCallId, and the local/Moonshot providers called fetch() without a signal. Cancelling a turn left in-flight HTTP requests running in the background. Thread ctx.signal through the tools and providers down to the underlying fetch, matching how Bash, Grep, and Agent already honor cancellation. --- .changeset/fix-web-tools-abort-signal.md | 5 +++++ .../src/tools/builtin/web/fetch-url.ts | 8 +++++-- .../src/tools/builtin/web/web-search.ts | 11 ++++++++-- .../src/tools/providers/local-fetch-url.ts | 6 +++++- .../src/tools/providers/moonshot-fetch-url.ts | 17 +++++++++++---- .../tools/providers/moonshot-web-search.ts | 11 +++++++--- .../agent-core/test/tools/fetch-url.test.ts | 3 ++- .../tools/providers/local-fetch-url.test.ts | 12 +++++++++++ .../agent-core/test/tools/web-search.test.ts | 21 ++++++++++++++++++- 9 files changed, 80 insertions(+), 14 deletions(-) create mode 100644 .changeset/fix-web-tools-abort-signal.md diff --git a/.changeset/fix-web-tools-abort-signal.md b/.changeset/fix-web-tools-abort-signal.md new file mode 100644 index 000000000..81b6dde8b --- /dev/null +++ b/.changeset/fix-web-tools-abort-signal.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Cancelling a turn now aborts an in-flight WebSearch or FetchURL request instead of leaving it running in the background. The web tools previously dropped the abort signal, so a hung or slow network request kept consuming a connection after the user pressed Ctrl-C. 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 9ea5b126c..59e787f41 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 8129fc5d7..02de39af6 100644 --- a/packages/agent-core/src/tools/builtin/web/web-search.ts +++ b/packages/agent-core/src/tools/builtin/web/web-search.ts @@ -29,7 +29,7 @@ export interface WebSearchResult { export interface WebSearchProvider { search( query: string, - options?: { limit?: number; includeContent?: boolean; toolCallId?: string }, + options?: { limit?: number; includeContent?: boolean; toolCallId?: string; signal?: AbortSignal }, ): Promise; } @@ -82,11 +82,18 @@ export class WebSearchTool implements BuiltinTool { args: WebSearchInput, { toolCallId, + signal, }: ExecutableToolContext, ): Promise { try { - const opts: { limit?: number; includeContent?: boolean; toolCallId?: string } = { + 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; 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 af10a8ca3..154986c60 100644 --- a/packages/agent-core/src/tools/providers/local-fetch-url.ts +++ b/packages/agent-core/src/tools/providers/local-fetch-url.ts @@ -141,12 +141,16 @@ 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 { assertSafeFetchTarget(url, this.allowPrivateAddresses); const response = await this.fetchImpl(url, { method: 'GET', headers: { 'User-Agent': this.userAgent }, + signal: options?.signal, }); if (response.status >= 400) { 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 825781da4..51ef276ed 100644 --- a/packages/agent-core/src/tools/providers/moonshot-fetch-url.ts +++ b/packages/agent-core/src/tools/providers/moonshot-fetch-url.ts @@ -48,9 +48,12 @@ 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 { @@ -63,10 +66,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 +89,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 +108,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 f1ef6c18b..a0970756d 100644 --- a/packages/agent-core/src/tools/providers/moonshot-web-search.ts +++ b/packages/agent-core/src/tools/providers/moonshot-web-search.ts @@ -55,7 +55,7 @@ export class MoonshotWebSearchProvider implements WebSearchProvider { async search( query: string, - options?: { limit?: number; includeContent?: boolean; toolCallId?: string }, + options?: { limit?: number; includeContent?: boolean; toolCallId?: string; signal?: AbortSignal }, ): Promise { const body = { text_query: query, @@ -66,7 +66,7 @@ export class MoonshotWebSearchProvider implements WebSearchProvider { 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); @@ -97,7 +97,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', @@ -111,6 +115,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 0e5c55ee6..a64d1824d 100644 --- a/packages/agent-core/test/tools/fetch-url.test.ts +++ b/packages/agent-core/test/tools/fetch-url.test.ts @@ -134,7 +134,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, { @@ -145,6 +145,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 2c0ce931f..4311c8318 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 @@ -55,4 +55,16 @@ describe('LocalFetchURLProvider content kind', () => { expect(result.kind).toBe('extracted'); expect(result.content).toContain('quick brown fox'); }); + it('forwards the caller abort signal to the underlying fetch', async () => { + const controller = new AbortController(); + const fetchImpl = vi + .fn() + .mockResolvedValue(htmlResponse('plain body', 'text/plain')); + const provider = new LocalFetchURLProvider({ fetchImpl }); + await provider.fetch('https://example.com/file.txt', { signal: controller.signal }); + expect(fetchImpl).toHaveBeenCalledWith( + 'https://example.com/file.txt', + expect.objectContaining({ signal: controller.signal }), + ); + }); }); diff --git a/packages/agent-core/test/tools/web-search.test.ts b/packages/agent-core/test/tools/web-search.test.ts index 94a1f4064..18bf6be77 100644 --- a/packages/agent-core/test/tools/web-search.test.ts +++ b/packages/agent-core/test/tools/web-search.test.ts @@ -231,7 +231,7 @@ describe('WebSearchTool', () => { expect(content).toContain('The operation was aborted'); }); - it('passes limit and includeContent to provider', async () => { + it('passes limit, includeContent, and abort signal to provider', async () => { const provider = fakeProvider([]); const tool = new WebSearchTool(provider); await executeTool(tool, { @@ -244,6 +244,7 @@ describe('WebSearchTool', () => { limit: 10, includeContent: true, toolCallId: 'c4', + signal, }); }); @@ -286,4 +287,22 @@ describe('MoonshotWebSearchProvider', () => { Authorization: 'Bearer fresh-token', }); }); + it('forwards the caller abort signal to the underlying fetch', async () => { + const controller = new AbortController(); + const fetchImpl = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ search_results: [] }), { status: 200 }), + ); + const provider = new MoonshotWebSearchProvider({ + apiKey: 'test-key', + baseUrl: 'https://search.example/v1', + fetchImpl, + }); + await provider.search('query', { signal: controller.signal }); + expect(fetchImpl).toHaveBeenCalledWith( + 'https://search.example/v1', + expect.objectContaining({ signal: controller.signal }), + ); + }); }); From 120345b934a66ec588a827bfb670e861c7aeb01c Mon Sep 17 00:00:00 2001 From: Caldalis <2726397382@qq.com> Date: Fri, 26 Jun 2026 16:39:13 +0800 Subject: [PATCH 2/2] fix(agent-core): propagate abort from Moonshot fetch instead of falling back --- .../src/tools/providers/moonshot-fetch-url.ts | 5 ++++- .../agent-core/test/tools/fetch-url.test.ts | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) 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 51ef276ed..e3e724949 100644 --- a/packages/agent-core/src/tools/providers/moonshot-fetch-url.ts +++ b/packages/agent-core/src/tools/providers/moonshot-fetch-url.ts @@ -56,7 +56,10 @@ export class MoonshotFetchURLProvider implements UrlFetcher { 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) { + 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 ?? {}); diff --git a/packages/agent-core/test/tools/fetch-url.test.ts b/packages/agent-core/test/tools/fetch-url.test.ts index a64d1824d..18927f63b 100644 --- a/packages/agent-core/test/tools/fetch-url.test.ts +++ b/packages/agent-core/test/tools/fetch-url.test.ts @@ -286,4 +286,22 @@ describe('MoonshotFetchURLProvider', () => { expect(fetchImpl).toHaveBeenCalledTimes(1); expect(localFallback.fetch).toHaveBeenCalledWith('https://example.com/page', {}); }); + it('propagates an abort instead of falling back to the local fetcher', async () => { + const controller = new AbortController(); + const localFallback = fakeFetcher('fallback content'); + const fetchImpl = vi.fn().mockImplementation(() => { + controller.abort(); + return Promise.reject(new DOMException('The operation was aborted', 'AbortError')); + }); + const provider = new MoonshotFetchURLProvider({ + apiKey: 'test-key', + baseUrl: 'https://fetch.example/v1', + localFallback, + fetchImpl, + }); + await expect( + provider.fetch('https://example.com/page', { signal: controller.signal }), + ).rejects.toThrow(/abort/i); + expect(localFallback.fetch).not.toHaveBeenCalled(); + }); });