mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
fix: handle compaction truncation and output budgets (#267)
This commit is contained in:
parent
1084f1d217
commit
e2e17289fc
11 changed files with 292 additions and 6 deletions
7
.changeset/named-compaction-truncation.md
Normal file
7
.changeset/named-compaction-truncation.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@moonshot-ai/agent-core": patch
|
||||
"@moonshot-ai/kosong": patch
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Report truncated compaction summaries clearly and apply valid completion token budgets across supported providers.
|
||||
|
|
@ -25,10 +25,18 @@ import {
|
|||
estimateTokens,
|
||||
estimateTokensForMessages,
|
||||
} from '../../utils/tokens';
|
||||
import {
|
||||
applyCompletionBudget,
|
||||
resolveCompletionBudget,
|
||||
} from '../../utils/completion-budget';
|
||||
import compactionInstructionTemplate from './compaction-instruction.md';
|
||||
import { renderMessagesToText } from './render-messages';
|
||||
import type { CompactionBeginData, CompactionResult } from './types';
|
||||
import { DEFAULT_COMPACTION_CONFIG, DefaultCompactionStrategy, type CompactionStrategy } from './strategy';
|
||||
import {
|
||||
DEFAULT_COMPACTION_CONFIG,
|
||||
DefaultCompactionStrategy,
|
||||
type CompactionStrategy,
|
||||
} from './strategy';
|
||||
|
||||
type CompactionTelemetryTrigger = CompactionBeginData['source'] | 'manual-with-prompt' | 'unknown';
|
||||
|
||||
|
|
@ -38,6 +46,13 @@ export interface CompactedHistory {
|
|||
|
||||
export const MAX_COMPACTION_RETRY_ATTEMPTS = 5;
|
||||
|
||||
class CompactionTruncatedError extends Error {
|
||||
constructor() {
|
||||
super('Compaction response was truncated before producing a complete summary.');
|
||||
this.name = 'CompactionTruncatedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class FullCompaction {
|
||||
protected compactionCountInTurn = 0;
|
||||
protected compacting: {
|
||||
|
|
@ -225,6 +240,13 @@ export class FullCompaction {
|
|||
await this.triggerPreCompactHook(data, tokensBefore, signal);
|
||||
|
||||
const model = this.agent.config.model;
|
||||
const provider = applyCompletionBudget({
|
||||
provider: this.agent.config.provider,
|
||||
budget: resolveCompletionBudget({
|
||||
reservedContextSize: this.agent.kimiConfig?.loopControl?.reservedContextSize,
|
||||
}),
|
||||
capability: this.agent.config.modelCapabilities,
|
||||
});
|
||||
|
||||
const delays = retryBackoffDelays(MAX_COMPACTION_RETRY_ATTEMPTS);
|
||||
let usage: TokenUsage | null;
|
||||
|
|
@ -244,10 +266,9 @@ export class FullCompaction {
|
|||
toolCalls: [],
|
||||
} satisfies Message,
|
||||
];
|
||||
class TruncatedError extends Error {}
|
||||
try {
|
||||
const response = await this.agent.generate(
|
||||
this.agent.config.provider,
|
||||
provider,
|
||||
this.agent.config.systemPrompt,
|
||||
[...this.agent.tools.loopTools],
|
||||
messages,
|
||||
|
|
@ -255,13 +276,13 @@ export class FullCompaction {
|
|||
{ signal },
|
||||
);
|
||||
if (response.finishReason === 'truncated') {
|
||||
throw new TruncatedError();
|
||||
throw new CompactionTruncatedError();
|
||||
}
|
||||
usage = response.usage;
|
||||
summary = extractCompactionSummary(response);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (error instanceof APIContextOverflowError || error instanceof TruncatedError) {
|
||||
if (error instanceof APIContextOverflowError || error instanceof CompactionTruncatedError) {
|
||||
compactedCount = this.strategy.reduceCompactOnOverflow(messagesToCompact);
|
||||
}
|
||||
else if (!isRetryableGenerateError(error)) {
|
||||
|
|
|
|||
|
|
@ -731,6 +731,42 @@ describe('FullCompaction', () => {
|
|||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
it('names truncated compaction responses when retries are exhausted', async () => {
|
||||
vi.useFakeTimers();
|
||||
let attempts = 0;
|
||||
const generate: GenerateFn = async () => {
|
||||
attempts += 1;
|
||||
return {
|
||||
...textResult('Partial summary.'),
|
||||
finishReason: 'truncated',
|
||||
rawFinishReason: 'length',
|
||||
};
|
||||
};
|
||||
const ctx = testAgent({ generate, compactionStrategy: alwaysCompactOnce });
|
||||
ctx.configure();
|
||||
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Trigger truncated auto compaction' }] });
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
const events = await ctx.untilTurnEnd();
|
||||
|
||||
expect(attempts).toBe(5);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
event: 'turn.ended',
|
||||
args: {
|
||||
turnId: 0,
|
||||
reason: 'failed',
|
||||
error: expect.objectContaining({
|
||||
code: 'compaction.failed',
|
||||
message:
|
||||
'CompactionTruncatedError: Compaction response was truncated before producing a complete summary.',
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
it('reports compaction retry_count when retryable generation failures are exhausted', async () => {
|
||||
vi.useFakeTimers();
|
||||
const records: TelemetryRecord[] = [];
|
||||
|
|
@ -1382,12 +1418,14 @@ describe('FullCompaction', () => {
|
|||
|
||||
it('compacts provider overflow when model context size is unknown', async () => {
|
||||
let callCount = 0;
|
||||
const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => {
|
||||
const compactionMaxCompletionTokens: unknown[] = [];
|
||||
const generate: GenerateFn = async (provider, _system, _tools, _history, callbacks) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
throw new APIContextOverflowError(400, 'Context length exceeded', 'req-unknown-context');
|
||||
}
|
||||
if (callCount === 2) {
|
||||
compactionMaxCompletionTokens.push(providerMaxCompletionTokens(provider));
|
||||
return textResult('Unknown window compacted summary.');
|
||||
}
|
||||
if (callCount === 3) {
|
||||
|
|
@ -1419,6 +1457,7 @@ describe('FullCompaction', () => {
|
|||
const events = await ctx.untilTurnEnd();
|
||||
|
||||
expect(callCount).toBe(3);
|
||||
expect(compactionMaxCompletionTokens).toEqual([32000]);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
event: 'compaction.started',
|
||||
|
|
@ -1442,6 +1481,74 @@ describe('FullCompaction', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('honors completion budget env hard caps during compaction', async () => {
|
||||
vi.stubEnv('KIMI_MODEL_MAX_COMPLETION_TOKENS', '8192');
|
||||
let callCount = 0;
|
||||
const compactionMaxCompletionTokens: unknown[] = [];
|
||||
const generate: GenerateFn = async (provider, _system, _tools, _history, callbacks) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
throw new APIContextOverflowError(400, 'Context length exceeded', 'req-hard-cap');
|
||||
}
|
||||
if (callCount === 2) {
|
||||
compactionMaxCompletionTokens.push(providerMaxCompletionTokens(provider));
|
||||
return textResult('Hard cap compacted summary.');
|
||||
}
|
||||
await callbacks?.onMessagePart?.({
|
||||
type: 'text',
|
||||
text: 'Recovered with hard cap.',
|
||||
});
|
||||
return textResult('Recovered with hard cap.');
|
||||
};
|
||||
const ctx = testAgent({ generate });
|
||||
ctx.configure({
|
||||
provider: CATALOGUED_PROVIDER,
|
||||
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
|
||||
});
|
||||
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
|
||||
ctx.newEvents();
|
||||
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Retry with hard cap' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
|
||||
expect(callCount).toBe(3);
|
||||
expect(compactionMaxCompletionTokens).toEqual([8192]);
|
||||
});
|
||||
|
||||
it('honors completion budget env opt-out during compaction', async () => {
|
||||
vi.stubEnv('KIMI_MODEL_MAX_COMPLETION_TOKENS', '0');
|
||||
let callCount = 0;
|
||||
const compactionMaxCompletionTokens: unknown[] = [];
|
||||
const generate: GenerateFn = async (provider, _system, _tools, _history, callbacks) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
throw new APIContextOverflowError(400, 'Context length exceeded', 'req-opt-out');
|
||||
}
|
||||
if (callCount === 2) {
|
||||
compactionMaxCompletionTokens.push(providerMaxCompletionTokens(provider));
|
||||
return textResult('Opt-out compacted summary.');
|
||||
}
|
||||
await callbacks?.onMessagePart?.({
|
||||
type: 'text',
|
||||
text: 'Recovered with opt-out.',
|
||||
});
|
||||
return textResult('Recovered with opt-out.');
|
||||
};
|
||||
const ctx = testAgent({ generate });
|
||||
ctx.configure({
|
||||
provider: CATALOGUED_PROVIDER,
|
||||
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
|
||||
});
|
||||
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
|
||||
ctx.newEvents();
|
||||
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Retry with opt-out' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
|
||||
expect(callCount).toBe(3);
|
||||
expect(compactionMaxCompletionTokens).toEqual([undefined]);
|
||||
});
|
||||
|
||||
it('ignores filtered assistant placeholders when checking the retained overflow suffix', async () => {
|
||||
let callCount = 0;
|
||||
const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => {
|
||||
|
|
@ -1625,6 +1732,14 @@ function oauthTestAgentOptions(
|
|||
};
|
||||
}
|
||||
|
||||
function providerMaxCompletionTokens(provider: Parameters<GenerateFn>[0]): unknown {
|
||||
return (
|
||||
provider as {
|
||||
readonly modelParameters?: Record<string, unknown>;
|
||||
}
|
||||
).modelParameters?.['max_completion_tokens'];
|
||||
}
|
||||
|
||||
function textResult(text: string): Awaited<ReturnType<GenerateFn>> {
|
||||
return {
|
||||
id: 'mock-compaction-oauth-retry',
|
||||
|
|
|
|||
|
|
@ -815,6 +815,7 @@ export class AnthropicChatProvider implements ChatProvider {
|
|||
private _defaultHeaders: Record<string, string> | undefined;
|
||||
private _clientFactory: ((auth: ProviderRequestAuth) => Anthropic) | undefined;
|
||||
private _adaptiveThinking: boolean | undefined;
|
||||
private _explicitMaxTokens: boolean;
|
||||
|
||||
constructor(options: AnthropicOptions) {
|
||||
this._model = options.model;
|
||||
|
|
@ -827,6 +828,7 @@ export class AnthropicChatProvider implements ChatProvider {
|
|||
this._defaultHeaders = options.defaultHeaders;
|
||||
this._clientFactory = options.clientFactory;
|
||||
this._client = this._apiKey === undefined ? undefined : this._buildClient(this._apiKey);
|
||||
this._explicitMaxTokens = options.defaultMaxTokens !== undefined;
|
||||
this._generationKwargs = {
|
||||
max_tokens: resolveDefaultMaxTokens(options.model, options.defaultMaxTokens),
|
||||
betaFeatures: options.betaFeatures ?? [INTERLEAVED_THINKING_BETA],
|
||||
|
|
@ -1082,9 +1084,25 @@ export class AnthropicChatProvider implements ChatProvider {
|
|||
return this._withGenerationKwargs(kwargs);
|
||||
}
|
||||
|
||||
withMaxCompletionTokens(maxCompletionTokens: number): AnthropicChatProvider {
|
||||
const requestedCap = resolveDefaultMaxTokens(this._model, maxCompletionTokens);
|
||||
const existingCap = this._generationKwargs.max_tokens;
|
||||
const clone = this._withGenerationKwargs({
|
||||
max_tokens:
|
||||
existingCap === undefined || this._explicitMaxTokens
|
||||
? existingCap ?? requestedCap
|
||||
: Math.min(existingCap, requestedCap),
|
||||
});
|
||||
clone._explicitMaxTokens = this._explicitMaxTokens;
|
||||
return clone;
|
||||
}
|
||||
|
||||
private _withGenerationKwargs(kwargs: Partial<AnthropicGenerationKwargs>): AnthropicChatProvider {
|
||||
const clone = this._clone();
|
||||
clone._generationKwargs = { ...clone._generationKwargs, ...kwargs };
|
||||
if ('max_tokens' in kwargs) {
|
||||
clone._explicitMaxTokens = kwargs.max_tokens !== undefined;
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -888,6 +888,10 @@ export class GoogleGenAIChatProvider implements ChatProvider {
|
|||
return clone;
|
||||
}
|
||||
|
||||
withMaxCompletionTokens(maxCompletionTokens: number): GoogleGenAIChatProvider {
|
||||
return this.withGenerationKwargs({ max_output_tokens: maxCompletionTokens });
|
||||
}
|
||||
|
||||
private _clone(): GoogleGenAIChatProvider {
|
||||
const clone = Object.assign(
|
||||
Object.create(Object.getPrototypeOf(this) as object) as GoogleGenAIChatProvider,
|
||||
|
|
|
|||
|
|
@ -476,6 +476,10 @@ export class OpenAILegacyChatProvider implements ChatProvider {
|
|||
return clone;
|
||||
}
|
||||
|
||||
withMaxCompletionTokens(maxCompletionTokens: number): OpenAILegacyChatProvider {
|
||||
return this.withGenerationKwargs({ max_tokens: maxCompletionTokens });
|
||||
}
|
||||
|
||||
private _clone(): OpenAILegacyChatProvider {
|
||||
const clone = Object.assign(
|
||||
Object.create(Object.getPrototypeOf(this) as object) as OpenAILegacyChatProvider,
|
||||
|
|
|
|||
|
|
@ -975,6 +975,10 @@ export class OpenAIResponsesChatProvider implements ChatProvider {
|
|||
return clone;
|
||||
}
|
||||
|
||||
withMaxCompletionTokens(maxCompletionTokens: number): OpenAIResponsesChatProvider {
|
||||
return this.withGenerationKwargs({ max_output_tokens: maxCompletionTokens });
|
||||
}
|
||||
|
||||
private _clone(): OpenAIResponsesChatProvider {
|
||||
const clone = Object.assign(
|
||||
Object.create(Object.getPrototypeOf(this) as object) as OpenAIResponsesChatProvider,
|
||||
|
|
|
|||
|
|
@ -2138,4 +2138,80 @@ describe('AnthropicChatProvider constructor max_tokens', () => {
|
|||
it('clamps defaultMaxTokens above the documented ceiling for known models', async () => {
|
||||
expect(await maxTokensFor('claude-opus-4-7', { defaultMaxTokens: 999999 })).toBe(128000);
|
||||
});
|
||||
|
||||
it('withMaxCompletionTokens sets max_tokens when no existing cap is present', async () => {
|
||||
const original = new AnthropicChatProvider({
|
||||
model: 'claude-opus-4-7',
|
||||
apiKey: 'test-key',
|
||||
stream: false,
|
||||
});
|
||||
const provider = original
|
||||
.withGenerationKwargs({ max_tokens: undefined })
|
||||
.withMaxCompletionTokens(2048);
|
||||
const history: Message[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
|
||||
];
|
||||
const body = await captureRequestBody(provider, '', [], history);
|
||||
|
||||
expect(provider).not.toBe(original);
|
||||
expect(body['max_tokens']).toBe(2048);
|
||||
});
|
||||
|
||||
it('withMaxCompletionTokens lowers the inferred model default cap', async () => {
|
||||
const provider = new AnthropicChatProvider({
|
||||
model: 'claude-opus-4-7',
|
||||
apiKey: 'test-key',
|
||||
stream: false,
|
||||
}).withMaxCompletionTokens(8192);
|
||||
const history: Message[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
|
||||
];
|
||||
const body = await captureRequestBody(provider, '', [], history);
|
||||
|
||||
expect(body['max_tokens']).toBe(8192);
|
||||
});
|
||||
|
||||
it('withMaxCompletionTokens preserves an existing lower max_tokens cap', async () => {
|
||||
const provider = new AnthropicChatProvider({
|
||||
model: 'claude-opus-4-7',
|
||||
apiKey: 'test-key',
|
||||
stream: false,
|
||||
defaultMaxTokens: 1024,
|
||||
}).withMaxCompletionTokens(128000);
|
||||
const history: Message[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
|
||||
];
|
||||
const body = await captureRequestBody(provider, '', [], history);
|
||||
|
||||
expect(body['max_tokens']).toBe(1024);
|
||||
});
|
||||
|
||||
it('withMaxCompletionTokens preserves an existing higher max_tokens cap', async () => {
|
||||
const provider = new AnthropicChatProvider({
|
||||
model: 'unknown-model',
|
||||
apiKey: 'test-key',
|
||||
stream: false,
|
||||
defaultMaxTokens: 128000,
|
||||
}).withMaxCompletionTokens(1024);
|
||||
const history: Message[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
|
||||
];
|
||||
const body = await captureRequestBody(provider, '', [], history);
|
||||
|
||||
expect(body['max_tokens']).toBe(128000);
|
||||
});
|
||||
|
||||
it('withMaxCompletionTokens clamps above the documented ceiling for known models', async () => {
|
||||
const provider = new AnthropicChatProvider({
|
||||
model: 'claude-opus-4-7',
|
||||
apiKey: 'test-key',
|
||||
stream: false,
|
||||
}).withMaxCompletionTokens(999999);
|
||||
const history: Message[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
|
||||
];
|
||||
const body = await captureRequestBody(provider, '', [], history);
|
||||
|
||||
expect(body['max_tokens']).toBe(128000);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -605,6 +605,19 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
expect(config['temperature']).toBe(0.7);
|
||||
expect(config['max_output_tokens']).toBe(2048);
|
||||
});
|
||||
|
||||
it('withMaxCompletionTokens sets max_output_tokens on the cloned provider', async () => {
|
||||
const original = createProvider();
|
||||
const provider = original.withMaxCompletionTokens(1024);
|
||||
const history: Message[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] },
|
||||
];
|
||||
const body = await captureRequestBody(provider, '', [], history);
|
||||
|
||||
const config = body['config'] as Record<string, unknown>;
|
||||
expect(provider).not.toBe(original);
|
||||
expect(config['max_output_tokens']).toBe(1024);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool name inference from tool_call_id (orphan tool messages)', () => {
|
||||
|
|
|
|||
|
|
@ -424,6 +424,18 @@ describe('OpenAILegacyChatProvider', () => {
|
|||
expect(body['temperature']).toBe(0.7);
|
||||
expect(body['max_tokens']).toBe(2048);
|
||||
});
|
||||
|
||||
it('withMaxCompletionTokens sets max_tokens on the cloned provider', async () => {
|
||||
const original = createProvider();
|
||||
const provider = original.withMaxCompletionTokens(1024);
|
||||
const history: Message[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] },
|
||||
];
|
||||
const body = await captureRequestBody(provider, '', [], history);
|
||||
|
||||
expect(provider).not.toBe(original);
|
||||
expect(body['max_tokens']).toBe(1024);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxTokens option', () => {
|
||||
|
|
|
|||
|
|
@ -742,6 +742,18 @@ describe('OpenAIResponsesChatProvider', () => {
|
|||
expect(body['temperature']).toBe(0.7);
|
||||
expect(body['max_output_tokens']).toBe(2048);
|
||||
});
|
||||
|
||||
it('withMaxCompletionTokens sets max_output_tokens on the cloned provider', async () => {
|
||||
const original = createProvider();
|
||||
const provider = original.withMaxCompletionTokens(1024);
|
||||
const history: Message[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] },
|
||||
];
|
||||
const body = await captureRequestBody(provider, '', [], history);
|
||||
|
||||
expect(provider).not.toBe(original);
|
||||
expect(body['max_output_tokens']).toBe(1024);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reasoning configuration', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue