From 7bd11aa5d3d29ab06551fa19fb96958691c9b695 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Tue, 11 Aug 2026 14:46:50 -0700 Subject: [PATCH] fix(agentlayer): allow short manual compaction HumanLayer-Session: https://cloud.dev.codelayer.gg/sessions/019feeba-ab18-7035-b0f3-a47b76384add --- packages/agentlayer-core/src/agent.ts | 19 +++++--- .../agentlayer-core/src/compaction/engine.ts | 15 +++++++ .../agentlayer-core/test/compaction.test.ts | 44 +++++++++++++++++++ 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/packages/agentlayer-core/src/agent.ts b/packages/agentlayer-core/src/agent.ts index 48d4500..d436681 100644 --- a/packages/agentlayer-core/src/agent.ts +++ b/packages/agentlayer-core/src/agent.ts @@ -8,6 +8,7 @@ import { COMPACTION_SYSTEM_PROMPT, type CompactionTrigger, compactionSummaryMessage, + fitsCompactionTail, isContextOverflowError, parseCompactCommand, planCompaction, @@ -709,11 +710,19 @@ export class Agent> = Record index !== manualCommand.messageIndex, ) - await applyCompaction( - 'manual', - { ...buildState(), messages: messagesWithoutCommand }, - manualCommand.additionalInstructions, - ) + const stateWithoutCommand = { ...buildState(), messages: messagesWithoutCommand } + if ( + fitsCompactionTail(messagesWithoutCommand, { + keepRecentTokens: compactionPolicy.keepRecentTokens, + requiredToolCallIds: new Set( + (stateWithoutCommand.pendingToolCalls ?? []).map((pending) => pending.toolCallId), + ), + }) + ) { + allMessages.splice(0, allMessages.length, ...messagesWithoutCommand) + } else { + await applyCompaction('manual', stateWithoutCommand, manualCommand.additionalInstructions) + } } else if ( shouldCompactForThreshold({ contextWindowTokens: contextWindowTokens > 0 ? contextWindowTokens : undefined, diff --git a/packages/agentlayer-core/src/compaction/engine.ts b/packages/agentlayer-core/src/compaction/engine.ts index 337374f..96f6502 100644 --- a/packages/agentlayer-core/src/compaction/engine.ts +++ b/packages/agentlayer-core/src/compaction/engine.ts @@ -270,6 +270,21 @@ export function planCompaction( } } +/** + * Whether a complete, valid conversation already fits in its configured native tail. + * + * This is distinct from a missing compaction plan caused by malformed tool traffic or + * insufficient conversation structure. Manual compaction commands can safely become a + * no-op only in this case. + */ +export function fitsCompactionTail(messages: ReadonlyArray, options: FindCompactionCutOptions): boolean { + if (messages.length < 2 || findCompactionCut(messages, options) !== 0) return false + if (!hasValidToolCallResultPairs(messages)) return false + if (!containsRequiredToolCalls(messages, options.requiredToolCallIds ?? new Set())) return false + const totalTokens = messages.reduce((total, message) => total + estimateMessageTokens(message), 0) + return totalTokens <= Math.max(1, options.keepRecentTokens) +} + export const CONTEXT_OVERFLOW_PATTERNS: ReadonlyArray = [ /context[_ ]length[_ ]exceeded/i, /model_context_window_exceeded/i, diff --git a/packages/agentlayer-core/test/compaction.test.ts b/packages/agentlayer-core/test/compaction.test.ts index fe2042b..7aa475b 100644 --- a/packages/agentlayer-core/test/compaction.test.ts +++ b/packages/agentlayer-core/test/compaction.test.ts @@ -532,6 +532,50 @@ describe('automatic loop compaction', () => { } }) + test('consumes bare and instructed compact commands as no-ops when the full conversation fits the retained tail', async () => { + for (const command of ['/compact', '/compact Preserve verification commands.']) { + const calls: LanguageModelV3CallOptions[] = [] + const events: AgentEvent[] = [] + const priorMessages = [ + userMessage('Short completed request.'), + { role: 'assistant' as const, content: 'Short completed answer.' }, + ] + const run = new Agent({ + model: scriptedModel([{ text: 'normal answer' }], calls), + tools: {}, + }).run({ + state: startState([...priorMessages, userMessage(command)]), + stream: true, + }) + for await (const event of run) events.push(event) + const result = await run.result + + expect(result.finishReason).toBe('complete') + expect(calls).toHaveLength(1) + expect(callContains(calls[0]!, '/compact')).toBe(false) + expect(callContains(calls[0]!, 'Short completed request.')).toBe(true) + expect(events.filter((event) => event.type === 'compaction')).toHaveLength(0) + expect(result.state.compaction).toBeUndefined() + expect(result.state.messages.slice(0, -1)).toEqual(priorMessages) + expect(result.state.messages.at(-1)).toMatchObject({ + role: 'assistant', + content: [{ type: 'text', text: 'normal answer' }], + }) + } + }) + + test('keeps the coherent-prefix error for a compact command without prior conversation', async () => { + const calls: LanguageModelV3CallOptions[] = [] + const result = await new Agent({ + model: scriptedModel([], calls), + tools: {}, + }).run({ state: startState([userMessage('/compact')]) }).result + + expect(result.finishReason).toBe('error') + expect(result.error?.message).toBe('Compaction requires a coherent message prefix to summarize.') + expect(calls).toHaveLength(0) + }) + test('compacts and retries exactly once after context overflow', async () => { const calls: LanguageModelV3CallOptions[] = [] const events: AgentEvent[] = []