mirror of https://github.com/buster-so/buster.git
Add 529 error handling with tool call cleanup and backoff strategy
Co-authored-by: dallin <dallin@buster.so>
This commit is contained in:
parent
58542c204d
commit
7988ccbb58
|
@ -43,6 +43,14 @@ export function determineHealingStrategy(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 529 Overloaded errors - special handling, cleanup is done separately
|
||||||
|
case 'overloaded-error':
|
||||||
|
return {
|
||||||
|
shouldRemoveLastAssistantMessage: false,
|
||||||
|
healingMessage: null, // No healing message needed, cleanup handles it
|
||||||
|
backoffMultiplier: 2, // Longer backoff for overload
|
||||||
|
};
|
||||||
|
|
||||||
// Network/server errors - just retry with backoff
|
// Network/server errors - just retry with backoff
|
||||||
case 'network-timeout':
|
case 'network-timeout':
|
||||||
case 'server-error':
|
case 'server-error':
|
||||||
|
|
|
@ -10,6 +10,7 @@ export {
|
||||||
logRetryInfo,
|
logRetryInfo,
|
||||||
logMessagesAfterHealing,
|
logMessagesAfterHealing,
|
||||||
handleRetryWithHealing,
|
handleRetryWithHealing,
|
||||||
|
cleanupIncompleteToolCalls,
|
||||||
} from './retry-helpers';
|
} from './retry-helpers';
|
||||||
export {
|
export {
|
||||||
determineHealingStrategy,
|
determineHealingStrategy,
|
||||||
|
|
|
@ -202,6 +202,19 @@ export function detectRetryableError(
|
||||||
|
|
||||||
// Server errors (5xx)
|
// Server errors (5xx)
|
||||||
if (error.statusCode && error.statusCode >= 500 && error.statusCode < 600) {
|
if (error.statusCode && error.statusCode >= 500 && error.statusCode < 600) {
|
||||||
|
// Special handling for 529 overloaded errors
|
||||||
|
if (error.statusCode === 529) {
|
||||||
|
return {
|
||||||
|
type: 'overloaded-error',
|
||||||
|
originalError: error,
|
||||||
|
healingMessage: {
|
||||||
|
role: 'user',
|
||||||
|
content: `Server overloaded (529). Retrying after cleanup...`,
|
||||||
|
},
|
||||||
|
requiresMessageCleanup: true, // New flag
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'server-error',
|
type: 'server-error',
|
||||||
originalError: error,
|
originalError: error,
|
||||||
|
|
|
@ -311,6 +311,62 @@ export function logMessagesAfterHealing(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up incomplete tool calls from message history
|
||||||
|
* This is specifically for handling 529 errors that disconnect mid-stream
|
||||||
|
* while generating tool calls, leaving orphaned tool calls without results
|
||||||
|
*/
|
||||||
|
export function cleanupIncompleteToolCalls(messages: CoreMessage[]): CoreMessage[] {
|
||||||
|
const cleaned = [...messages];
|
||||||
|
|
||||||
|
// Find the last assistant message
|
||||||
|
for (let i = cleaned.length - 1; i >= 0; i--) {
|
||||||
|
const msg = cleaned[i];
|
||||||
|
if (!msg) continue; // Add guard for undefined
|
||||||
|
|
||||||
|
if (msg.role === 'assistant' && Array.isArray(msg.content)) {
|
||||||
|
const toolCalls = msg.content.filter(
|
||||||
|
c => typeof c === 'object' && 'type' in c && c.type === 'tool-call'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if any tool calls lack results
|
||||||
|
const orphanedToolCalls = toolCalls.filter(toolCall => {
|
||||||
|
// Look ahead for matching tool result
|
||||||
|
for (let j = i + 1; j < cleaned.length; j++) {
|
||||||
|
const nextMsg = cleaned[j];
|
||||||
|
if (!nextMsg) continue; // Add guard for undefined
|
||||||
|
|
||||||
|
if (nextMsg.role === 'tool' && Array.isArray(nextMsg.content)) {
|
||||||
|
const hasResult = nextMsg.content.some(
|
||||||
|
c => typeof c === 'object' &&
|
||||||
|
'type' in c &&
|
||||||
|
c.type === 'tool-result' &&
|
||||||
|
'toolCallId' in c &&
|
||||||
|
'toolCallId' in toolCall &&
|
||||||
|
c.toolCallId === toolCall.toolCallId
|
||||||
|
);
|
||||||
|
if (hasResult) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true; // No result found
|
||||||
|
});
|
||||||
|
|
||||||
|
if (orphanedToolCalls.length > 0) {
|
||||||
|
// Remove the entire assistant message with orphaned tool calls
|
||||||
|
console.info(`cleanupIncompleteToolCalls: Removing assistant message with ${orphanedToolCalls.length} orphaned tool calls`, {
|
||||||
|
messageIndex: i,
|
||||||
|
orphanedToolCallIds: orphanedToolCalls.map(tc => 'toolCallId' in tc ? tc.toolCallId : 'unknown'),
|
||||||
|
textContent: msg.content.filter(c => typeof c === 'object' && 'type' in c && c.type === 'text').map(c => 'text' in c ? c.text : ''),
|
||||||
|
});
|
||||||
|
cleaned.splice(i, 1);
|
||||||
|
break; // Only clean up the last problematic message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles retry logic after a RetryWithHealingError is caught
|
* Handles retry logic after a RetryWithHealingError is caught
|
||||||
* Returns the healed messages and whether to continue without healing
|
* Returns the healed messages and whether to continue without healing
|
||||||
|
@ -328,6 +384,23 @@ export async function handleRetryWithHealing(
|
||||||
// Determine the healing strategy
|
// Determine the healing strategy
|
||||||
const healingStrategy = determineHealingStrategy(retryableError, context);
|
const healingStrategy = determineHealingStrategy(retryableError, context);
|
||||||
|
|
||||||
|
// Special handling for 529 overloaded errors
|
||||||
|
if (retryableError.type === 'overloaded-error') {
|
||||||
|
const cleanedMessages = cleanupIncompleteToolCalls(currentMessages);
|
||||||
|
|
||||||
|
console.info(`${context.currentStep}: Cleaned incomplete tool calls after 529 error`, {
|
||||||
|
originalCount: currentMessages.length,
|
||||||
|
cleanedCount: cleanedMessages.length,
|
||||||
|
removed: currentMessages.length - cleanedMessages.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
healedMessages: cleanedMessages,
|
||||||
|
shouldContinueWithoutHealing: false,
|
||||||
|
backoffDelay: calculateBackoffDelay(retryCount) * 2, // Longer backoff for overload
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// For network/server errors, just retry without healing
|
// For network/server errors, just retry without healing
|
||||||
if (shouldRetryWithoutHealing(retryableError.type)) {
|
if (shouldRetryWithoutHealing(retryableError.type)) {
|
||||||
const backoffDelay =
|
const backoffDelay =
|
||||||
|
|
|
@ -7,6 +7,7 @@ export interface RetryableError {
|
||||||
| 'empty-response'
|
| 'empty-response'
|
||||||
| 'rate-limit'
|
| 'rate-limit'
|
||||||
| 'server-error'
|
| 'server-error'
|
||||||
|
| 'overloaded-error'
|
||||||
| 'network-timeout'
|
| 'network-timeout'
|
||||||
| 'stream-interruption'
|
| 'stream-interruption'
|
||||||
| 'json-parse-error'
|
| 'json-parse-error'
|
||||||
|
@ -14,6 +15,7 @@ export interface RetryableError {
|
||||||
| 'unknown-error';
|
| 'unknown-error';
|
||||||
originalError?: Error | unknown;
|
originalError?: Error | unknown;
|
||||||
healingMessage: CoreMessage;
|
healingMessage: CoreMessage;
|
||||||
|
requiresMessageCleanup?: boolean; // New optional flag for 529 errors
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -0,0 +1,204 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { CoreMessage } from 'ai';
|
||||||
|
import { cleanupIncompleteToolCalls } from '../../../src/utils/retry';
|
||||||
|
|
||||||
|
describe('cleanupIncompleteToolCalls', () => {
|
||||||
|
it('should remove assistant message with orphaned tool call', () => {
|
||||||
|
const messages: CoreMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: 'Hello'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Let me help' },
|
||||||
|
{ type: 'tool-call', toolCallId: '123', toolName: 'getTodo', args: {} }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
// No tool result - orphaned
|
||||||
|
];
|
||||||
|
|
||||||
|
const cleaned = cleanupIncompleteToolCalls(messages);
|
||||||
|
expect(cleaned).toHaveLength(1);
|
||||||
|
expect(cleaned[0]?.role).toBe('user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve complete tool call/result pairs', () => {
|
||||||
|
const messages: CoreMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'tool-call', toolCallId: '123', toolName: 'getTodo', args: {} }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'tool',
|
||||||
|
content: [
|
||||||
|
{ type: 'tool-result', toolCallId: '123', toolName: 'getTodo', result: { todo: 'test' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const cleaned = cleanupIncompleteToolCalls(messages);
|
||||||
|
expect(cleaned).toHaveLength(2);
|
||||||
|
expect(cleaned[0]?.role).toBe('assistant');
|
||||||
|
expect(cleaned[1]?.role).toBe('tool');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed complete and incomplete tool calls', () => {
|
||||||
|
const messages: CoreMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'tool-call', toolCallId: '123', toolName: 'getTodo', args: {} },
|
||||||
|
{ type: 'tool-call', toolCallId: '456', toolName: 'createTodo', args: { title: 'test' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'tool',
|
||||||
|
content: [
|
||||||
|
{ type: 'tool-result', toolCallId: '123', toolName: 'getTodo', result: { todo: 'test' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
// Missing result for toolCallId '456' - partially orphaned
|
||||||
|
];
|
||||||
|
|
||||||
|
const cleaned = cleanupIncompleteToolCalls(messages);
|
||||||
|
expect(cleaned).toHaveLength(0); // Both messages removed because assistant message had orphaned call
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple assistant messages and only clean the last one', () => {
|
||||||
|
const messages: CoreMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'tool-call', toolCallId: '111', toolName: 'getTodo', args: {} }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'tool',
|
||||||
|
content: [
|
||||||
|
{ type: 'tool-result', toolCallId: '111', toolName: 'getTodo', result: { todo: 'first' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: 'Another request'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Processing...' },
|
||||||
|
{ type: 'tool-call', toolCallId: '222', toolName: 'createTodo', args: { title: 'new' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
// No result for '222' - orphaned
|
||||||
|
];
|
||||||
|
|
||||||
|
const cleaned = cleanupIncompleteToolCalls(messages);
|
||||||
|
expect(cleaned).toHaveLength(3); // Only last assistant message removed
|
||||||
|
expect(cleaned[0]?.role).toBe('assistant');
|
||||||
|
expect(cleaned[1]?.role).toBe('tool');
|
||||||
|
expect(cleaned[2]?.role).toBe('user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle assistant messages with only text content', () => {
|
||||||
|
const messages: CoreMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: 'Hello'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Hi there, how can I help?'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const cleaned = cleanupIncompleteToolCalls(messages);
|
||||||
|
expect(cleaned).toHaveLength(2); // No changes
|
||||||
|
expect(cleaned).toEqual(messages);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty message array', () => {
|
||||||
|
const messages: CoreMessage[] = [];
|
||||||
|
const cleaned = cleanupIncompleteToolCalls(messages);
|
||||||
|
expect(cleaned).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle messages with mixed content types', () => {
|
||||||
|
const messages: CoreMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Let me search for that' },
|
||||||
|
{ type: 'tool-call', toolCallId: '789', toolName: 'search', args: { query: 'test' } },
|
||||||
|
{ type: 'text', text: 'Searching now...' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
// No tool result - orphaned
|
||||||
|
];
|
||||||
|
|
||||||
|
const cleaned = cleanupIncompleteToolCalls(messages);
|
||||||
|
expect(cleaned).toHaveLength(0); // Removed due to orphaned tool call
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle tool results that appear before their calls', () => {
|
||||||
|
const messages: CoreMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'tool',
|
||||||
|
content: [
|
||||||
|
{ type: 'tool-result', toolCallId: '999', toolName: 'getTodo', result: { error: 'Not found' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'tool-call', toolCallId: '999', toolName: 'getTodo', args: {} }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const cleaned = cleanupIncompleteToolCalls(messages);
|
||||||
|
expect(cleaned).toHaveLength(1); // Assistant message removed, tool result remains
|
||||||
|
expect(cleaned[0]?.role).toBe('tool');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple tool results in single message', () => {
|
||||||
|
const messages: CoreMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'tool-call', toolCallId: 'abc', toolName: 'getTodo', args: {} },
|
||||||
|
{ type: 'tool-call', toolCallId: 'def', toolName: 'createTodo', args: { title: 'new' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'tool',
|
||||||
|
content: [
|
||||||
|
{ type: 'tool-result', toolCallId: 'abc', toolName: 'getTodo', result: { todo: 'existing' } },
|
||||||
|
{ type: 'tool-result', toolCallId: 'def', toolName: 'createTodo', result: { id: 1, title: 'new' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const cleaned = cleanupIncompleteToolCalls(messages);
|
||||||
|
expect(cleaned).toHaveLength(2); // All complete, no changes
|
||||||
|
expect(cleaned).toEqual(messages);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle assistant message with no tool calls', () => {
|
||||||
|
const messages: CoreMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Here is your answer' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const cleaned = cleanupIncompleteToolCalls(messages);
|
||||||
|
expect(cleaned).toHaveLength(1); // No changes
|
||||||
|
expect(cleaned).toEqual(messages);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,203 @@
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import type { CoreMessage } from 'ai';
|
||||||
|
import { APICallError } from 'ai';
|
||||||
|
import { detectRetryableError, handleRetryWithHealing } from '../../../src/utils/retry';
|
||||||
|
import type { RetryableError, WorkflowContext } from '../../../src/utils/retry';
|
||||||
|
|
||||||
|
describe('529 error handling', () => {
|
||||||
|
it('should detect 529 as overloaded-error type', () => {
|
||||||
|
const error = new APICallError({
|
||||||
|
message: 'Server overloaded',
|
||||||
|
statusCode: 529,
|
||||||
|
responseHeaders: {},
|
||||||
|
responseBody: 'Server is overloaded, please try again',
|
||||||
|
url: 'https://api.example.com',
|
||||||
|
requestBodyValues: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = detectRetryableError(error);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.type).toBe('overloaded-error');
|
||||||
|
expect(result?.requiresMessageCleanup).toBe(true);
|
||||||
|
expect(result?.healingMessage.role).toBe('user');
|
||||||
|
expect(result?.healingMessage.content).toContain('Server overloaded (529)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply cleanup for overloaded errors in handleRetryWithHealing', async () => {
|
||||||
|
const messagesWithOrphan: CoreMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: 'Please analyze this data'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Let me analyze that for you' },
|
||||||
|
{ type: 'tool-call', toolCallId: 'tc-123', toolName: 'analyzeData', args: { data: 'test' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
// No tool result - connection interrupted
|
||||||
|
];
|
||||||
|
|
||||||
|
const retryableError: RetryableError = {
|
||||||
|
type: 'overloaded-error',
|
||||||
|
originalError: new Error('529'),
|
||||||
|
healingMessage: { role: 'user', content: 'Server overloaded (529). Retrying after cleanup...' },
|
||||||
|
requiresMessageCleanup: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const context: WorkflowContext = {
|
||||||
|
currentStep: 'analyst',
|
||||||
|
availableTools: new Set(['analyzeData', 'createMetrics'])
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await handleRetryWithHealing(
|
||||||
|
retryableError,
|
||||||
|
messagesWithOrphan,
|
||||||
|
0,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.healedMessages).toHaveLength(1); // Only user message remains
|
||||||
|
expect(result.healedMessages[0]?.role).toBe('user');
|
||||||
|
expect(result.shouldContinueWithoutHealing).toBe(false);
|
||||||
|
expect(result.backoffDelay).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle 529 differently from other 5xx errors', () => {
|
||||||
|
const error529 = new APICallError({
|
||||||
|
message: 'Server overloaded',
|
||||||
|
statusCode: 529,
|
||||||
|
responseHeaders: {},
|
||||||
|
responseBody: 'Overloaded',
|
||||||
|
url: 'https://api.example.com',
|
||||||
|
requestBodyValues: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const error500 = new APICallError({
|
||||||
|
message: 'Internal server error',
|
||||||
|
statusCode: 500,
|
||||||
|
responseHeaders: {},
|
||||||
|
responseBody: 'Internal error',
|
||||||
|
url: 'https://api.example.com',
|
||||||
|
requestBodyValues: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result529 = detectRetryableError(error529);
|
||||||
|
const result500 = detectRetryableError(error500);
|
||||||
|
|
||||||
|
expect(result529?.type).toBe('overloaded-error');
|
||||||
|
expect(result529?.requiresMessageCleanup).toBe(true);
|
||||||
|
|
||||||
|
expect(result500?.type).toBe('server-error');
|
||||||
|
expect(result500?.requiresMessageCleanup).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use longer backoff for 529 errors', async () => {
|
||||||
|
const messages: CoreMessage[] = [
|
||||||
|
{ role: 'user', content: 'Test' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const retryableError: RetryableError = {
|
||||||
|
type: 'overloaded-error',
|
||||||
|
originalError: new Error('529'),
|
||||||
|
healingMessage: { role: 'user', content: 'Retrying...' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const context: WorkflowContext = {
|
||||||
|
currentStep: 'analyst', // Changed from 'test' to valid value
|
||||||
|
availableTools: new Set()
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await handleRetryWithHealing(
|
||||||
|
retryableError,
|
||||||
|
messages,
|
||||||
|
1, // retryCount
|
||||||
|
context
|
||||||
|
);
|
||||||
|
|
||||||
|
// Backoff should be multiplied by 2 for overloaded errors
|
||||||
|
expect(result.backoffDelay).toBeGreaterThan(1000); // Base backoff is usually 1000ms for retry 1
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log cleanup details', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'info');
|
||||||
|
|
||||||
|
const messagesWithMultipleOrphans: CoreMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'tool-call', toolCallId: 'tc-1', toolName: 'tool1', args: {} },
|
||||||
|
{ type: 'tool-call', toolCallId: 'tc-2', toolName: 'tool2', args: {} }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const retryableError: RetryableError = {
|
||||||
|
type: 'overloaded-error',
|
||||||
|
originalError: new Error('529'),
|
||||||
|
healingMessage: { role: 'user', content: 'Retrying...' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const context: WorkflowContext = {
|
||||||
|
currentStep: 'analyst',
|
||||||
|
availableTools: new Set()
|
||||||
|
};
|
||||||
|
|
||||||
|
await handleRetryWithHealing(
|
||||||
|
retryableError,
|
||||||
|
messagesWithMultipleOrphans,
|
||||||
|
0,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
'analyst: Cleaned incomplete tool calls after 529 error',
|
||||||
|
expect.objectContaining({
|
||||||
|
originalCount: 1,
|
||||||
|
cleanedCount: 0,
|
||||||
|
removed: 1
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve messages when no cleanup is needed', async () => {
|
||||||
|
const completeMessages: CoreMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'tool-call', toolCallId: 'tc-123', toolName: 'getTodo', args: {} }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'tool',
|
||||||
|
content: [
|
||||||
|
{ type: 'tool-result', toolCallId: 'tc-123', toolName: 'getTodo', result: { todo: 'test' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const retryableError: RetryableError = {
|
||||||
|
type: 'overloaded-error',
|
||||||
|
originalError: new Error('529'),
|
||||||
|
healingMessage: { role: 'user', content: 'Retrying...' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const context: WorkflowContext = {
|
||||||
|
currentStep: 'analyst',
|
||||||
|
availableTools: new Set()
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await handleRetryWithHealing(
|
||||||
|
retryableError,
|
||||||
|
completeMessages,
|
||||||
|
0,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.healedMessages).toHaveLength(2); // No cleanup needed
|
||||||
|
expect(result.healedMessages).toEqual(completeMessages);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue