Skip to content
Open
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
49 changes: 49 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.19.0",
"@anthropic-ai/sdk": "^0.90.0",
"@boxlite-ai/boxlite": "^0.4.3",
"better-sqlite3": "^11.8.1",
"cron-parser": "^5.5.0",
Expand Down
2 changes: 1 addition & 1 deletion src/agent/actions-http.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ class StdioMcpClient {
`MCP request ${method} #${id} timed out. stderr so far:\n${this.stderr}`,
),
);
}, 5000);
}, 10_000);
this.pending.set(id, (res) => {
clearTimeout(timer);
resolve(res);
Expand Down
85 changes: 85 additions & 0 deletions src/agent/context-compressor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

import {
ContextCompressor,
type FormattedMessage,
} from './context-compressor.js';

const mockAnthropic = {
messages: {
create: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'Mock summary of conversation.' }],
}),
},
} as any;

describe('ContextCompressor', () => {
let compressor: ContextCompressor;

beforeEach(() => {
vi.clearAllMocks();
compressor = new ContextCompressor(mockAnthropic);
});

describe('needsCompression', () => {
it('returns false when utilization is null', () => {
expect(compressor.needsCompression(null)).toBe(false);
});
it('returns false when utilization is below 0.80', () => {
expect(compressor.needsCompression(0.79)).toBe(false);
});
it('returns true when utilization is exactly 0.80', () => {
expect(compressor.needsCompression(0.8)).toBe(true);
});
it('returns true when utilization is above 0.80', () => {
expect(compressor.needsCompression(0.85)).toBe(true);
});
});

describe('compress', () => {
it('returns empty result for empty messages', async () => {
const result = await compressor.compress([]);
expect(result).toEqual({
summary: '',
messagesCompressed: 0,
messagesKept: 0,
});
});

it('keeps at least 1 message verbatim', async () => {
const messages: FormattedMessage[] = [
{ sender: 'user', content: 'hello' },
];
const result = await compressor.compress(messages);
expect(result.messagesKept).toBeGreaterThanOrEqual(1);
});

it('compresses and keeps correct counts for 10 messages', async () => {
const messages: FormattedMessage[] = Array.from(
{ length: 10 },
(_, i) => ({
sender: i % 2 === 0 ? 'user' : 'assistant',
content: `Message ${i}`,
}),
);
const result = await compressor.compress(messages);
expect(result.messagesKept).toBe(2); // 20% of 10
expect(result.messagesCompressed).toBe(8);
expect(result.summary).toBe('Mock summary of conversation.');
});

it('calls haiku model for summarization', async () => {
const messages: FormattedMessage[] = Array.from(
{ length: 5 },
(_, i) => ({
sender: 'user',
content: `msg ${i}`,
}),
);
await compressor.compress(messages);
expect(mockAnthropic.messages.create).toHaveBeenCalledWith(
expect.objectContaining({ model: 'claude-haiku-4-5-20251001' }),
);
});
});
});
54 changes: 54 additions & 0 deletions src/agent/context-compressor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Anthropic from '@anthropic-ai/sdk';
import { TextBlock } from '@anthropic-ai/sdk/resources/messages.js';

export interface FormattedMessage {
sender: string;
content: string;
}

export interface CompressResult {
summary: string;
messagesCompressed: number;
messagesKept: number;
}

export class ContextCompressor {
constructor(private anthropic: Anthropic) {}

needsCompression(utilization: number | null): boolean {
return utilization !== null && utilization >= 0.8;
}

async compress(messages: FormattedMessage[]): Promise<CompressResult> {
if (messages.length === 0) {
return { summary: '', messagesCompressed: 0, messagesKept: 0 };
}
const keepCount = Math.max(1, Math.floor(messages.length * 0.2));
const toSummarize = messages.slice(0, messages.length - keepCount);
const kept = messages.slice(messages.length - keepCount);
const summary =
toSummarize.length > 0 ? await this.callHaiku(toSummarize) : '';
return {
summary,
messagesCompressed: toSummarize.length,
messagesKept: kept.length,
};
}

private async callHaiku(messages: FormattedMessage[]): Promise<string> {
const transcript = messages
.map((m) => `[${m.sender}]: ${m.content}`)
.join('\n');
const response = await this.anthropic.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 1024,
messages: [
{
role: 'user',
content: `Summarize this conversation compactly, preserving key facts, decisions, and context:\n\n${transcript}`,
},
],
});
return (response.content[0] as TextBlock).text;
}
}
Loading