From 72f7978ed46f9f1fabc41d881f3a9530009eca23 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 23 Apr 2026 15:36:21 +0200 Subject: [PATCH] feat(agent-loop): expose compactionsDone + compactedReminder producer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the loop on M2: when the compactor fires, the LLM needs to know it's now seeing a instead of raw turns so it doesn't waste a turn asking about lost details or re-executing tools whose responses are gone. shared-ai: - LoopState grows `compactionsDone: number` (cap-1 by current loop policy, but shape kept as count for future multi-compact cycles). - runPlannerLoop populates it on each reminder-channel call. New loop test asserts [0, 1] sequence: round 1 before compaction, round 2 after. mana-ai: - New producer `compactedReminder` — fires severity=info when compactionsDone >= 1, wrapped in a German one-liner ("frag nicht nach verlorenen Details"). - Injected FIRST in buildReminderChannel so the LLM frames the rest of the round with "I'm looking at a summary" context. Metric surface stays `{producer='compacted', severity='info'}`. 4 new reminder tests (3 pure producer + 1 composition-ordering) + 1 loop-wiring test. 77 shared-ai, 20 reminders.test.ts — green. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/shared-ai/src/planner/loop.test.ts | 44 +++++++++++++++++++ packages/shared-ai/src/planner/loop.ts | 10 +++++ .../mana-ai/src/planner/reminders.test.ts | 41 +++++++++++++++++ services/mana-ai/src/planner/reminders.ts | 27 ++++++++++++ 4 files changed, 122 insertions(+) diff --git a/packages/shared-ai/src/planner/loop.test.ts b/packages/shared-ai/src/planner/loop.test.ts index 1acd349f0..e4516c84e 100644 --- a/packages/shared-ai/src/planner/loop.test.ts +++ b/packages/shared-ai/src/planner/loop.test.ts @@ -480,6 +480,50 @@ describe('runPlannerLoop — compactor', () => { expect(compactSpy).not.toHaveBeenCalled(); }); + it('surfaces compactionsDone in LoopState for reminder producers', async () => { + const llm = new MockLlmClient(); + // Round 1: over threshold + (llm as unknown as { queue: unknown[] }).queue.push({ + content: null, + toolCalls: [{ id: 'c1', name: 'list_things', arguments: {} }], + finishReason: 'tool_calls', + usage: { promptTokens: 950, completionTokens: 0, totalTokens: 950 }, + }); + // Round 2: stop so we end cleanly + llm.enqueueStop('done'); + + const compactionsDoneSeen: number[] = []; + await runPlannerLoop({ + llm, + input: { + systemPrompt: 's', + userPrompt: 'u', + tools, + model: 'm', + compactor: { + maxContextTokens: 1000, + compact: async () => ({ + messages: [ + { role: 'system', content: 's' }, + { role: 'user', content: 'u' }, + { role: 'assistant', content: '' }, + ], + compactedTurns: 2, + }), + }, + reminderChannel: (state) => { + compactionsDoneSeen.push(state.compactionsDone); + return []; + }, + }, + onToolCall: async () => ({ success: true, message: 'ok' }), + }); + + // Round 1 channel call: before compaction fires, so 0 + // Round 2 channel call: after compaction, so 1 + expect(compactionsDoneSeen).toEqual([0, 1]); + }); + it('skips when the compactor returns 0 compacted turns', async () => { const llm = new MockLlmClient(); (llm as unknown as { queue: unknown[] }).queue.push({ diff --git a/packages/shared-ai/src/planner/loop.ts b/packages/shared-ai/src/planner/loop.ts index 706a16689..69b6e9994 100644 --- a/packages/shared-ai/src/planner/loop.ts +++ b/packages/shared-ai/src/planner/loop.ts @@ -100,6 +100,15 @@ export interface LoopState { * tool), and similar. Empty in round 1; grows up to the cap. */ readonly recentCalls: readonly ExecutedCall[]; + /** + * Number of times the compactor has folded the message history in + * this loop run. Capped at 1 by the loop itself (fire-once policy), + * but still exposed as a count rather than a boolean so future + * policies (e.g. multi-compact cycles) don't need a breaking API + * change. A producer can use this to inject a "just compacted" + * reminder on the round immediately after compaction. + */ + readonly compactionsDone: number; } /** @@ -276,6 +285,7 @@ export async function runPlannerLoop(opts: { }, lastCall: executedCalls[executedCalls.length - 1], recentCalls, + compactionsDone, }; const reminders = input.reminderChannel(state); if (reminders.length > 0) { diff --git a/services/mana-ai/src/planner/reminders.test.ts b/services/mana-ai/src/planner/reminders.test.ts index c3ba47aa6..466e9b610 100644 --- a/services/mana-ai/src/planner/reminders.test.ts +++ b/services/mana-ai/src/planner/reminders.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test'; import { buildReminderChannel, + compactedReminder, retryLoopReminder, tokenBudgetReminder, type ReminderContext, @@ -50,6 +51,7 @@ function makeState(overrides: Partial = {}): LoopState { toolCallCount: 0, usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, recentCalls: [], + compactionsDone: 0, ...overrides, }; } @@ -188,6 +190,27 @@ describe('retryLoopReminder', () => { }); }); +// ─── compactedReminder ──────────────────────────────────────────── + +describe('compactedReminder', () => { + it('is silent when no compaction has happened', () => { + expect(compactedReminder({ compactionsDone: 0 })).toBeNull(); + }); + + it('fires once compactionsDone >= 1 with severity=info', () => { + const r = compactedReminder({ compactionsDone: 1 }); + expect(r).not.toBeNull(); + expect(r!.severity).toBe('info'); + expect(r!.producer).toBe('compacted'); + expect(r!.text).toContain('compact-summary'); + expect(r!.text).toContain('frag nicht'); + }); + + it('fires for counts greater than 1 too (future multi-compact)', () => { + expect(compactedReminder({ compactionsDone: 3 })).not.toBeNull(); + }); +}); + // ─── buildReminderChannel — composition ─────────────────────────── describe('buildReminderChannel', () => { @@ -229,6 +252,24 @@ describe('buildReminderChannel', () => { expect(out[0]).toContain('fehlgeschlagen'); }); + it('puts compacted reminder FIRST when it fires alongside budget warning', () => { + const channel = buildReminderChannel({ + agent: makeAgent({ maxTokensPerDay: 10_000 }), + mission: makeMission(), + pretickUsage24h: 9_000, // triggers budget warn + }); + const out = channel( + makeState({ + round: 2, + compactionsDone: 1, + usage: { promptTokens: 500, completionTokens: 500, totalTokens: 1_000 }, + }) + ); + expect(out).toHaveLength(2); + expect(out[0]).toContain('compact-summary'); // compacted first + expect(out[1]).toContain('Tagesbudget'); // budget second + }); + it('can fire budget + retry together (composition)', () => { const channel = buildReminderChannel({ agent: makeAgent({ maxTokensPerDay: 10_000 }), diff --git a/services/mana-ai/src/planner/reminders.ts b/services/mana-ai/src/planner/reminders.ts index 223f77e05..15dc55fbb 100644 --- a/services/mana-ai/src/planner/reminders.ts +++ b/services/mana-ai/src/planner/reminders.ts @@ -101,6 +101,25 @@ export function tokenBudgetReminder(ctx: ReminderContext, roundUsage: number): R * because a run that mixes failures and successes is not a true retry * loop, it's just flaky tools. */ +/** + * Fires the round immediately after the compactor folded older turns. + * Tells the LLM that it is now looking at a summary, not the raw log, + * so it doesn't burn a turn asking about "that error from earlier" or + * trying to re-execute a tool whose detailed result is gone. + */ +export function compactedReminder(state: { readonly compactionsDone: number }): Reminder | null { + if (state.compactionsDone < 1) return null; + return { + producer: 'compacted', + severity: 'info', + text: + `Ältere Turns wurden in ein gefaltet, um den ` + + `Context-Window nicht zu sprengen. Die Summary (Goal / Decisions / ` + + `Tools Called / Current Progress) ist die autoritative Kurz-Historie ` + + `— frag nicht nach verlorenen Details, arbeite ab da weiter.`, + }; +} + export function retryLoopReminder(state: { readonly round: number; readonly recentCalls: readonly { readonly result: { readonly success: boolean } }[]; @@ -132,8 +151,16 @@ export function retryLoopReminder(state: { export function buildReminderChannel(ctx: ReminderContext): ReminderChannel { return (state) => { const reminders: Reminder[] = []; + + // Order matters — the compacted-info block goes first so the LLM + // frames the rest of the reminders (and the upcoming turn) with + // the knowledge that it's looking at a summary. + const compacted = compactedReminder({ compactionsDone: state.compactionsDone }); + if (compacted) reminders.push(compacted); + const budget = tokenBudgetReminder(ctx, state.usage.totalTokens); if (budget) reminders.push(budget); + const retry = retryLoopReminder({ round: state.round, recentCalls: state.recentCalls }); if (retry) reminders.push(retry);