refactor: share LLM retry classification (#92)

This commit is contained in:
_Kerman 2026-05-27 11:20:26 +08:00 committed by GitHub
parent cef5efc619
commit 4e458d6364
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 71 additions and 50 deletions

View file

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

View file

@ -6,10 +6,8 @@ import {
toKimiErrorPayload,
} from '#/errors';
import {
APIConnectionError,
APIEmptyResponseError,
APIStatusError,
APITimeoutError,
isRetryableGenerateError,
inputTotal,
type GenerateResult,
type Message,
@ -470,7 +468,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;
@ -547,15 +545,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);
}

View file

@ -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';
@ -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,

View file

@ -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 {
@ -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,

View file

@ -16,12 +16,9 @@
*/
import {
APIConnectionError,
APIEmptyResponseError,
APIStatusError,
APITimeoutError,
emptyUsage,
generate as kosongGenerate,
isRetryableGenerateError,
type ChatProvider,
type GenerateCallbacks,
type Message,
@ -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
@ -121,8 +114,8 @@ 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(),
};
@ -130,13 +123,7 @@ export class KosongLLM implements LLM {
}
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);
}
}

View file

@ -57,8 +57,8 @@ export interface LLMChatParams {
export interface LLMChatResponse {
toolCalls: ToolCall[];
providerFinishReason?: FinishReason | undefined;
rawFinishReason?: string | undefined;
providerFinishReason?: FinishReason;
rawFinishReason?: string;
usage: TokenUsage;
}

View file

@ -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)/,

View file

@ -60,6 +60,7 @@ export {
APIStatusError,
APITimeoutError,
ChatProviderError,
isRetryableGenerateError,
} from './errors';
/**

View file

@ -5,6 +5,7 @@ import {
APIStatusError,
APITimeoutError,
ChatProviderError,
isRetryableGenerateError,
normalizeAPIStatusError,
} from '#/errors';
import { describe, expect, it } from 'vitest';
@ -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 = [