diff --git a/packages/core/src/PageAgentCore.ts b/packages/core/src/PageAgentCore.ts index 12611dc..5711263 100644 --- a/packages/core/src/PageAgentCore.ts +++ b/packages/core/src/PageAgentCore.ts @@ -103,11 +103,14 @@ export class PageAgentCore extends EventTarget { this.tools = new Map(tools) this.pageController = config.pageController - // Listen to LLM retry events this.#llm.addEventListener('retry', (e) => { - const { attempt, maxAttempts } = (e as CustomEvent).detail + const { attempt, maxAttempts, lastError } = (e as CustomEvent).detail this.#emitActivity({ type: 'retrying', attempt, maxAttempts }) - // Also push to history for panel rendering + this.history.push({ + type: 'error', + message: String(lastError), + rawResponse: (lastError as InvokeError).rawResponse, + }) this.history.push({ type: 'retry', message: `LLM retry attempt ${attempt} of ${maxAttempts}`, @@ -116,19 +119,6 @@ export class PageAgentCore extends EventTarget { }) this.#emitHistoryChange() }) - this.#llm.addEventListener('error', (e) => { - const error = (e as CustomEvent).detail.error as Error | InvokeError - if ((error as any)?.rawError?.name === 'AbortError') return - const message = String(error) - this.#emitActivity({ type: 'error', message }) - // Also push to history for panel rendering - this.history.push({ - type: 'error', - message, - rawResponse: (error as InvokeError).rawResponse, - }) - this.#emitHistoryChange() - }) if (this.config.customTools) { for (const [name, tool] of Object.entries(this.config.customTools)) { @@ -312,9 +302,9 @@ export class PageAgentCore extends EventTarget { } } catch (error: unknown) { console.groupEnd() // to prevent nested groups - const isAbortError = (error as any)?.rawError?.name === 'AbortError' + const isAbortError = (error as any)?.name === 'AbortError' - console.error('Task failed', error) + if (!isAbortError) console.error('Task failed', error) const errorMessage = isAbortError ? 'Task stopped' : String(error) this.#emitActivity({ type: 'error', message: errorMessage }) this.history.push({ type: 'error', message: errorMessage, rawResponse: error }) @@ -378,8 +368,8 @@ export class PageAgentCore extends EventTarget { description: 'You MUST call this tool every step!', inputSchema: macroToolSchema as z.ZodType, execute: async (input: MacroToolInput): Promise => { - // abort - if (this.#abortController.signal.aborted) throw new Error('AbortError') + // abort — throws DOMException whose .name === 'AbortError' + this.#abortController.signal.throwIfAborted() console.log(chalk.blue.bold('MacroTool input'), input) const action = input.action diff --git a/packages/llms/src/OpenAIClient.ts b/packages/llms/src/OpenAIClient.ts index b587953..fbc8d2c 100644 --- a/packages/llms/src/OpenAIClient.ts +++ b/packages/llms/src/OpenAIClient.ts @@ -25,6 +25,9 @@ export class OpenAIClient implements LLMClient { abortSignal?: AbortSignal, options?: InvokeOptions ): Promise { + // in case user aborted before invoking + if (abortSignal?.aborted) throw new InvokeError(InvokeErrorTypes.ABORTED, 'Aborted') + // 1. Convert tools to OpenAI format const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t)) @@ -70,10 +73,11 @@ export class OpenAIClient implements LLMClient { signal: abortSignal, }) } catch (error: unknown) { - const isAbortError = (error as any)?.name === 'AbortError' - const errorMessage = isAbortError ? 'Network request aborted' : 'Network request failed' - if (!isAbortError) console.error(error) - throw new InvokeError(InvokeErrorTypes.NETWORK_ERROR, errorMessage, error) + if ((error as any)?.name === 'AbortError') { + throw new InvokeError(InvokeErrorTypes.ABORTED, 'Aborted', error) + } + console.error(error) + throw new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'Network request failed', error) } // 3. Handle HTTP errors @@ -212,11 +216,14 @@ export class OpenAIClient implements LLMClient { let toolResult: unknown try { toolResult = await tool.execute(toolInput) - } catch (e) { + } catch (error: unknown) { + if ((error as any)?.name === 'AbortError') { + throw new InvokeError(InvokeErrorTypes.ABORTED, 'Aborted', error) + } throw new InvokeError( InvokeErrorTypes.TOOL_EXECUTION_ERROR, - `Tool execution failed: ${(e as Error).message}`, - e, + `Tool execution failed: ${(error as Error)?.message}`, + error, data ) } diff --git a/packages/llms/src/errors.ts b/packages/llms/src/errors.ts index 42d264f..5659f3c 100644 --- a/packages/llms/src/errors.ts +++ b/packages/llms/src/errors.ts @@ -14,6 +14,7 @@ export const InvokeErrorTypes = { UNKNOWN: 'unknown', // Non-retryable + ABORTED: 'aborted', // User aborted via AbortSignal — instance has name='AbortError' CONFIG_ERROR: 'config_error', // Invalid local configuration or hook AUTH_ERROR: 'auth_error', // Authentication failed CONTEXT_LENGTH: 'context_length', // Prompt too long @@ -22,6 +23,16 @@ export const InvokeErrorTypes = { type InvokeErrorType = (typeof InvokeErrorTypes)[keyof typeof InvokeErrorTypes] +const RETRYABLE_TYPES: readonly InvokeErrorType[] = [ + InvokeErrorTypes.NETWORK_ERROR, + InvokeErrorTypes.RATE_LIMIT, + InvokeErrorTypes.SERVER_ERROR, + InvokeErrorTypes.NO_TOOL_CALL, + InvokeErrorTypes.INVALID_TOOL_ARGS, + InvokeErrorTypes.TOOL_EXECUTION_ERROR, + InvokeErrorTypes.UNKNOWN, +] + export class InvokeError extends Error { type: InvokeErrorType retryable: boolean @@ -33,26 +44,12 @@ export class InvokeError extends Error { constructor(type: InvokeErrorType, message: string, rawError?: unknown, rawResponse?: unknown) { super(message) - this.name = 'InvokeError' + // ABORTED conforms to the web platform convention so any consumer using + // `err.name === 'AbortError'` (including native DOMException handlers) Just Works. + this.name = type === InvokeErrorTypes.ABORTED ? 'AbortError' : 'InvokeError' this.type = type - this.retryable = this.isRetryable(type, rawError) + this.retryable = RETRYABLE_TYPES.includes(type) this.rawError = rawError this.rawResponse = rawResponse } - - private isRetryable(type: InvokeErrorType, rawError?: unknown): boolean { - const isAbortError = (rawError as any)?.name === 'AbortError' - if (isAbortError) return false - - const retryableTypes: InvokeErrorType[] = [ - InvokeErrorTypes.NETWORK_ERROR, - InvokeErrorTypes.RATE_LIMIT, - InvokeErrorTypes.SERVER_ERROR, - InvokeErrorTypes.NO_TOOL_CALL, - InvokeErrorTypes.INVALID_TOOL_ARGS, - InvokeErrorTypes.TOOL_EXECUTION_ERROR, - InvokeErrorTypes.UNKNOWN, - ] - return retryableTypes.includes(type) - } } diff --git a/packages/llms/src/index.ts b/packages/llms/src/index.ts index 63a94a9..439c77e 100644 --- a/packages/llms/src/index.ts +++ b/packages/llms/src/index.ts @@ -50,65 +50,42 @@ export class LLM extends EventTarget { abortSignal: AbortSignal, options?: InvokeOptions ): Promise { - return await withRetry( - async () => { - // in case user aborted before invoking - if (abortSignal.aborted) throw new Error('AbortError') - - const result = await this.client.invoke(messages, tools, abortSignal, options) - - return result + return await withRetry(async () => this.client.invoke(messages, tools, abortSignal, options), { + maxRetries: this.config.maxRetries, + onRetry: (attempt, lastError) => { + this.dispatchEvent( + new CustomEvent('retry', { + detail: { attempt, maxAttempts: this.config.maxRetries, lastError }, + }) + ) }, - // retry settings - { - maxRetries: this.config.maxRetries, - onRetry: (attempt: number) => { - this.dispatchEvent( - new CustomEvent('retry', { detail: { attempt, maxAttempts: this.config.maxRetries } }) - ) - }, - onError: (error: Error) => { - this.dispatchEvent(new CustomEvent('error', { detail: { error } })) - }, - } - ) + }) } } +/** + * Retry a function until it succeeds or reaches the maximum number of retries. + */ async function withRetry( fn: () => Promise, settings: { maxRetries: number - onRetry: (attempt: number) => void - onError: (error: Error) => void + onRetry: (attempt: number, lastError: Error) => void } ): Promise { let attempt = 0 - let lastError: Error | null = null - while (attempt <= settings.maxRetries) { - if (attempt > 0) { - settings.onRetry(attempt) - await new Promise((resolve) => setTimeout(resolve, 100)) - } - + while (true) { try { return await fn() } catch (error: unknown) { - // do not retry if aborted by user - if ((error as any)?.rawError?.name === 'AbortError') throw error - - console.error(error) - settings.onError(error as Error) - - // do not retry if error is not retryable (InvokeError) if (error instanceof InvokeError && !error.retryable) throw error - - lastError = error as Error attempt++ + if (attempt > settings.maxRetries) throw error + + console.debug('[LLM] retryable failure, will retry:', error) + settings.onRetry(attempt, error as Error) await new Promise((resolve) => setTimeout(resolve, 100)) } } - - throw lastError! }