From fb8de08c39b54505b383e0cda9af4ff64dbcba85 Mon Sep 17 00:00:00 2001 From: Simon <10131203+gaomeng1900@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:39:28 +0800 Subject: [PATCH 1/4] fix(llms): fix abort handling and clean up retry logic --- packages/core/src/PageAgentCore.ts | 30 +++++---------- packages/llms/src/OpenAIClient.ts | 21 +++++++---- packages/llms/src/errors.ts | 33 ++++++++--------- packages/llms/src/index.ts | 59 +++++++++--------------------- 4 files changed, 57 insertions(+), 86 deletions(-) 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! } From fd12fb9f1b278ab6908169547c60fc0976e9a46e Mon Sep 17 00:00:00 2001 From: Simon <10131203+gaomeng1900@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:00:33 +0800 Subject: [PATCH 2/4] refactor(llms): split AbortError out of InvokeError --- packages/core/src/PageAgentCore.ts | 1 - packages/llms/src/OpenAIClient.ts | 11 +++-------- packages/llms/src/errors.ts | 7 ++----- packages/llms/src/index.ts | 1 + 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/core/src/PageAgentCore.ts b/packages/core/src/PageAgentCore.ts index 5711263..aa32146 100644 --- a/packages/core/src/PageAgentCore.ts +++ b/packages/core/src/PageAgentCore.ts @@ -368,7 +368,6 @@ export class PageAgentCore extends EventTarget { description: 'You MUST call this tool every step!', inputSchema: macroToolSchema as z.ZodType, execute: async (input: MacroToolInput): Promise => { - // abort — throws DOMException whose .name === 'AbortError' this.#abortController.signal.throwIfAborted() console.log(chalk.blue.bold('MacroTool input'), input) diff --git a/packages/llms/src/OpenAIClient.ts b/packages/llms/src/OpenAIClient.ts index fbc8d2c..fd40f38 100644 --- a/packages/llms/src/OpenAIClient.ts +++ b/packages/llms/src/OpenAIClient.ts @@ -25,8 +25,7 @@ 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') + abortSignal?.throwIfAborted() // 1. Convert tools to OpenAI format const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t)) @@ -73,9 +72,7 @@ export class OpenAIClient implements LLMClient { signal: abortSignal, }) } catch (error: unknown) { - if ((error as any)?.name === 'AbortError') { - throw new InvokeError(InvokeErrorTypes.ABORTED, 'Aborted', error) - } + if ((error as any)?.name === 'AbortError') throw error console.error(error) throw new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'Network request failed', error) } @@ -217,9 +214,7 @@ export class OpenAIClient implements LLMClient { try { toolResult = await tool.execute(toolInput) } catch (error: unknown) { - if ((error as any)?.name === 'AbortError') { - throw new InvokeError(InvokeErrorTypes.ABORTED, 'Aborted', error) - } + if ((error as any)?.name === 'AbortError') throw error throw new InvokeError( InvokeErrorTypes.TOOL_EXECUTION_ERROR, `Tool execution failed: ${(error as Error)?.message}`, diff --git a/packages/llms/src/errors.ts b/packages/llms/src/errors.ts index 5659f3c..ee4f1d6 100644 --- a/packages/llms/src/errors.ts +++ b/packages/llms/src/errors.ts @@ -1,5 +1,5 @@ /** - * Error types and error handling for LLM invocations + * Error types and error handling for LLM invocations. */ export const InvokeErrorTypes = { @@ -14,7 +14,6 @@ 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 @@ -44,9 +43,7 @@ export class InvokeError extends Error { constructor(type: InvokeErrorType, message: string, rawError?: unknown, rawResponse?: unknown) { super(message) - // 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.name = 'InvokeError' this.type = type this.retryable = RETRYABLE_TYPES.includes(type) this.rawError = rawError diff --git a/packages/llms/src/index.ts b/packages/llms/src/index.ts index 439c77e..be5e7e7 100644 --- a/packages/llms/src/index.ts +++ b/packages/llms/src/index.ts @@ -78,6 +78,7 @@ async function withRetry( try { return await fn() } catch (error: unknown) { + if ((error as any)?.name === 'AbortError') throw error if (error instanceof InvokeError && !error.retryable) throw error attempt++ if (attempt > settings.maxRetries) throw error From 823195ad94ce3ae48676480f30c4ecaaa37c8e5e Mon Sep 17 00:00:00 2001 From: Simon <10131203+gaomeng1900@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:14:12 +0800 Subject: [PATCH 3/4] fix(llms): add error handling for invalid JSON responses and schema validation --- packages/llms/src/OpenAIClient.ts | 15 ++++++++++++--- packages/llms/src/errors.ts | 4 ++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/llms/src/OpenAIClient.ts b/packages/llms/src/OpenAIClient.ts index fd40f38..57be760 100644 --- a/packages/llms/src/OpenAIClient.ts +++ b/packages/llms/src/OpenAIClient.ts @@ -112,11 +112,20 @@ export class OpenAIClient implements LLMClient { } // 4. Parse and validate response - const data = await response.json() + let data: any + try { + data = await response.json() + } catch (error) { + throw new InvokeError( + InvokeErrorTypes.INVALID_RESPONSE, + 'Response body is not valid JSON', + error + ) + } const choice = data.choices?.[0] if (!choice) { - throw new InvokeError(InvokeErrorTypes.UNKNOWN, 'No choices in response', data) + throw new InvokeError(InvokeErrorTypes.INVALID_SCHEMA, 'No choices in response', data) } // Check finish_reason @@ -141,7 +150,7 @@ export class OpenAIClient implements LLMClient { ) default: throw new InvokeError( - InvokeErrorTypes.UNKNOWN, + InvokeErrorTypes.INVALID_SCHEMA, `Unexpected finish_reason: ${choice.finish_reason}`, undefined, data diff --git a/packages/llms/src/errors.ts b/packages/llms/src/errors.ts index ee4f1d6..936693e 100644 --- a/packages/llms/src/errors.ts +++ b/packages/llms/src/errors.ts @@ -10,6 +10,8 @@ export const InvokeErrorTypes = { NO_TOOL_CALL: 'no_tool_call', // Model did not call tool INVALID_TOOL_ARGS: 'invalid_tool_args', // Tool args don't match schema TOOL_EXECUTION_ERROR: 'tool_execution_error', // Tool execution error + INVALID_RESPONSE: 'invalid_response', // Response body is not valid JSON + INVALID_SCHEMA: 'invalid_schema', // Response is valid JSON but doesn't match expected shape UNKNOWN: 'unknown', @@ -29,6 +31,8 @@ const RETRYABLE_TYPES: readonly InvokeErrorType[] = [ InvokeErrorTypes.NO_TOOL_CALL, InvokeErrorTypes.INVALID_TOOL_ARGS, InvokeErrorTypes.TOOL_EXECUTION_ERROR, + InvokeErrorTypes.INVALID_RESPONSE, + InvokeErrorTypes.INVALID_SCHEMA, InvokeErrorTypes.UNKNOWN, ] From 9891e0221555d0633c94762810859069d8808ed6 Mon Sep 17 00:00:00 2001 From: Simon <10131203+gaomeng1900@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:31:01 +0800 Subject: [PATCH 4/4] fix(llms): handle AbortError during .json() --- packages/llms/src/OpenAIClient.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/llms/src/OpenAIClient.ts b/packages/llms/src/OpenAIClient.ts index 57be760..c3b45f9 100644 --- a/packages/llms/src/OpenAIClient.ts +++ b/packages/llms/src/OpenAIClient.ts @@ -79,9 +79,13 @@ export class OpenAIClient implements LLMClient { // 3. Handle HTTP errors if (!response.ok) { - const errorData = await response.json().catch() - const errorMessage = - (errorData as { error?: { message?: string } }).error?.message || response.statusText + let errorData: any + try { + errorData = await response.json() + } catch (error) { + if ((error as any)?.name === 'AbortError') throw error + } + const errorMessage = errorData?.error?.message || response.statusText if (response.status === 401 || response.status === 403) { throw new InvokeError( @@ -116,6 +120,7 @@ export class OpenAIClient implements LLMClient { try { data = await response.json() } catch (error) { + if ((error as any)?.name === 'AbortError') throw error throw new InvokeError( InvokeErrorTypes.INVALID_RESPONSE, 'Response body is not valid JSON',