Skip to content

Commit 098a043

Browse files
committed
feat: context window management — sliding window with auto-summarization
1 parent 6056cb5 commit 098a043

5 files changed

Lines changed: 94 additions & 14 deletions

File tree

src/agent/context-compressor.test.ts

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,39 @@ function buildMessages(count: number): FormattedMessage[] {
1313
}));
1414
}
1515

16+
function createAnthropicMock(summary = 'compact summary'): Anthropic {
17+
return {
18+
messages: {
19+
create: vi.fn(async () => ({
20+
content: [{ type: 'text', text: summary }],
21+
})),
22+
},
23+
} as unknown as Anthropic;
24+
}
25+
1626
describe('ContextCompressor', () => {
17-
it('needsCompression uses the 80% threshold', () => {
18-
const compressor = new ContextCompressor({} as Anthropic);
27+
it('returns false for null utilization', () => {
28+
const compressor = new ContextCompressor(createAnthropicMock());
1929

2030
expect(compressor.needsCompression(null)).toBe(false);
31+
});
32+
33+
it('returns false below 80% utilization', () => {
34+
const compressor = new ContextCompressor(createAnthropicMock());
35+
2136
expect(compressor.needsCompression(0.79)).toBe(false);
37+
});
38+
39+
it('returns true at 80% utilization', () => {
40+
const compressor = new ContextCompressor(createAnthropicMock());
41+
2242
expect(compressor.needsCompression(0.8)).toBe(true);
23-
expect(compressor.needsCompression(0.85)).toBe(true);
24-
expect(compressor.needsCompression(1.0)).toBe(true);
43+
});
44+
45+
it('returns true above 80% utilization', () => {
46+
const compressor = new ContextCompressor(createAnthropicMock());
47+
48+
expect(compressor.needsCompression(0.95)).toBe(true);
2549
});
2650

2751
it('compress keeps the most recent 20% and summarizes the rest', async () => {
@@ -58,6 +82,22 @@ describe('ContextCompressor', () => {
5882
);
5983
});
6084

85+
it('compress keeps a single message and skips summarization', async () => {
86+
const anthropic = createAnthropicMock();
87+
const compressor = new ContextCompressor(anthropic);
88+
89+
const result = await compressor.compress([
90+
{ sender: 'User', content: 'one' },
91+
]);
92+
93+
expect(result).toEqual({
94+
summary: '',
95+
messagesCompressed: 0,
96+
messagesKept: 1,
97+
});
98+
expect(anthropic.messages.create).not.toHaveBeenCalled();
99+
});
100+
61101
it('formatSummaryBlock wraps and escapes summary content', () => {
62102
const compressor = new ContextCompressor({} as Anthropic);
63103

@@ -71,4 +111,20 @@ describe('ContextCompressor', () => {
71111
expect(block).toContain('Use <tags> & "quotes"');
72112
expect(block).toContain('</context_summary>');
73113
});
114+
115+
it('calls Haiku with the expected model', async () => {
116+
const anthropic = createAnthropicMock();
117+
const compressor = new ContextCompressor(anthropic);
118+
119+
await compressor.compress([
120+
{ sender: 'user', content: 'old context' },
121+
{ sender: 'assistant', content: 'new context' },
122+
]);
123+
124+
expect(anthropic.messages.create).toHaveBeenCalledWith(
125+
expect.objectContaining({
126+
model: 'claude-haiku-4-5-20251001',
127+
}),
128+
);
129+
});
74130
});

src/agent/context-compressor.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@ export interface CompressResult {
77
messagesKept: number;
88
}
99

10-
export interface FormattedMessage {
11-
sender: string;
10+
export interface CompressMessage {
11+
role?: string;
12+
sender?: string;
1213
content: string;
1314
}
1415

16+
export type FormattedMessage = CompressMessage;
17+
1518
function isTextBlock(block: { type: string }): block is TextBlock {
1619
return block.type === 'text' && 'text' in block;
1720
}
@@ -35,7 +38,7 @@ export class ContextCompressor {
3538
return utilization !== null && utilization >= ContextCompressor.THRESHOLD;
3639
}
3740

38-
async compress(messages: FormattedMessage[]): Promise<CompressResult> {
41+
async compress(messages: CompressMessage[]): Promise<CompressResult> {
3942
if (messages.length === 0) {
4043
return { summary: '', messagesCompressed: 0, messagesKept: 0 };
4144
}
@@ -62,16 +65,23 @@ export class ContextCompressor {
6265
return `<context_summary type="compressed" compressed_at="${escapeXml(compressedAt)}">\nEarlier conversation summary (auto-generated):\n${escapeXml(summary)}\n</context_summary>`;
6366
}
6467

68+
buildSummaryBlock(summary: string): string {
69+
return this.formatSummaryBlock(summary);
70+
}
71+
6572
private getAnthropic(): Anthropic {
6673
if (!this.anthropic) {
6774
this.anthropic = new Anthropic();
6875
}
6976
return this.anthropic;
7077
}
7178

72-
private async callHaiku(messages: FormattedMessage[]): Promise<string> {
79+
private async callHaiku(messages: CompressMessage[]): Promise<string> {
7380
const transcript = messages
74-
.map((message) => `[${message.sender}]: ${message.content}`)
81+
.map(
82+
(message) =>
83+
`[${message.role ?? message.sender ?? 'unknown'}]: ${message.content}`,
84+
)
7585
.join('\n');
7686
const anthropic = this.getAnthropic();
7787
const response = await anthropic.messages.create({

src/agent/message-processor.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -541,7 +541,7 @@ export class MessageProcessor {
541541
try {
542542
const result = await this.contextCompressor.compress(
543543
recentMessages.map((message) => ({
544-
sender: message.sender_name || message.sender,
544+
role: message.sender_name || message.sender,
545545
content: message.content,
546546
})),
547547
);
@@ -555,8 +555,8 @@ export class MessageProcessor {
555555
const compressedAt = new Date().toISOString();
556556

557557
delete this.ctx.sessions[group.folder];
558-
this.ctx.db.setSession(group.folder, '');
559-
this.ctx.db.setContextUtilization(chatJid, null);
558+
this.ctx.db.clearSession(group.folder);
559+
this.ctx.db.clearContextUtilization(chatJid);
560560
this.ctx.emit('context_compressed', {
561561
agentId: this.ctx.id,
562562
jid: chatJid,
@@ -566,7 +566,7 @@ export class MessageProcessor {
566566
timestamp: compressedAt,
567567
});
568568

569-
return `${this.contextCompressor.formatSummaryBlock(result.summary, compressedAt)}\n${formatMessages(
569+
return `${this.contextCompressor.formatSummaryBlock(result.summary, compressedAt)}\n\n${formatMessages(
570570
keptMessages,
571571
this.ctx.runtimeConfig.timezone,
572572
)}`;

src/db.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,7 @@ export class AgentDb {
575575
.run(
576576
this.contextUtilizationKey(groupJid),
577577
utilization,
578-
utilization === null ? null : Date.now(),
578+
utilization !== null ? Date.now() : null,
579579
);
580580
}
581581

@@ -588,6 +588,10 @@ export class AgentDb {
588588
return row?.context_utilization ?? null;
589589
}
590590

591+
clearContextUtilization(groupJid: string): void {
592+
this.setContextUtilization(groupJid, null);
593+
}
594+
591595
// --- Sessions ---
592596

593597
getSession(groupFolder: string): string | undefined {
@@ -605,6 +609,12 @@ export class AgentDb {
605609
.run(groupFolder, sessionId);
606610
}
607611

612+
clearSession(groupFolder: string): void {
613+
this.db
614+
.prepare('DELETE FROM sessions WHERE group_folder = ?')
615+
.run(groupFolder);
616+
}
617+
608618
getAllSessions(): Record<string, string> {
609619
const rows = this.db
610620
.prepare('SELECT group_folder, session_id FROM sessions')

src/run-stream-events.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ vi.mock('./agent/context-compressor.js', () => ({
4444
compress(messages: Array<{ sender: string; content: string }>) {
4545
return contextCompressionMocks.compress(messages);
4646
}
47+
48+
formatSummaryBlock(summary: string, compressedAt: string) {
49+
return `<context_summary type="compressed" compressed_at="${compressedAt}">\nEarlier conversation summary (auto-generated):\n${summary}\n</context_summary>`;
50+
}
4751
},
4852
}));
4953

0 commit comments

Comments
 (0)