feat(agent-loop): expose compactionsDone + compactedReminder producer

Closes the loop on M2: when the compactor fires, the LLM needs to know
it's now seeing a <compact-summary> 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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 15:36:21 +02:00
parent be8f5618c6
commit 72f7978ed4
4 changed files with 122 additions and 0 deletions

View file

@ -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> = {}): 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 }),

View file

@ -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 <compact-summary> 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);