mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
fix(agent-core-v2): align full compaction with v1
This commit is contained in:
parent
3ea9f8474f
commit
a8a134f64a
2 changed files with 200 additions and 139 deletions
|
|
@ -2,7 +2,7 @@ import { Disposable } from "#/_base/di/lifecycle";
|
|||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { renderPrompt } from "#/_base/utils/render-prompt";
|
||||
import { estimateTokensForMessages } from "#/_base/utils/tokens";
|
||||
import { estimateTokensForMessage, estimateTokensForMessages } from "#/_base/utils/tokens";
|
||||
import { buildCompactionSummaryText, isRealUserInput } from '#/agent/contextMemory/compactionHandoff';
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
|
|
@ -68,6 +68,8 @@ declare module '#/agent/wireRecord/wireRecord' {
|
|||
export const MAX_COMPACTION_RETRY_ATTEMPTS = 5;
|
||||
const DEFAULT_COMPACTION_MAX_COMPLETION_TOKENS = 128 * 1024;
|
||||
const OVERFLOW_STATUS_RECOVERY_RATIO = 0.5;
|
||||
const MAX_COMPACTION_OVERFLOW_SHRINK_ATTEMPTS = 3;
|
||||
const COMPACTION_OVERFLOW_SHRINK_RATIOS = [0.7, 0.5, 0.35] as const;
|
||||
|
||||
type CompactionTelemetryProperties = Record<string, string | number | boolean | undefined>;
|
||||
|
||||
|
|
@ -159,6 +161,9 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
if (this.compactionCountInTurn > this.strategy.maxCompactionPerTurn) return false;
|
||||
|
||||
const history = this.context.get();
|
||||
if (history.length === 0) {
|
||||
throw new KimiError(ErrorCodes.COMPACTION_UNABLE, 'No messages to compact in current history.');
|
||||
}
|
||||
if (data.source === 'manual' && this.turn.getActiveTurn() !== undefined) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.COMPACTION_UNABLE,
|
||||
|
|
@ -166,10 +171,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
);
|
||||
}
|
||||
const tokenCount = estimateTokensForMessages(history);
|
||||
const compactedCount = this.strategy.computeCompactCount(history, data.source);
|
||||
if (compactedCount === 0) {
|
||||
throw new KimiError(ErrorCodes.COMPACTION_UNABLE, 'No prefix that can be compacted in current history.');
|
||||
}
|
||||
|
||||
this.wire.dispatch(fullCompactionBegin(data));
|
||||
|
||||
|
|
@ -194,7 +195,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
void this.compactionWorker(
|
||||
active,
|
||||
data,
|
||||
data.source === 'manual' ? history.length : compactedCount,
|
||||
)
|
||||
.then(resolveCompaction, rejectCompaction);
|
||||
void active.promise.catch(() => undefined);
|
||||
|
|
@ -330,47 +330,18 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
private async compactionWorker(
|
||||
active: ActiveCompaction,
|
||||
data: Readonly<CompactionBeginData>,
|
||||
initialCompactedCount: number,
|
||||
): Promise<CompactionResult> {
|
||||
try {
|
||||
const finalResult: CompactionResult = {
|
||||
summary: '',
|
||||
contextSummary: '',
|
||||
compactedCount: 1,
|
||||
tokensBefore: 0,
|
||||
tokensAfter: 0,
|
||||
keptUserMessageCount: 0,
|
||||
};
|
||||
let compactedCount = initialCompactedCount;
|
||||
|
||||
for (let round = 1; ; round++) {
|
||||
const result = await this.compactionRound(active, round, data, compactedCount);
|
||||
if (this._compacting !== active) throw compactionCancelledReason(active);
|
||||
|
||||
finalResult.summary = result.summary;
|
||||
finalResult.contextSummary = result.contextSummary;
|
||||
finalResult.compactedCount += result.compactedCount - 1;
|
||||
finalResult.tokensBefore += result.tokensBefore - finalResult.tokensAfter;
|
||||
finalResult.tokensAfter = result.tokensAfter;
|
||||
finalResult.keptUserMessageCount = result.keptUserMessageCount;
|
||||
finalResult.keptHeadUserMessageCount = result.keptHeadUserMessageCount;
|
||||
finalResult.droppedCount = result.droppedCount;
|
||||
|
||||
if (result.tokensBefore - result.tokensAfter < 1024) break;
|
||||
if (!this.strategy.shouldBlock(result.tokensAfter)) break;
|
||||
compactedCount = this.strategy.computeCompactCount(this.context.get(), data.source);
|
||||
if (compactedCount === 0) break;
|
||||
}
|
||||
|
||||
const result = await this.compactionRound(active, data);
|
||||
if (this._compacting !== active) throw compactionCancelledReason(active);
|
||||
this.lastCompactedTokenCount = finalResult.tokensAfter;
|
||||
if (!this.markCompleted(active, completeData(finalResult))) {
|
||||
this.lastCompactedTokenCount = result.tokensAfter;
|
||||
if (!this.markCompleted(active, completeData(result))) {
|
||||
throw compactionCancelledReason(active);
|
||||
}
|
||||
const { contextSummary: _contextSummary, ...eventResult } = finalResult;
|
||||
const { contextSummary: _contextSummary, ...eventResult } = result;
|
||||
void _contextSummary;
|
||||
this.eventBus.publish({ type: 'compaction.completed', result: eventResult, trigger: data.source });
|
||||
return finalResult;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (active.abortController.signal.aborted || isAbortError(error)) {
|
||||
this.cancelActive(active);
|
||||
|
|
@ -393,9 +364,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
|
||||
private async compactionRound(
|
||||
active: ActiveCompaction,
|
||||
round: number,
|
||||
data: Readonly<CompactionBeginData>,
|
||||
initialCompactedCount: number,
|
||||
): Promise<CompactionResult> {
|
||||
const startedAt = Date.now();
|
||||
const originalHistory = [...this.context.get()];
|
||||
|
|
@ -403,15 +372,10 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
let retryCount = 0;
|
||||
|
||||
try {
|
||||
const compactedCount = Math.min(initialCompactedCount, originalHistory.length);
|
||||
const signal = active.abortController.signal;
|
||||
signal.throwIfAborted();
|
||||
|
||||
// One logical compaction fires the hook once, even when it takes
|
||||
// multiple window-sized rounds to bring the context under the ratio.
|
||||
if (round === 1) {
|
||||
await this.hooks.onWillCompact.run(active);
|
||||
}
|
||||
await this.hooks.onWillCompact.run(active);
|
||||
|
||||
const resolvedModel = this.profile.resolveModelContext();
|
||||
const maxContextTokens = resolvedModel.modelCapabilities.max_context_tokens;
|
||||
|
|
@ -427,8 +391,9 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
|
||||
const delays = retryBackoffDelays(MAX_COMPACTION_RETRY_ATTEMPTS);
|
||||
let attempt: CompactionAttemptResult | undefined;
|
||||
let historyForModel = originalHistory.slice(0, compactedCount);
|
||||
let historyForModel: readonly ContextMessage[] = originalHistory;
|
||||
let droppedCount = 0;
|
||||
let overflowShrinkCount = 0;
|
||||
let emptyOrTruncatedShrinkCount = 0;
|
||||
while (true) {
|
||||
const messagesToCompact = historyForModel;
|
||||
|
|
@ -454,14 +419,19 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
break;
|
||||
} catch (error) {
|
||||
if (this.shouldRecoverFromCompactionOverflow(error, messages)) {
|
||||
const reduced = this.strategy.reduceCompactOnOverflow(messagesToCompact);
|
||||
// An overflow that cannot shrink further would replay the same
|
||||
// request; give up (v1: throws when the history cannot shrink).
|
||||
if (reduced >= messagesToCompact.length) {
|
||||
overflowShrinkCount += 1;
|
||||
if (
|
||||
overflowShrinkCount > MAX_COMPACTION_OVERFLOW_SHRINK_ATTEMPTS ||
|
||||
messagesToCompact.length <= 1
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
droppedCount += messagesToCompact.length - reduced;
|
||||
historyForModel = messagesToCompact.slice(0, reduced);
|
||||
const before = messagesToCompact.length;
|
||||
historyForModel = shrinkCompactionHistoryAfterOverflow(
|
||||
messagesToCompact,
|
||||
overflowShrinkCount,
|
||||
);
|
||||
droppedCount += before - historyForModel.length;
|
||||
retryCount = 0;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -504,20 +474,13 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
throw compactionCancelledReason(active);
|
||||
}
|
||||
|
||||
const shouldKeepPartialTail =
|
||||
data.source === 'auto' &&
|
||||
maxContextTokens > 0 &&
|
||||
compactedCount < originalHistory.length &&
|
||||
tokensBefore > maxContextTokens;
|
||||
const appliedCompactedCount = shouldKeepPartialTail ? historyForModel.length : compactedCount;
|
||||
const summary = this.postProcessSummary(attempt.summary);
|
||||
const result = this.context.applyCompaction({
|
||||
summary,
|
||||
contextSummary: buildCompactionSummaryText(summary),
|
||||
compactedCount: appliedCompactedCount,
|
||||
compactedCount: originalHistory.length,
|
||||
tokensBefore,
|
||||
droppedCount: droppedCount === 0 ? undefined : droppedCount,
|
||||
legacyTail: shouldKeepPartialTail ? true : undefined,
|
||||
});
|
||||
|
||||
this.telemetry.track('compaction_finished', {
|
||||
|
|
@ -529,7 +492,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
compacted_count: result.compactedCount,
|
||||
dropped_count: result.droppedCount,
|
||||
retry_count: retryCount,
|
||||
round,
|
||||
round: 1,
|
||||
thinking_effort: this.profile.data().thinkingLevel,
|
||||
...usageTelemetry(attempt.usage),
|
||||
});
|
||||
|
|
@ -540,7 +503,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
source: data.source,
|
||||
tokens_before: tokensBefore,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
round,
|
||||
round: 1,
|
||||
retry_count: retryCount,
|
||||
thinking_effort: this.profile.data().thinkingLevel,
|
||||
error_type: error instanceof Error ? error.name : 'Unknown',
|
||||
|
|
@ -638,6 +601,34 @@ function historySafeToCompact(
|
|||
return current.slice(original.length).every(isRealUserInput);
|
||||
}
|
||||
|
||||
function shrinkCompactionHistoryAfterOverflow<T extends Message>(
|
||||
messages: readonly T[],
|
||||
attempt: number,
|
||||
): T[] {
|
||||
if (messages.length <= 1) return messages.slice();
|
||||
const ratio = COMPACTION_OVERFLOW_SHRINK_RATIOS[
|
||||
Math.min(attempt - 1, COMPACTION_OVERFLOW_SHRINK_RATIOS.length - 1)
|
||||
]!;
|
||||
const tokenBudget = Math.floor(estimateTokensForMessages(messages) * ratio);
|
||||
return takeRecentMessagesWithinTokenBudget(messages, tokenBudget);
|
||||
}
|
||||
|
||||
function takeRecentMessagesWithinTokenBudget<T extends Message>(
|
||||
messages: readonly T[],
|
||||
tokenBudget: number,
|
||||
): T[] {
|
||||
let start = messages.length;
|
||||
let tokens = 0;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const messageTokens = estimateTokensForMessage(messages[i]!);
|
||||
if (tokens + messageTokens > tokenBudget) break;
|
||||
tokens += messageTokens;
|
||||
start = i;
|
||||
}
|
||||
if (start === 0) start = 1;
|
||||
return dropLeadingToolResults(messages.slice(start));
|
||||
}
|
||||
|
||||
function dropOldestMessageAndLeadingToolResults<T extends { readonly role: string }>(
|
||||
messages: readonly T[],
|
||||
): T[] {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { COMPACTION_SUMMARY_PREFIX } from '#/agent/contextMemory/compactionHando
|
|||
import { estimateTokensForMessages } from '#/_base/utils/tokens';
|
||||
import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs';
|
||||
import type { TestAgentContext, TestAgentOptions, TestAgentServiceOverride } from '../harness';
|
||||
import { appServices, createCommandRunner, execEnvServices, testAgent } from '../harness';
|
||||
import { appServices, createCommandRunner, execEnvServices, sessionServices, testAgent } from '../harness';
|
||||
import {
|
||||
IAgentFullCompactionService,
|
||||
IAgentMicroCompactionService,
|
||||
|
|
@ -1227,7 +1227,7 @@ describe('FullCompaction', () => {
|
|||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
it('auto-compacts very large context in window-sized rounds', async () => {
|
||||
it('auto-compacts very large context in one full-history round when the summarizer accepts it', async () => {
|
||||
const maxContextTokens = 4_000;
|
||||
const ctx = testAgent();
|
||||
ctx.configure({
|
||||
|
|
@ -1246,9 +1246,7 @@ describe('FullCompaction', () => {
|
|||
}
|
||||
const initialTokens = estimateTokensForMessages(ctx.context.get());
|
||||
const completed = ctx.once('compaction.completed');
|
||||
for (let i = 1; i <= 30; i++) {
|
||||
ctx.mockNextResponse({ type: 'text', text: `Auto summary ${String(i)}.` });
|
||||
}
|
||||
ctx.mockNextResponse({ type: 'text', text: 'Auto summary.' });
|
||||
|
||||
ctx.get(IAgentFullCompactionService).begin({ source: 'auto', instruction: undefined });
|
||||
await completed;
|
||||
|
|
@ -1260,8 +1258,8 @@ describe('FullCompaction', () => {
|
|||
expect(initialTokens).toBeGreaterThan(maxContextTokens * 9);
|
||||
expect(countEvents(events, 'full_compaction.complete')).toBe(1);
|
||||
expect(countEvents(events, 'compaction.completed')).toBe(1);
|
||||
expect(compactedPrefixSizes.length).toBeGreaterThan(1);
|
||||
expect(compactedPrefixSizes.every((size) => size <= maxContextTokens)).toBe(true);
|
||||
expect(compactedPrefixSizes).toHaveLength(1);
|
||||
expect(compactedPrefixSizes[0]).toBe(initialTokens);
|
||||
expect(ctx.contextData().tokenCount).toBeLessThan(maxContextTokens * 0.85);
|
||||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
|
@ -1285,22 +1283,23 @@ describe('FullCompaction', () => {
|
|||
const events = ctx.newEvents();
|
||||
expect(events).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: '[wire]', event: 'context.splice' }),
|
||||
expect.objectContaining({ type: '[wire]', event: 'full_compaction.begin' }),
|
||||
// Clearing context is a full-history `context.splice` (the v1.5
|
||||
// equivalent of the legacy `context.clear` record).
|
||||
expect.objectContaining({
|
||||
type: '[wire]',
|
||||
event: 'context.splice',
|
||||
args: expect.objectContaining({ start: 0, deleteCount: 4, messages: [] }),
|
||||
}),
|
||||
expect.objectContaining({ type: '[wire]', event: 'context.clear' }),
|
||||
expect.objectContaining({ type: '[wire]', event: 'full_compaction.cancel' }),
|
||||
expect.objectContaining({ type: '[rpc]', event: 'compaction.cancelled' }),
|
||||
]),
|
||||
);
|
||||
expect(eventIndex(events, 'full_compaction.begin')).toBeLessThan(
|
||||
eventIndex(events, 'context.clear'),
|
||||
);
|
||||
expect(eventIndex(events, 'context.clear')).toBeLessThan(
|
||||
eventIndex(events, 'full_compaction.cancel'),
|
||||
);
|
||||
expect(countEvents(events, 'context.apply_compaction')).toBe(0);
|
||||
expect(countEvents(events, 'full_compaction.complete')).toBe(0);
|
||||
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
|
||||
system: <system-prompt>
|
||||
tools: Agent, AgentSwarm, CronCreate, CronDelete, CronList, EnterPlanMode, ExitPlanMode
|
||||
tools: Agent, AgentSwarm, EnterPlanMode, ExitPlanMode
|
||||
messages:
|
||||
user: text "old user one"
|
||||
assistant: text "old assistant one"
|
||||
|
|
@ -1360,37 +1359,53 @@ describe('FullCompaction', () => {
|
|||
expect(events).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: '[wire]', event: 'context.splice' }),
|
||||
expect.objectContaining({ type: '[wire]', event: 'turn.launch' }),
|
||||
expect.objectContaining({ type: '[wire]', event: 'turn.prompt' }),
|
||||
expect.objectContaining({ type: '[rpc]', event: 'turn.started' }),
|
||||
expect.objectContaining({ type: '[wire]', event: 'full_compaction.begin' }),
|
||||
expect.objectContaining({ type: '[rpc]', event: 'compaction.blocked' }),
|
||||
expect.objectContaining({ type: '[wire]', event: 'full_compaction.complete' }),
|
||||
expect.objectContaining({ type: '[rpc]', event: 'turn.step.started' }),
|
||||
expect.objectContaining({ type: '[rpc]', event: 'turn.ended' }),
|
||||
]),
|
||||
);
|
||||
expect(eventIndex(events, 'turn.prompt')).toBeLessThan(
|
||||
eventIndex(events, 'full_compaction.begin'),
|
||||
);
|
||||
expect(eventIndex(events, 'full_compaction.begin')).toBeLessThan(
|
||||
eventIndex(events, 'full_compaction.complete'),
|
||||
);
|
||||
expect(eventIndex(events, 'compaction.blocked')).toBeLessThan(
|
||||
eventIndex(events, 'full_compaction.complete'),
|
||||
);
|
||||
expect(eventIndex(events, 'full_compaction.complete')).toBeLessThan(
|
||||
eventIndex(events, 'turn.step.started'),
|
||||
);
|
||||
expect(ctx.llmInputs()).toMatchInlineSnapshot(`
|
||||
call 1:
|
||||
system: <system-prompt>
|
||||
tools: Agent, AgentSwarm, CronCreate, CronDelete, CronList, EnterPlanMode, ExitPlanMode
|
||||
tools: Agent, AgentSwarm, EnterPlanMode, ExitPlanMode
|
||||
messages:
|
||||
user: text "old user one"
|
||||
assistant: text "old assistant one"
|
||||
user: text "old user two"
|
||||
assistant: text "old assistant two"
|
||||
user: text "recent user three"
|
||||
assistant: text "recent assistant three"
|
||||
user: text "Answer after compacting"
|
||||
user: text <compaction-instruction>
|
||||
|
||||
call 2:
|
||||
messages:
|
||||
assistant: text "Auto compacted summary."
|
||||
user: text "recent user three"
|
||||
assistant: text "recent assistant three"
|
||||
user: text "Answer after compacting"
|
||||
user: text "old user one\\n\\nold user two\\n\\nrecent user three\\n\\nAnswer after compacting"
|
||||
user: text "The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary.\\nAuto compacted summary."
|
||||
`);
|
||||
expect(records).toContainEqual({
|
||||
event: 'compaction_finished',
|
||||
properties: expect.objectContaining({
|
||||
source: 'auto',
|
||||
tokens_before: 46,
|
||||
tokens_after: 28,
|
||||
compacted_count: 4,
|
||||
tokens_after: 166,
|
||||
compacted_count: 7,
|
||||
retry_count: 0,
|
||||
}),
|
||||
});
|
||||
|
|
@ -1540,46 +1555,43 @@ describe('FullCompaction', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('fails the turn with compaction.unable when auto compaction has no compactable prefix', async () => {
|
||||
const ctx = testAgent();
|
||||
ctx.configure({
|
||||
provider: CATALOGUED_PROVIDER,
|
||||
modelCapabilities: {
|
||||
...CATALOGUED_MODEL_CAPABILITIES,
|
||||
max_context_tokens: 2_000,
|
||||
},
|
||||
});
|
||||
const oversizedPrompt = `initial-pending-verbatim:${'x'.repeat(8_000)}`;
|
||||
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: oversizedPrompt }] });
|
||||
const events = await ctx.untilTurnEnd();
|
||||
|
||||
expect(eventIndex(events, 'compaction.started')).toBe(-1);
|
||||
expect(ctx.llmCalls).toHaveLength(0);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
event: 'turn.ended',
|
||||
args: expect.objectContaining({
|
||||
reason: 'failed',
|
||||
error: expect.objectContaining({ code: 'compaction.unable' }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
it('rejects manual compaction with compaction.unable when no prefix is compactable', async () => {
|
||||
it('compacts a single user message and keeps it ahead of the summary', async () => {
|
||||
const ctx = testAgent();
|
||||
ctx.configure({
|
||||
provider: CATALOGUED_PROVIDER,
|
||||
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
|
||||
});
|
||||
ctx.appendUserMessage([{ type: 'text', text: 'only pending user' }]);
|
||||
const compacted = ctx.once('full_compaction.complete');
|
||||
const completed = ctx.once('compaction.completed');
|
||||
|
||||
await expect(ctx.rpc.beginCompaction({})).rejects.toMatchObject({
|
||||
code: 'compaction.unable',
|
||||
ctx.mockNextResponse({ type: 'text', text: 'Single message summary.' });
|
||||
await ctx.rpc.beginCompaction({});
|
||||
await compacted;
|
||||
await completed;
|
||||
|
||||
expect(ctx.llmCalls).toHaveLength(1);
|
||||
expect(ctx.compactHistory()).toEqual([
|
||||
{ role: 'user', text: 'only pending user' },
|
||||
{
|
||||
role: 'user',
|
||||
text: `${COMPACTION_SUMMARY_PREFIX}\nSingle message summary.`,
|
||||
},
|
||||
]);
|
||||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
it('manual compaction can run after a previous single-message compaction', async () => {
|
||||
const ctx = testAgent();
|
||||
ctx.configure({
|
||||
provider: CATALOGUED_PROVIDER,
|
||||
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
|
||||
});
|
||||
expect(ctx.llmCalls).toHaveLength(0);
|
||||
|
||||
ctx.appendUserMessage([{ type: 'text', text: 'only pending user' }]);
|
||||
ctx.mockNextResponse({ type: 'text', text: 'Single message summary.' });
|
||||
await ctx.rpc.beginCompaction({});
|
||||
await ctx.once('compaction.completed');
|
||||
|
||||
ctx.clearContext();
|
||||
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
|
||||
|
|
@ -1587,23 +1599,37 @@ describe('FullCompaction', () => {
|
|||
const compacted = ctx.once('full_compaction.complete');
|
||||
const completed = ctx.once('compaction.completed');
|
||||
|
||||
ctx.mockNextResponse({ type: 'text', text: 'Compacted after no-op cancel.' });
|
||||
ctx.mockNextResponse({ type: 'text', text: 'Compacted after single-message compact.' });
|
||||
await ctx.rpc.beginCompaction({});
|
||||
await compacted;
|
||||
await completed;
|
||||
|
||||
expect(ctx.llmCalls).toHaveLength(1);
|
||||
expect(ctx.llmCalls).toHaveLength(2);
|
||||
expect(ctx.compactHistory()).toEqual([
|
||||
{ role: 'user', text: 'old user one' },
|
||||
{ role: 'user', text: 'recent user two' },
|
||||
{
|
||||
role: 'user',
|
||||
text: expect.stringContaining('Compacted after no-op cancel.'),
|
||||
text: expect.stringContaining('Compacted after single-message compact.'),
|
||||
},
|
||||
]);
|
||||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
it('rejects manual compaction with compaction.unable when history is empty', async () => {
|
||||
const ctx = testAgent();
|
||||
ctx.configure({
|
||||
provider: CATALOGUED_PROVIDER,
|
||||
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
|
||||
});
|
||||
|
||||
await expect(ctx.rpc.beginCompaction({})).rejects.toMatchObject({
|
||||
code: 'compaction.unable',
|
||||
});
|
||||
expect(ctx.llmCalls).toHaveLength(0);
|
||||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
it('does not auto compact small contexts when reserved size exceeds the model window', async () => {
|
||||
const ctx = testAgent({
|
||||
initialConfig: {
|
||||
|
|
@ -1661,7 +1687,7 @@ describe('FullCompaction', () => {
|
|||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
it('keeps an oversized pending user prompt out of auto compaction', async () => {
|
||||
it('includes an oversized pending user prompt in auto compaction', async () => {
|
||||
const ctx = testAgent();
|
||||
ctx.configure({
|
||||
provider: CATALOGUED_PROVIDER,
|
||||
|
|
@ -1681,8 +1707,15 @@ describe('FullCompaction', () => {
|
|||
expect(ctx.llmCalls).toHaveLength(2);
|
||||
const [compactionCall, answerCall] = ctx.llmCalls;
|
||||
const compactionTexts = compactionCall?.history.map(messageText) ?? [];
|
||||
expect(compactionTexts.some((text) => text.includes('keep-this-pending-verbatim'))).toBe(false);
|
||||
expect(compactionCall?.history.map((message) => message.role)).toEqual(['user', 'assistant', 'user']);
|
||||
// The whole history is compacted, so the pending prompt is included in the
|
||||
// compaction input and kept verbatim in the post-compaction replacement.
|
||||
expect(compactionTexts.some((text) => text.includes('keep-this-pending-verbatim'))).toBe(true);
|
||||
expect(compactionCall?.history.map((message) => message.role)).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
'user',
|
||||
'user',
|
||||
]);
|
||||
expect(
|
||||
answerCall?.history.map(messageText).some((text) => text.includes('Oversized prompt summary.')),
|
||||
).toBe(true);
|
||||
|
|
@ -1712,8 +1745,15 @@ describe('FullCompaction', () => {
|
|||
expect(ctx.llmCalls).toHaveLength(2);
|
||||
const [compactionCall, answerCall] = ctx.llmCalls;
|
||||
const compactionTexts = compactionCall?.history.map(messageText) ?? [];
|
||||
expect(compactionTexts.some((text) => text.includes('ratio-pending-verbatim'))).toBe(false);
|
||||
expect(compactionCall?.history.map((message) => message.role)).toEqual(['user', 'assistant', 'user']);
|
||||
// The whole history is compacted, so the pending prompt is included in the
|
||||
// compaction input and kept verbatim in the post-compaction replacement.
|
||||
expect(compactionTexts.some((text) => text.includes('ratio-pending-verbatim'))).toBe(true);
|
||||
expect(compactionCall?.history.map((message) => message.role)).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
'user',
|
||||
'user',
|
||||
]);
|
||||
expect(
|
||||
answerCall?.history.map(messageText).some((text) => text.includes('Ratio compacted summary.')),
|
||||
).toBe(true);
|
||||
|
|
@ -1769,7 +1809,7 @@ describe('FullCompaction', () => {
|
|||
args: expect.objectContaining({
|
||||
result: expect.objectContaining({
|
||||
summary: 'Overflow compacted summary.',
|
||||
compactedCount: 2,
|
||||
compactedCount: 4,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
|
@ -1790,6 +1830,7 @@ describe('FullCompaction', () => {
|
|||
[
|
||||
"user: old user one",
|
||||
"assistant: old assistant one",
|
||||
"user: Retry after provider overflow",
|
||||
"user: <compaction-instruction>",
|
||||
],
|
||||
[
|
||||
|
|
@ -2044,7 +2085,7 @@ describe('FullCompaction', () => {
|
|||
args: expect.objectContaining({
|
||||
result: expect.objectContaining({
|
||||
summary: 'Unknown window compacted summary.',
|
||||
compactedCount: 2,
|
||||
compactedCount: 4,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
|
@ -2204,8 +2245,10 @@ describe('FullCompaction', () => {
|
|||
|
||||
it('ignores filtered assistant placeholders when checking the retained overflow suffix', async () => {
|
||||
let callCount = 0;
|
||||
const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => {
|
||||
const inputs: string[][] = [];
|
||||
const generate: GenerateFn = async (_provider, _system, _tools, history, callbacks) => {
|
||||
callCount += 1;
|
||||
inputs.push(inputHistorySnapshot(history));
|
||||
if (callCount === 1) {
|
||||
throw new APIContextOverflowError(
|
||||
400,
|
||||
|
|
@ -2255,7 +2298,8 @@ describe('FullCompaction', () => {
|
|||
args: expect.objectContaining({
|
||||
result: expect.objectContaining({
|
||||
summary: 'Placeholder compacted summary.',
|
||||
compactedCount: 2,
|
||||
compactedCount: 3,
|
||||
droppedCount: 2,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
|
@ -2266,11 +2310,42 @@ describe('FullCompaction', () => {
|
|||
args: { turnId: 0, reason: 'completed' },
|
||||
}),
|
||||
);
|
||||
expect(inputs).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"user: old user one",
|
||||
"assistant: old assistant one",
|
||||
"user: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"user: <compaction-instruction>",
|
||||
],
|
||||
[
|
||||
"user: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"user: <compaction-instruction>",
|
||||
],
|
||||
[
|
||||
"user: old user one
|
||||
|
||||
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"user: The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary.
|
||||
Placeholder compacted summary.",
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
|
||||
it('appends the todo list to the compaction summary', async () => {
|
||||
const ctx = testAgent();
|
||||
const todos = [
|
||||
{ title: 'Fix the auth bug', status: 'in_progress' },
|
||||
{ title: 'Add tests', status: 'pending' },
|
||||
] as const;
|
||||
const ctx = testAgent(
|
||||
sessionServices((reg) => {
|
||||
reg.definePartialInstance(ISessionTodoService, {
|
||||
getTodos: () => todos,
|
||||
});
|
||||
}),
|
||||
);
|
||||
ctx.configure({
|
||||
provider: CATALOGUED_PROVIDER,
|
||||
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
|
||||
|
|
@ -2278,11 +2353,6 @@ describe('FullCompaction', () => {
|
|||
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
|
||||
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
|
||||
|
||||
ctx.get(ISessionTodoService).setTodos([
|
||||
{ title: 'Fix the auth bug', status: 'in_progress' },
|
||||
{ title: 'Add tests', status: 'pending' },
|
||||
]);
|
||||
|
||||
const compacted = new Promise<void>((resolve) => {
|
||||
ctx.emitter.once('full_compaction.complete', () => {
|
||||
resolve();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue