Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions packages/agentlayer-core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
COMPACTION_SYSTEM_PROMPT,
type CompactionTrigger,
compactionSummaryMessage,
fitsCompactionTail,
isContextOverflowError,
parseCompactCommand,
planCompaction,
Expand Down Expand Up @@ -709,11 +710,19 @@ export class Agent<TTools extends Record<string, Tool<any, any>> = Record<string
const messagesWithoutCommand = allMessages.filter(
(_, index) => 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,
Expand Down
15 changes: 15 additions & 0 deletions packages/agentlayer-core/src/compaction/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ModelMessage>, 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<string>())) 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<RegExp> = [
/context[_ ]length[_ ]exceeded/i,
/model_context_window_exceeded/i,
Expand Down
44 changes: 44 additions & 0 deletions packages/agentlayer-core/test/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []
Expand Down
Loading