Skip to content

Commit 72d0c89

Browse files
committed
keep specific messages during truncation
1 parent ee6f368 commit 72d0c89

File tree

4 files changed

+206
-71
lines changed

4 files changed

+206
-71
lines changed

backend/src/run-agent-step.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -107,15 +107,16 @@ export const runAgentStep = async (
107107
})
108108

109109
let messageHistory = agentState.messageHistory
110-
const messagesWithUserPrompt = buildArray<CodebuffMessage>(
111-
...messageHistory,
112-
prompt && [
113-
{
114-
role: 'user' as const,
115-
content: asUserMessage(prompt),
116-
},
117-
],
118-
)
110+
const messagesWithUserPrompt = prompt
111+
? [
112+
...messageHistory.map((m) => ({ ...m, keepDuringTruncation: false })),
113+
{
114+
role: 'user' as const,
115+
content: asUserMessage(prompt),
116+
keepDuringTruncation: true,
117+
},
118+
]
119+
: messageHistory
119120

120121
// Check number of assistant messages since last user message with prompt
121122
if (agentState.stepsRemaining <= 0) {
@@ -252,7 +253,9 @@ export const runAgentStep = async (
252253
: undefined
253254

254255
const agentMessagesUntruncated = buildArray<CodebuffMessage>(
255-
...expireMessages(messageHistory, prompt ? 'userPrompt' : 'agentStep'),
256+
...expireMessages(messageHistory, prompt ? 'userPrompt' : 'agentStep').map(
257+
(m) => (prompt ? { ...m, keepDuringTruncation: false } : m),
258+
),
256259

257260
toolResults.length > 0 && {
258261
role: 'user' as const,
@@ -282,12 +285,14 @@ export const runAgentStep = async (
282285
role: 'user' as const,
283286
content: instructionsPrompt,
284287
timeToLive: 'userPrompt' as const,
288+
keepDuringTruncation: true,
285289
},
286290

287291
stepPrompt && {
288292
role: 'user' as const,
289293
content: stepPrompt,
290294
timeToLive: 'agentStep' as const,
295+
keepDuringTruncation: true,
291296
},
292297
)
293298

backend/src/util/__tests__/messages.test.ts

Lines changed: 155 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -159,31 +159,16 @@ describe('trimMessagesToFitTokenLimit', () => {
159159
maxTotalTokens,
160160
)
161161

162-
// Verify the first message was dropped
163-
expect(result).toHaveLength(testMessages.length - 1)
164-
165-
// Regular messages should be unchanged
166-
expect(result[0].content).toBe(testMessages[1].content)
167-
expect(result[6].content).toEqual(testMessages[7].content)
168-
169-
// 0th and second terminal outputs should be simplified
170-
expect(result[1].role).toEqual(testMessages[2].role)
171-
expect(Array.isArray(result[1].content)).toBe(true)
172-
expect((result[1].content[0] as any).text).toContain(
173-
'<result>[Output omitted]</result>',
174-
)
175-
expect((result[1].content[1] as any).text).toBe(
176-
(testMessages[2].content[1] as any).text,
177-
)
178-
179-
expect(result[2].role).toEqual(testMessages[3].role)
180-
expect(result[2].content).toContain('<result>[Output omitted]</result>')
162+
// Should have replacement message for omitted content
163+
expect(result.length).toBeGreaterThan(0)
181164

182-
// Terminal outputs 3-7 should be preserved exactly
183-
expect(result[3].content).toBe(testMessages[4].content)
184-
expect(result[4].content).toEqual(testMessages[5].content)
185-
expect(result[5].content).toBe(testMessages[6].content)
186-
expect(result[6].content).toBe(testMessages[7].content)
165+
// Should contain a replacement message for omitted content
166+
const hasReplacementMessage = result.some(
167+
(msg) =>
168+
typeof msg.content === 'string' &&
169+
msg.content.includes('Previous message(s) omitted due to length'),
170+
)
171+
expect(hasReplacementMessage).toBe(true)
187172

188173
// Verify total tokens are under limit
189174
const finalTokens = tokenCounter.countTokensJson(result)
@@ -199,31 +184,16 @@ describe('trimMessagesToFitTokenLimit', () => {
199184
maxTotalTokens,
200185
)
201186

202-
// Verify the first message was dropped
203-
expect(result).toHaveLength(testMessages.length - 1)
204-
205-
// Regular messages should be unchanged
206-
expect(result[0].content).toBe(testMessages[1].content)
207-
expect(result[6].content).toEqual(testMessages[7].content)
187+
// Should have replacement message for omitted content
188+
expect(result.length).toBeGreaterThan(0)
208189

209-
// 0th and second terminal outputs should be simplified
210-
expect(result[1].role).toEqual(testMessages[2].role)
211-
expect(Array.isArray(result[1].content)).toBe(true)
212-
expect((result[1].content[0] as any).text).toContain(
213-
'<result>[Output omitted]</result>',
214-
)
215-
expect((result[1].content[1] as any).text).toBe(
216-
(testMessages[2].content[1] as any).text,
190+
// Should contain a replacement message for omitted content
191+
const hasReplacementMessage = result.some(
192+
(msg) =>
193+
typeof msg.content === 'string' &&
194+
msg.content.includes('Previous message(s) omitted due to length'),
217195
)
218-
219-
expect(result[2].role).toEqual(testMessages[3].role)
220-
expect(result[2].content).toContain('<result>[Output omitted]</result>')
221-
222-
// Terminal outputs 3-7 should be preserved exactly
223-
expect(result[3].content).toBe(testMessages[4].content)
224-
expect(result[4].content).toEqual(testMessages[5].content)
225-
expect(result[5].content).toBe(testMessages[6].content)
226-
expect(result[6].content).toBe(testMessages[7].content)
196+
expect(hasReplacementMessage).toBe(true)
227197

228198
// Verify total tokens are under limit
229199
const finalTokens = tokenCounter.countTokensJson(result)
@@ -258,4 +228,142 @@ describe('trimMessagesToFitTokenLimit', () => {
258228

259229
expect(result).toEqual([])
260230
})
231+
232+
describe('keepDuringTruncation functionality', () => {
233+
it('preserves messages marked with keepDuringTruncation=true', () => {
234+
const messages = [
235+
{ role: 'user', content: 'A'.repeat(500) }, // Large message to force truncation
236+
{ role: 'user', content: 'B'.repeat(500) }, // Large message to force truncation
237+
{
238+
role: 'user',
239+
content: 'Message 3 - keep me!',
240+
keepDuringTruncation: true,
241+
},
242+
{ role: 'assistant', content: 'C'.repeat(500) }, // Large message to force truncation
243+
{
244+
role: 'user',
245+
content: 'Message 5 - keep me too!',
246+
keepDuringTruncation: true,
247+
},
248+
] as CodebuffMessage[]
249+
250+
const result = trimMessagesToFitTokenLimit(messages, 0, 1000)
251+
252+
// Should contain the kept messages
253+
const keptMessages = result.filter(
254+
(msg) =>
255+
typeof msg.content === 'string' &&
256+
(msg.content.includes('keep me!') ||
257+
msg.content.includes('keep me too!')),
258+
)
259+
expect(keptMessages).toHaveLength(2)
260+
261+
// Should have replacement message for omitted content
262+
const hasReplacementMessage = result.some(
263+
(msg) =>
264+
typeof msg.content === 'string' &&
265+
msg.content.includes('Previous message(s) omitted due to length'),
266+
)
267+
expect(hasReplacementMessage).toBe(true)
268+
})
269+
270+
it('does not add replacement message when no messages are removed', () => {
271+
const messages = [
272+
{ role: 'user', content: 'Short message 1' },
273+
{
274+
role: 'user',
275+
content: 'Short message 2',
276+
keepDuringTruncation: true,
277+
},
278+
] as CodebuffMessage[]
279+
280+
const result = trimMessagesToFitTokenLimit(messages, 0, 10000)
281+
282+
// Should be unchanged when under token limit
283+
expect(result).toHaveLength(2)
284+
expect(result[0].content).toBe('Short message 1')
285+
expect(result[1].content).toBe('Short message 2')
286+
})
287+
288+
it('handles consecutive replacement messages correctly', () => {
289+
const messages = [
290+
{ role: 'user', content: 'A'.repeat(1000) }, // Large message to be removed
291+
{ role: 'user', content: 'B'.repeat(1000) }, // Large message to be removed
292+
{ role: 'user', content: 'C'.repeat(1000) }, // Large message to be removed
293+
{ role: 'user', content: 'Keep this', keepDuringTruncation: true },
294+
] as CodebuffMessage[]
295+
296+
const result = trimMessagesToFitTokenLimit(messages, 0, 1000)
297+
298+
// Should only have one replacement message for consecutive removals
299+
const replacementMessages = result.filter(
300+
(msg) =>
301+
typeof msg.content === 'string' &&
302+
msg.content.includes('Previous message(s) omitted due to length'),
303+
)
304+
expect(replacementMessages).toHaveLength(1)
305+
306+
// Should keep the marked message
307+
const keptMessage = result.find(
308+
(msg) =>
309+
typeof msg.content === 'string' && msg.content.includes('Keep this'),
310+
)
311+
expect(keptMessage).toBeDefined()
312+
})
313+
314+
it('calculates token removal correctly with keepDuringTruncation', () => {
315+
const messages = [
316+
{ role: 'user', content: 'A'.repeat(500) }, // Will be removed
317+
{ role: 'user', content: 'B'.repeat(500) }, // Will be removed
318+
{
319+
role: 'user',
320+
content: 'Keep this short message',
321+
keepDuringTruncation: true,
322+
},
323+
{ role: 'user', content: 'C'.repeat(100) }, // Might be kept
324+
] as CodebuffMessage[]
325+
326+
const result = trimMessagesToFitTokenLimit(messages, 0, 2000)
327+
328+
// Should preserve the keepDuringTruncation message
329+
const keptMessage = result.find(
330+
(msg) =>
331+
typeof msg.content === 'string' &&
332+
msg.content.includes('Keep this short message'),
333+
)
334+
expect(keptMessage).toBeDefined()
335+
336+
// Total tokens should be under limit
337+
const finalTokens = tokenCounter.countTokensJson(result)
338+
expect(finalTokens).toBeLessThan(2000)
339+
})
340+
341+
it('handles mixed keepDuringTruncation and regular messages', () => {
342+
const messages = [
343+
{ role: 'user', content: 'A'.repeat(800) }, // Large message to force truncation
344+
{ role: 'user', content: 'Keep 1', keepDuringTruncation: true },
345+
{ role: 'user', content: 'B'.repeat(800) }, // Large message to force truncation
346+
{ role: 'user', content: 'Keep 2', keepDuringTruncation: true },
347+
{ role: 'user', content: 'C'.repeat(800) }, // Large message to force truncation
348+
] as CodebuffMessage[]
349+
350+
const result = trimMessagesToFitTokenLimit(messages, 0, 500)
351+
352+
// Should keep both marked messages
353+
const keptMessages = result.filter(
354+
(msg) =>
355+
typeof msg.content === 'string' &&
356+
(msg.content.includes('Keep 1') || msg.content.includes('Keep 2')),
357+
)
358+
expect(keptMessages).toHaveLength(2)
359+
360+
// Should have replacement messages for removed content
361+
const replacementMessages = result.filter(
362+
(msg) =>
363+
typeof msg.content === 'string' &&
364+
msg.content.includes('Previous message(s) omitted due to length'),
365+
)
366+
expect(replacementMessages.length).toBeGreaterThan(0)
367+
})
368+
})
261369
})

backend/src/util/messages.ts

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ function simplifyTerminalHelper(
113113

114114
// Factor to reduce token count target by, to leave room for new messages
115115
const shortenedMessageTokenFactor = 0.5
116+
const replacementMessage = {
117+
role: 'user',
118+
content: asSystemMessage('Previous message(s) omitted due to length'),
119+
} satisfies CodebuffMessage
116120

117121
/**
118122
* Trims messages from the beginning to fit within token limits while preserving
@@ -133,18 +137,16 @@ export function trimMessagesToFitTokenLimit(
133137
systemTokens: number,
134138
maxTotalTokens: number = 190_000,
135139
): CodebuffMessage[] {
136-
const MAX_MESSAGE_TOKENS = maxTotalTokens - systemTokens
140+
const maxMessageTokens = maxTotalTokens - systemTokens
137141

138142
// Check if we're already under the limit
139143
const initialTokens = countTokensJson(messages)
140144

141-
if (initialTokens < MAX_MESSAGE_TOKENS) {
145+
if (initialTokens < maxMessageTokens) {
142146
return messages
143147
}
144148

145-
let totalTokens = 0
146-
const targetTokens = MAX_MESSAGE_TOKENS * shortenedMessageTokenFactor
147-
const results: CodebuffMessage[] = []
149+
const shortenedMessages: CodebuffMessage[] = []
148150
let numKept = 0
149151

150152
// Process messages from newest to oldest
@@ -208,22 +210,41 @@ export function trimMessagesToFitTokenLimit(
208210
message = { ...m, content: newContent }
209211
}
210212
} else {
213+
m satisfies never
211214
throw new AssertionError({ message: 'Not a valid role' })
212215
}
213216

214-
// Check if adding this message would exceed our token target
215-
const messageTokens = countTokensJson(message)
217+
shortenedMessages.push(message)
218+
}
219+
shortenedMessages.reverse()
216220

217-
if (totalTokens + messageTokens <= targetTokens) {
218-
results.push(message)
219-
totalTokens += messageTokens
220-
} else {
221-
break
221+
const requiredTokens = countTokensJson(
222+
shortenedMessages.filter((m) => m.keepDuringTruncation),
223+
)
224+
let removedTokens = 0
225+
const tokensToRemove =
226+
(maxMessageTokens - requiredTokens) * (1 - shortenedMessageTokenFactor)
227+
228+
const placeholder = 'deleted'
229+
const filteredMessages: (CodebuffMessage | typeof placeholder)[] = []
230+
for (const message of shortenedMessages) {
231+
if (removedTokens >= tokensToRemove || message.keepDuringTruncation) {
232+
filteredMessages.push(message)
233+
continue
234+
}
235+
removedTokens += countTokensJson(message)
236+
if (
237+
filteredMessages.length === 0 ||
238+
filteredMessages[filteredMessages.length - 1] !== placeholder
239+
) {
240+
filteredMessages.push(placeholder)
241+
removedTokens -= countTokensJson(replacementMessage)
222242
}
223243
}
224244

225-
results.reverse()
226-
return results
245+
return filteredMessages.map((m) =>
246+
m === placeholder ? replacementMessage : m,
247+
)
227248
}
228249

229250
export function getMessagesSubset(

common/src/types/message.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export const CodebuffMessageSchema = z.intersection(
6161
timeToLive: z
6262
.union([z.literal('agentStep'), z.literal('userPrompt')])
6363
.optional(),
64+
keepDuringTruncation: z.boolean().optional(),
6465
}),
6566
)
6667

0 commit comments

Comments
 (0)