Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-web-tools-abort-signal.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 6 additions & 2 deletions packages/agent-core/src/tools/builtin/web/fetch-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ export interface UrlFetchResult {
}

export interface UrlFetcher {
fetch(url: string, options?: { toolCallId?: string }): Promise<UrlFetchResult>;
fetch(
url: string,
options?: { toolCallId?: string; signal?: AbortSignal },
): Promise<UrlFetchResult>;
}

/**
Expand Down Expand Up @@ -86,10 +89,11 @@ export class FetchURLTool implements BuiltinTool<FetchURLInput> {
args: FetchURLInput,
{
toolCallId,
signal,
}: ExecutableToolContext,
): Promise<ExecutableToolResult> {
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 {
Expand Down
8 changes: 6 additions & 2 deletions packages/agent-core/src/tools/builtin/web/web-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ export interface WebSearchResult {
}

export interface WebSearchProvider {
search(query: string, options?: { toolCallId?: string }): Promise<WebSearchResult[]>;
search(
query: string,
options?: { toolCallId?: string; signal?: AbortSignal },
): Promise<WebSearchResult[]>;
}

// ── Input schema ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -62,10 +65,11 @@ export class WebSearchTool implements BuiltinTool<WebSearchInput> {
args: WebSearchInput,
{
toolCallId,
signal,
}: ExecutableToolContext,
): Promise<ExecutableToolResult> {
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 });

Expand Down
9 changes: 7 additions & 2 deletions packages/agent-core/src/tools/providers/local-fetch-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,12 +206,15 @@ export class LocalFetchURLProvider implements UrlFetcher {
this.allowPrivateAddresses = options.allowPrivateAddresses ?? false;
}

async fetch(url: string, _options?: { toolCallId?: string }): Promise<UrlFetchResult> {
async fetch(
url: string,
options?: { toolCallId?: string; signal?: AbortSignal },
): Promise<UrlFetchResult> {
// 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(
Expand Down Expand Up @@ -280,6 +283,7 @@ export class LocalFetchURLProvider implements UrlFetcher {
private async requestWithValidatedRedirects(
url: string,
dispatchers: Dispatcher[],
signal: AbortSignal | undefined,
): Promise<Response> {
let currentUrl = url;
let redirects = 0;
Expand All @@ -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.
Expand Down
25 changes: 20 additions & 5 deletions packages/agent-core/src/tools/providers/moonshot-fetch-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UrlFetchResult> {
async fetch(
url: string,
options?: { toolCallId?: string; signal?: AbortSignal },
): Promise<UrlFetchResult> {
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 ?? {});
Expand All @@ -63,10 +72,11 @@ export class MoonshotFetchURLProvider implements UrlFetcher {
private async fetchViaMoonshot(
url: string,
toolCallId: string | undefined,
signal: AbortSignal | undefined,
): Promise<string> {
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 = '';
Expand All @@ -85,7 +95,11 @@ export class MoonshotFetchURLProvider implements UrlFetcher {
return response.text();
}

private async post(bodyJson: string, toolCallId: string | undefined): Promise<Response> {
private async post(
bodyJson: string,
toolCallId: string | undefined,
signal: AbortSignal | undefined,
): Promise<Response> {
const accessToken = await this.resolveApiKey();
return this.fetchImpl(this.baseUrl, {
method: 'POST',
Expand All @@ -100,6 +114,7 @@ export class MoonshotFetchURLProvider implements UrlFetcher {
...this.customHeaders,
},
body: bodyJson,
signal,
});
}

Expand Down
11 changes: 8 additions & 3 deletions packages/agent-core/src/tools/providers/moonshot-web-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,13 @@ export class MoonshotWebSearchProvider implements WebSearchProvider {

async search(
query: string,
options?: { toolCallId?: string },
options?: { toolCallId?: string; signal?: AbortSignal },
): Promise<WebSearchResult[]> {
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);
Expand Down Expand Up @@ -92,7 +92,11 @@ export class MoonshotWebSearchProvider implements WebSearchProvider {
});
}

private async post(bodyJson: string, toolCallId: string | undefined): Promise<Response> {
private async post(
bodyJson: string,
toolCallId: string | undefined,
signal: AbortSignal | undefined,
): Promise<Response> {
const accessToken = await this.resolveApiKey();
return this.fetchImpl(this.baseUrl, {
method: 'POST',
Expand All @@ -106,6 +110,7 @@ export class MoonshotWebSearchProvider implements WebSearchProvider {
...this.customHeaders,
},
body: bodyJson,
signal,
});
}

Expand Down
4 changes: 3 additions & 1 deletion packages/agent-core/test/tools/fetch-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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, {
Expand All @@ -176,6 +177,7 @@ describe('FetchURLTool', () => {
});
expect(fetcher.fetch).toHaveBeenCalledWith('https://example.com', {
toolCallId: 'c4',
signal,
});
});

Expand Down
15 changes: 15 additions & 0 deletions packages/agent-core/test/tools/providers/local-fetch-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof fetch>()
.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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>>().mockResolvedValue('token');
const fetchImpl = vi.fn<typeof fetch>().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<string>>().mockResolvedValue('token');
const controller = new AbortController();
const abortError = new DOMException('The operation was aborted.', 'AbortError');
const fetchImpl = vi.fn<typeof fetch>().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();
});
});
4 changes: 3 additions & 1 deletion packages/agent-core/test/tools/web-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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, {
Expand All @@ -277,6 +278,7 @@ describe('WebSearchTool', () => {
});
expect(provider.search).toHaveBeenCalledWith('test', {
toolCallId: 'c4',
signal,
});
});

Expand Down