Merge pull request #2327 from glifocat/wip/compaction-destination-reminder
fix: inject destination reminder after SDK auto-compaction
This commit is contained in:
@@ -129,8 +129,116 @@ describe('poll loop integration', () => {
|
|||||||
|
|
||||||
await loopPromise.catch(() => {});
|
await loopPromise.catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should inject destination reminder after a compacted event', async () => {
|
||||||
|
// Two destinations — required for the reminder to fire (single-destination
|
||||||
|
// groups have a fallback path that works without <message to="…"> wrapping).
|
||||||
|
getInboundDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||||
|
VALUES ('discord-second', 'Discord Second', 'channel', 'discord', 'chan-2', NULL)`,
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||||
|
|
||||||
|
const provider = new CompactingProvider();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500);
|
||||||
|
|
||||||
|
await waitFor(() => getUndeliveredMessages().length > 0, 2500);
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
expect(provider.pushes.length).toBeGreaterThanOrEqual(1);
|
||||||
|
const reminder = provider.pushes.find((p) => p.includes('Context was just compacted'));
|
||||||
|
expect(reminder).toBeDefined();
|
||||||
|
expect(reminder).toContain('2 destinations');
|
||||||
|
expect(reminder).toContain('discord-test');
|
||||||
|
expect(reminder).toContain('discord-second');
|
||||||
|
expect(reminder).toContain('<message to="name">');
|
||||||
|
|
||||||
|
await loopPromise.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT inject destination reminder with a single destination', async () => {
|
||||||
|
insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||||
|
|
||||||
|
const provider = new CompactingProvider();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500);
|
||||||
|
|
||||||
|
await waitFor(() => getUndeliveredMessages().length > 0, 2500);
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
// Only the original prompt push (if any) — no reminder, since beforeEach
|
||||||
|
// seeds exactly one destination.
|
||||||
|
const reminders = provider.pushes.filter((p) => p.includes('Context was just compacted'));
|
||||||
|
expect(reminders).toHaveLength(0);
|
||||||
|
|
||||||
|
await loopPromise.catch(() => {});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider that emits a single compacted event mid-stream, then returns a
|
||||||
|
* result. Captures every push() call so tests can assert on the injected
|
||||||
|
* reminder content.
|
||||||
|
*/
|
||||||
|
class CompactingProvider {
|
||||||
|
readonly supportsNativeSlashCommands = false;
|
||||||
|
readonly pushes: string[] = [];
|
||||||
|
|
||||||
|
isSessionInvalid(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
query(_input: { prompt: string; cwd: string }) {
|
||||||
|
const pushes = this.pushes;
|
||||||
|
let ended = false;
|
||||||
|
let aborted = false;
|
||||||
|
let resolveWaiter: (() => void) | null = null;
|
||||||
|
|
||||||
|
async function* events() {
|
||||||
|
yield { type: 'activity' as const };
|
||||||
|
yield { type: 'init' as const, continuation: 'compaction-test-session' };
|
||||||
|
yield { type: 'activity' as const };
|
||||||
|
yield { type: 'compacted' as const, text: 'Context compacted (50,000 tokens compacted).' };
|
||||||
|
|
||||||
|
// Wait for poll-loop to push the reminder (or end / abort)
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
resolveWaiter = resolve;
|
||||||
|
// Belt-and-braces: don't hang forever if the reminder never arrives
|
||||||
|
setTimeout(resolve, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
yield { type: 'activity' as const };
|
||||||
|
yield { type: 'result' as const, text: '<message to="discord-test">ack</message>' };
|
||||||
|
while (!ended && !aborted) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
resolveWaiter = resolve;
|
||||||
|
setTimeout(resolve, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
push(message: string) {
|
||||||
|
pushes.push(message);
|
||||||
|
resolveWaiter?.();
|
||||||
|
},
|
||||||
|
end() {
|
||||||
|
ended = true;
|
||||||
|
resolveWaiter?.();
|
||||||
|
},
|
||||||
|
abort() {
|
||||||
|
aborted = true;
|
||||||
|
resolveWaiter?.();
|
||||||
|
},
|
||||||
|
events: events(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper: run poll loop until aborted or timeout
|
// Helper: run poll loop until aborted or timeout
|
||||||
async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise<void> {
|
async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise<void> {
|
||||||
return Promise.race([
|
return Promise.race([
|
||||||
|
|||||||
@@ -366,6 +366,23 @@ async function processQuery(
|
|||||||
if (event.text) {
|
if (event.text) {
|
||||||
dispatchResultText(event.text, routing);
|
dispatchResultText(event.text, routing);
|
||||||
}
|
}
|
||||||
|
} else if (event.type === 'compacted') {
|
||||||
|
// The SDK auto-compacted the conversation. After compaction the
|
||||||
|
// model often drops the learned `<message to="…">` wrapping
|
||||||
|
// discipline (the destinations are still in the system prompt,
|
||||||
|
// but the behavioral pattern is summarized away). Inject a
|
||||||
|
// reminder back into the live query so the next turn re-anchors
|
||||||
|
// on the destination model. Only do this when there's >1
|
||||||
|
// destination — single-destination groups have a fallback that
|
||||||
|
// works without wrapping. See qwibitai/nanoclaw#2325.
|
||||||
|
const destinations = getAllDestinations();
|
||||||
|
if (destinations.length > 1) {
|
||||||
|
const names = destinations.map((d) => d.name).join(', ');
|
||||||
|
query.push(
|
||||||
|
`[system] Context was just compacted. Reminder: you have ${destinations.length} destinations (${names}). ` +
|
||||||
|
`Use <message to="name"> blocks to address them. Bare text goes to the scratchpad fallback only.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -390,6 +407,9 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
|
|||||||
case 'progress':
|
case 'progress':
|
||||||
log(`Progress: ${event.message}`);
|
log(`Progress: ${event.message}`);
|
||||||
break;
|
break;
|
||||||
|
case 'compacted':
|
||||||
|
log(`Compacted: ${event.text}`);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ export class ClaudeProvider implements AgentProvider {
|
|||||||
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
|
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
|
||||||
const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata;
|
const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata;
|
||||||
const detail = meta?.pre_tokens ? ` (${meta.pre_tokens.toLocaleString()} tokens compacted)` : '';
|
const detail = meta?.pre_tokens ? ` (${meta.pre_tokens.toLocaleString()} tokens compacted)` : '';
|
||||||
yield { type: 'result', text: `Context compacted${detail}.` };
|
yield { type: 'compacted', text: `Context compacted${detail}.` };
|
||||||
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
|
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
|
||||||
const tn = message as { summary?: string };
|
const tn = message as { summary?: string };
|
||||||
yield { type: 'progress', message: tn.summary || 'Task notification' };
|
yield { type: 'progress', message: tn.summary || 'Task notification' };
|
||||||
|
|||||||
@@ -79,4 +79,12 @@ export type ProviderEvent =
|
|||||||
* event (tool call, thinking, partial message, anything) so the
|
* event (tool call, thinking, partial message, anything) so the
|
||||||
* poll-loop's idle timer stays honest during long tool runs.
|
* poll-loop's idle timer stays honest during long tool runs.
|
||||||
*/
|
*/
|
||||||
| { type: 'activity' };
|
| { type: 'activity' }
|
||||||
|
/**
|
||||||
|
* The provider's underlying SDK auto-compacted the conversation context.
|
||||||
|
* The poll-loop reacts by injecting a destination reminder back into
|
||||||
|
* the live query so the agent doesn't drop `<message to="…">` wrapping
|
||||||
|
* after compaction. Distinct from `result` so it doesn't mark the turn
|
||||||
|
* completed or get dispatched as a chat message. See qwibitai/nanoclaw#2325.
|
||||||
|
*/
|
||||||
|
| { type: 'compacted'; text: string };
|
||||||
|
|||||||
Reference in New Issue
Block a user