Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/share-llm-retry-classification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kosong": patch
"@moonshot-ai/kimi-code": patch
---

Use one retry classification for transient LLM failures across regular turns and compaction.
18 changes: 2 additions & 16 deletions packages/agent-core/src/agent/compaction/full.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@ import {
toKimiErrorPayload,
} from '#/errors';
import {
APIConnectionError,
APIEmptyResponseError,
APIStatusError,
APITimeoutError,
isRetryableGenerateError,
inputTotal,
type GenerateResult,
type Message,
Expand Down Expand Up @@ -478,7 +476,7 @@ export class FullCompaction {
const summary = extractCompactionSummary(response);
return { response, summary, retryCount };
} catch (error) {
if (attempt >= maxAttempts || !isRetryableCompactionError(error)) {
if (attempt >= maxAttempts || !isRetryableGenerateError(error)) {
throw error;
}
retryCount += 1;
Expand Down Expand Up @@ -555,15 +553,3 @@ function compactionTelemetryTrigger(
}
return trigger;
}

function isRetryableCompactionError(error: unknown): boolean {
if (error instanceof APIConnectionError || error instanceof APITimeoutError) {
return true;
}
if (error instanceof APIEmptyResponseError) {
return true;
}
if (!(error instanceof APIStatusError)) return false;
const statusCode = (error as { readonly statusCode?: unknown }).statusCode;
return typeof statusCode === 'number' && [429, 500, 502, 503, 504].includes(statusCode);
}
19 changes: 19 additions & 0 deletions packages/agent-core/src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,11 @@ import { ToolManager } from './tool/index';
import { TurnFlow } from './turn';
import {
GENERATE_REQUEST_LOG_CONTEXT,
KosongLLM,
type GenerateOptionsWithRequestLog,
} from './turn/kosong-llm';
import { UsageRecorder } from './usage';
import { resolveCompletionBudget } from '../utils/completion-budget';

export type { AgentRecord, AgentRecordPersistence } from './records';
export type { BuiltinTool, ToolInfo, ToolSource, UserToolRegistration } from './tool';
Expand Down Expand Up @@ -178,6 +180,23 @@ export class Agent {
};
}

get llm(): KosongLLM {
const model = this.config.model;
const provider = this.config.provider.withThinking(this.config.thinkingLevel);
const loopControl = this.providerManager?.config.loopControl;
const completionBudgetConfig = resolveCompletionBudget({
reservedContextSize: loopControl?.reservedContextSize,
});
return new KosongLLM({
provider,
modelName: model,
systemPrompt: this.config.systemPrompt,
capability: this.config.modelCapabilities,
generate: this.generate,
completionBudgetConfig,
});
}

private logLlmRequest(
provider: ChatProvider,
systemPrompt: string,
Expand Down
16 changes: 1 addition & 15 deletions packages/agent-core/src/agent/turn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,9 @@ import {
import type { AgentEvent, TurnEndedEvent } from '../../rpc';
import type { TelemetryPropertyValue } from '../../telemetry';
import { abortable } from '../../utils/abort';
import { resolveCompletionBudget } from '../../utils/completion-budget';
import { USER_PROMPT_ORIGIN, type PromptOrigin } from '../context';
import { renderUserPromptHookBlockResult, renderUserPromptHookResult } from '../hooks';
import { canonicalTelemetryArgs, isPlainRecord } from './canonical-args';
import { KosongLLM } from './kosong-llm';
import { ToolCallDeduplicator } from './tool-dedup';

interface ActiveTurn {
Expand Down Expand Up @@ -364,24 +362,12 @@ export class TurnFlow {
while (true) {
signal.throwIfAborted();
const model = this.agent.config.model;
const provider = this.agent.config.provider.withThinking(this.agent.config.thinkingLevel);
const loopControl = this.agent.providerManager?.config.loopControl;
const completionBudgetConfig = resolveCompletionBudget({
reservedContextSize: loopControl?.reservedContextSize,
});

try {
const result = await runTurn({
turnId: String(turnId),
signal,
llm: new KosongLLM({
provider,
modelName: model,
systemPrompt: this.agent.config.systemPrompt,
capability: this.agent.config.modelCapabilities,
generate: this.agent.generate,
completionBudgetConfig,
}),
llm: this.agent.llm,
buildMessages: () => this.agent.context.messages,
dispatchEvent: this.buildDispatchEvent(turnId),
tools: this.agent.tools.loopTools,
Expand Down
21 changes: 4 additions & 17 deletions packages/agent-core/src/agent/turn/kosong-llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,9 @@
*/

import {
APIConnectionError,
APIEmptyResponseError,
APIStatusError,
APITimeoutError,
emptyUsage,
generate as kosongGenerate,
isRetryableGenerateError,
type ChatProvider,
type GenerateCallbacks,
type Message,
Expand Down Expand Up @@ -81,10 +78,6 @@ export class KosongLLM implements LLM {
}

async chat(params: LLMChatParams): Promise<LLMChatResponse> {
return this.chatOnce(params);
}

private async chatOnce(params: LLMChatParams): Promise<LLMChatResponse> {
const callbacks = buildKosongCallbacks(params);

// Compute and apply the per-request completion budget against a
Expand Down Expand Up @@ -127,22 +120,16 @@ export class KosongLLM implements LLM {

const response: LLMChatResponse = {
toolCalls: [...result.message.toolCalls],
...(result.finishReason !== null ? { providerFinishReason: result.finishReason } : {}),
...(result.rawFinishReason !== null ? { rawFinishReason: result.rawFinishReason } : {}),
providerFinishReason: result.finishReason ?? undefined,
rawFinishReason: result.rawFinishReason ?? undefined,
usage: result.usage ?? emptyUsage(),
};

return response;
}

isRetryableError(error: unknown): boolean {
if (error instanceof APIConnectionError || error instanceof APITimeoutError) {
return true;
}
if (error instanceof APIEmptyResponseError) {
return true;
}
return error instanceof APIStatusError && [429, 500, 502, 503, 504].includes(error.statusCode);
return isRetryableGenerateError(error);
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/agent-core/src/loop/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ export interface LLMChatParams {

export interface LLMChatResponse {
toolCalls: ToolCall[];
providerFinishReason?: FinishReason | undefined;
rawFinishReason?: string | undefined;
providerFinishReason?: FinishReason;
rawFinishReason?: string;
usage: TokenUsage;
}

Expand Down
10 changes: 10 additions & 0 deletions packages/kosong/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ export class APIEmptyResponseError extends ChatProviderError {
}
}

export function isRetryableGenerateError(error: unknown): boolean {
if (error instanceof APIConnectionError || error instanceof APITimeoutError) {
return true;
}
if (error instanceof APIEmptyResponseError) {
return true;
}
return error instanceof APIStatusError && [429, 500, 502, 503, 504].includes(error.statusCode);
}

const CONTEXT_OVERFLOW_MESSAGE_PATTERNS = [
/context[ _-]?length/,
/(?:context[ _-]?window.*exceed|exceed.*context[ _-]?window)/,
Expand Down
1 change: 1 addition & 0 deletions packages/kosong/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export {
APIStatusError,
APITimeoutError,
ChatProviderError,
isRetryableGenerateError,
} from './errors';

/**
Expand Down
25 changes: 25 additions & 0 deletions packages/kosong/test/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
APIStatusError,
APITimeoutError,
ChatProviderError,
isRetryableGenerateError,
normalizeAPIStatusError,
} from '#/errors';
import { describe, expect, it } from 'vitest';
Expand Down Expand Up @@ -84,6 +85,30 @@ describe('APIContextOverflowError', () => {
});
});

describe('isRetryableGenerateError', () => {
it('matches transient provider errors and empty generate responses', () => {
expect(isRetryableGenerateError(new APIConnectionError('conn'))).toBe(true);
expect(isRetryableGenerateError(new APITimeoutError('timeout'))).toBe(true);
expect(isRetryableGenerateError(new APIEmptyResponseError('empty'))).toBe(true);
});

it.each([429, 500, 502, 503, 504])('treats HTTP %i as retryable', (statusCode) => {
expect(isRetryableGenerateError(new APIStatusError(statusCode, 'retryable'))).toBe(true);
});

it.each([400, 401, 403, 404, 422])('treats HTTP %i as non-retryable', (statusCode) => {
expect(isRetryableGenerateError(new APIStatusError(statusCode, 'non-retryable'))).toBe(false);
});

it('does not retry context overflow or unknown errors', () => {
expect(
isRetryableGenerateError(new APIContextOverflowError(400, 'Context length exceeded')),
).toBe(false);
expect(isRetryableGenerateError(new Error('boom'))).toBe(false);
expect(isRetryableGenerateError('boom')).toBe(false);
});
});

describe('error hierarchy instanceof checks', () => {
it('all error types are instanceof ChatProviderError', () => {
const errors = [
Expand Down
Loading