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
---

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.
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
11 changes: 9 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 @@ -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<WebSearchResult[]>;
}

Expand Down Expand Up @@ -82,11 +82,18 @@ export class WebSearchTool implements BuiltinTool<WebSearchInput> {
args: WebSearchInput,
{
toolCallId,
signal,
}: ExecutableToolContext,
): Promise<ExecutableToolResult> {
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;
Expand Down
6 changes: 5 additions & 1 deletion packages/agent-core/src/tools/providers/local-fetch-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,16 @@ 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> {
assertSafeFetchTarget(url, this.allowPrivateAddresses);

const response = await this.fetchImpl(url, {
method: 'GET',
headers: { 'User-Agent': this.userAgent },
signal: options?.signal,
});

if (response.status >= 400) {
Expand Down
22 changes: 17 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,18 @@ 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) {
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 +69,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 +92,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 +111,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,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<WebSearchResult[]> {
const body = {
text_query: query,
Expand All @@ -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);
Expand Down Expand Up @@ -97,7 +97,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 @@ -111,6 +115,7 @@ export class MoonshotWebSearchProvider implements WebSearchProvider {
...this.customHeaders,
},
body: bodyJson,
signal,
});
}

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

Expand Down Expand Up @@ -285,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<typeof fetch>().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();
});
});
12 changes: 12 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 @@ -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<typeof fetch>()
.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 }),
);
});
});
21 changes: 20 additions & 1 deletion packages/agent-core/test/tools/web-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -244,6 +244,7 @@ describe('WebSearchTool', () => {
limit: 10,
includeContent: true,
toolCallId: 'c4',
signal,
});
});

Expand Down Expand Up @@ -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<typeof fetch>()
.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 }),
);
});
});