fix: handle compaction truncation and output budgets (#267)

This commit is contained in:
_Kerman 2026-06-01 17:25:11 +08:00 committed by GitHub
parent 1084f1d217
commit e2e17289fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 292 additions and 6 deletions

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

View file

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

View file

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

View file

@ -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;
}

View file

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

View file

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

View file

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

View file

@ -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);
});
});

View file

@ -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)', () => {

View file

@ -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', () => {

View file

@ -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', () => {