Skip to content

Commit 45e504e

Browse files
committed
feat: implement token watermark and recent message protection in ConversationCompressor
1 parent bfdd6c0 commit 45e504e

File tree

8 files changed

+488
-150
lines changed

8 files changed

+488
-150
lines changed

migration/PHASE_1_IMPLEMENTATION_SUMMARY.md

Lines changed: 0 additions & 141 deletions
This file was deleted.

packages/browser-runtime/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,5 @@ export * from "./runtime/types.js";
2020
export * from "./storage/index.js";
2121
// Tools
2222
export * from "./tools/index.js";
23+
// Voice
24+
export * from "./voice/index.js";

packages/core/src/agent/aipex.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ export class AIPex {
241241

242242
if (session) {
243243
session.addMetrics(metrics);
244+
session.setMetadata("lastPromptTokens", metrics.promptTokens);
244245
if (this.conversationManager) {
245246
await this.conversationManager.saveSession(session);
246247
}
@@ -270,6 +271,7 @@ export class AIPex {
270271
yield { type: "error", error: agentError };
271272
if (session) {
272273
session.addMetrics(metrics);
274+
session.setMetadata("lastPromptTokens", metrics.promptTokens);
273275
if (this.conversationManager) {
274276
await this.conversationManager.saveSession(session);
275277
}

packages/core/src/conversation/compressor.test.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,163 @@ describe("ConversationCompressor", () => {
110110
expect(result.compressedItems.length).toBe(2);
111111
});
112112
});
113+
114+
describe("token watermark compression", () => {
115+
it("should trigger compression when lastPromptTokens exceeds watermark", () => {
116+
const watermarkCompressor = new ConversationCompressor(mockModel, {
117+
tokenWatermark: 150000,
118+
});
119+
120+
expect(watermarkCompressor.shouldCompress(10, 200000)).toBe(true);
121+
expect(watermarkCompressor.shouldCompress(10, 100000)).toBe(false);
122+
});
123+
124+
it("should fallback to item count when tokenWatermark not set", () => {
125+
const compressor = new ConversationCompressor(mockModel, {
126+
summarizeAfterItems: 20,
127+
});
128+
129+
expect(compressor.shouldCompress(25, 100000)).toBe(true);
130+
expect(compressor.shouldCompress(15, 200000)).toBe(false);
131+
});
132+
133+
it("should fallback to item count when lastPromptTokens not provided", () => {
134+
const watermarkCompressor = new ConversationCompressor(mockModel, {
135+
tokenWatermark: 150000,
136+
summarizeAfterItems: 20,
137+
});
138+
139+
expect(watermarkCompressor.shouldCompress(25)).toBe(true);
140+
expect(watermarkCompressor.shouldCompress(15)).toBe(false);
141+
});
142+
});
143+
144+
describe("protectRecentMessages", () => {
145+
it("should protect recent N message items", async () => {
146+
const protectCompressor = new ConversationCompressor(mockModel, {
147+
summarizeAfterItems: 5,
148+
protectRecentMessages: 2,
149+
});
150+
151+
const items: AgentInputItem[] = [
152+
createUserMessage("Message 1"),
153+
createAssistantMessage("Response 1"),
154+
createUserMessage("Message 2"),
155+
createAssistantMessage("Response 2"),
156+
createUserMessage("Message 3"),
157+
createAssistantMessage("Response 3"),
158+
];
159+
160+
const result = await protectCompressor.compressItems(items);
161+
162+
// Should protect the last 2 messages (assistant 2 and assistant 3)
163+
// and summarize everything before that
164+
expect(result.compressedItems.length).toBe(2);
165+
expect(result.summary).toBe("Compressed summary");
166+
});
167+
168+
it("should protect tool call/result pairs with recent messages", async () => {
169+
const protectCompressor = new ConversationCompressor(mockModel, {
170+
summarizeAfterItems: 5,
171+
protectRecentMessages: 1,
172+
});
173+
174+
const items: AgentInputItem[] = [
175+
createUserMessage("Message 1"),
176+
createAssistantMessage("Response 1"),
177+
createUserMessage("Use a tool"),
178+
createAssistantMessage("I'll use the tool"),
179+
{
180+
type: "function_call",
181+
callId: "call_123",
182+
name: "testTool",
183+
arguments: "{}",
184+
} as AgentInputItem,
185+
{
186+
type: "function_call_result",
187+
callId: "call_123",
188+
name: "testTool",
189+
output: "result",
190+
} as AgentInputItem,
191+
createAssistantMessage("Tool result received"),
192+
];
193+
194+
const result = await protectCompressor.compressItems(items);
195+
196+
// Should protect the last assistant message AND the tool call/result pair
197+
// and the assistant message before the tool calls
198+
expect(result.compressedItems.length).toBeGreaterThanOrEqual(4);
199+
expect(result.summary).toBe("Compressed summary");
200+
});
201+
202+
it("should include assistant message preceding tool calls", async () => {
203+
const protectCompressor = new ConversationCompressor(mockModel, {
204+
summarizeAfterItems: 5,
205+
protectRecentMessages: 1,
206+
});
207+
208+
const items: AgentInputItem[] = [
209+
createUserMessage("Message 1"),
210+
createAssistantMessage("Response 1"),
211+
createUserMessage("Message 2"),
212+
createAssistantMessage("I'll call a tool"),
213+
{
214+
type: "function_call",
215+
callId: "call_456",
216+
name: "testTool",
217+
arguments: "{}",
218+
} as AgentInputItem,
219+
{
220+
type: "function_call_result",
221+
callId: "call_456",
222+
name: "testTool",
223+
output: "result",
224+
} as AgentInputItem,
225+
createAssistantMessage("Final response"),
226+
];
227+
228+
const result = await protectCompressor.compressItems(items);
229+
230+
// Should protect the final assistant message, tool items,
231+
// and the assistant message that initiated the tool call
232+
const finalAssistant = result.compressedItems.find(
233+
(item) =>
234+
item.type === "message" &&
235+
item.role === "assistant" &&
236+
(item.content as any)?.[0]?.text === "Final response",
237+
);
238+
const toolCall = result.compressedItems.find(
239+
(item) => item.type === "function_call",
240+
);
241+
const precedingAssistant = result.compressedItems.find(
242+
(item) =>
243+
item.type === "message" &&
244+
item.role === "assistant" &&
245+
(item.content as any)?.[0]?.text === "I'll call a tool",
246+
);
247+
248+
expect(finalAssistant).toBeDefined();
249+
expect(toolCall).toBeDefined();
250+
expect(precedingAssistant).toBeDefined();
251+
});
252+
253+
it("should handle case with no messages to protect", async () => {
254+
const protectCompressor = new ConversationCompressor(mockModel, {
255+
summarizeAfterItems: 5,
256+
protectRecentMessages: 10,
257+
});
258+
259+
const items: AgentInputItem[] = [
260+
createUserMessage("Message 1"),
261+
createAssistantMessage("Response 1"),
262+
createUserMessage("Message 2"),
263+
];
264+
265+
const result = await protectCompressor.compressItems(items);
266+
267+
// Should protect all items since protectRecentMessages > total messages
268+
expect(result.compressedItems.length).toBe(3);
269+
expect(result.summary).toBe("");
270+
});
271+
});
113272
});

0 commit comments

Comments
 (0)