Skip to content

Commit b83dbfc

Browse files
authored
Merge pull request #70 from KryptSec/fix/trim-messages-orphaned-tool
fix: trimMessages() orphans tool messages, crashes OpenAI runs
2 parents 83b6a4e + 03baab7 commit b83dbfc

2 files changed

Lines changed: 52 additions & 0 deletions

File tree

src/lib/runner.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ export function trimMessages<T extends { role: string }>(messages: T[]): T[] {
3535
while (start < tail.length && tail[start].role === messages[0].role) {
3636
start++;
3737
}
38+
// Skip orphaned tool messages whose parent assistant+tool_calls was sliced off.
39+
// OpenAI hard-rejects these with 400; other providers silently degrade.
40+
while (start < tail.length && tail[start].role === 'tool') {
41+
start++;
42+
}
3843
return [messages[0], ...tail.slice(start)];
3944
}
4045

tests/unit/runner.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,4 +328,51 @@ describe('trimMessages', () => {
328328
expect(result[0].role).toBe('system');
329329
expect(result.length).toBeLessThanOrEqual(MAX_CONTEXT_MESSAGES);
330330
});
331+
332+
it('skips orphaned tool messages at trim boundary', () => {
333+
// Simulate a real agent conversation where trimming slices between
334+
// an assistant+tool_calls message and its tool response.
335+
// OpenAI rejects orphaned tool messages with 400.
336+
const msgs: { role: string; content: string }[] = [
337+
{ role: 'user', content: 'system prompt' },
338+
];
339+
// Fill with user/assistant pairs to push past the limit
340+
for (let i = 1; i < MAX_CONTEXT_MESSAGES; i++) {
341+
msgs.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: `msg-${i}` });
342+
}
343+
// Now add a tool response that will land at the start of the tail
344+
// after its parent assistant message gets sliced off
345+
msgs.push({ role: 'tool', content: 'tool-response-orphaned' });
346+
msgs.push({ role: 'tool', content: 'tool-response-orphaned-2' });
347+
msgs.push({ role: 'assistant', content: 'next-reasoning' });
348+
msgs.push({ role: 'user', content: 'latest' });
349+
350+
const result = trimMessages(msgs);
351+
// The orphaned tool messages right after the anchor should be skipped
352+
expect(result[1].role).not.toBe('tool');
353+
// The non-orphaned messages (assistant, user) should follow the anchor
354+
expect(result[1].role).toBe('assistant');
355+
});
356+
357+
it('handles tool message right after anchor role collision', () => {
358+
// Edge case: anchor is 'user', tail starts with 'user' (dropped by
359+
// existing dedup), then 'tool' (should also be dropped)
360+
const msgs: { role: string; content: string }[] = [
361+
{ role: 'user', content: 'anchor' },
362+
];
363+
// Push past limit with alternating messages
364+
for (let i = 1; i <= MAX_CONTEXT_MESSAGES + 2; i++) {
365+
msgs.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: `fill-${i}` });
366+
}
367+
// Manually inject a user+tool sequence at the expected tail boundary
368+
// After slicing, tail[0] = 'user' (deduped), tail[1] = 'tool' (orphaned)
369+
const tailStart = msgs.length - MAX_CONTEXT_MESSAGES + 1;
370+
msgs[tailStart] = { role: 'user', content: 'collision' };
371+
msgs[tailStart + 1] = { role: 'tool', content: 'orphaned-tool' };
372+
msgs[tailStart + 2] = { role: 'assistant', content: 'recovery' };
373+
374+
const result = trimMessages(msgs);
375+
expect(result[0].role).toBe('user');
376+
expect(result[1].role).not.toBe('tool');
377+
});
331378
});

0 commit comments

Comments
 (0)