Merge pull request #527 from alibaba/fix/abort-error-contract

improve error handling
This commit is contained in:
Simon
2026-06-03 23:40:48 +08:00
committed by GitHub
4 changed files with 73 additions and 92 deletions

View File

@@ -103,11 +103,14 @@ export class PageAgentCore extends EventTarget {
this.tools = new Map(tools) this.tools = new Map(tools)
this.pageController = config.pageController this.pageController = config.pageController
// Listen to LLM retry events
this.#llm.addEventListener('retry', (e) => { 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 }) 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({ this.history.push({
type: 'retry', type: 'retry',
message: `LLM retry attempt ${attempt} of ${maxAttempts}`, message: `LLM retry attempt ${attempt} of ${maxAttempts}`,
@@ -116,19 +119,6 @@ export class PageAgentCore extends EventTarget {
}) })
this.#emitHistoryChange() 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) { if (this.config.customTools) {
for (const [name, tool] of Object.entries(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) { } catch (error: unknown) {
console.groupEnd() // to prevent nested groups 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) const errorMessage = isAbortError ? 'Task stopped' : String(error)
this.#emitActivity({ type: 'error', message: errorMessage }) this.#emitActivity({ type: 'error', message: errorMessage })
this.history.push({ type: 'error', message: errorMessage, rawResponse: error }) this.history.push({ type: 'error', message: errorMessage, rawResponse: error })
@@ -378,8 +368,7 @@ export class PageAgentCore extends EventTarget {
description: 'You MUST call this tool every step!', description: 'You MUST call this tool every step!',
inputSchema: macroToolSchema as z.ZodType<MacroToolInput>, inputSchema: macroToolSchema as z.ZodType<MacroToolInput>,
execute: async (input: MacroToolInput): Promise<MacroToolResult> => { execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
// abort this.#abortController.signal.throwIfAborted()
if (this.#abortController.signal.aborted) throw new Error('AbortError')
console.log(chalk.blue.bold('MacroTool input'), input) console.log(chalk.blue.bold('MacroTool input'), input)
const action = input.action const action = input.action

View File

@@ -25,6 +25,8 @@ export class OpenAIClient implements LLMClient {
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
options?: InvokeOptions options?: InvokeOptions
): Promise<InvokeResult> { ): Promise<InvokeResult> {
abortSignal?.throwIfAborted()
// 1. Convert tools to OpenAI format // 1. Convert tools to OpenAI format
const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t)) const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t))
@@ -70,17 +72,20 @@ export class OpenAIClient implements LLMClient {
signal: abortSignal, signal: abortSignal,
}) })
} catch (error: unknown) { } catch (error: unknown) {
const isAbortError = (error as any)?.name === 'AbortError' if ((error as any)?.name === 'AbortError') throw error
const errorMessage = isAbortError ? 'Network request aborted' : 'Network request failed' console.error(error)
if (!isAbortError) console.error(error) throw new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'Network request failed', error)
throw new InvokeError(InvokeErrorTypes.NETWORK_ERROR, errorMessage, error)
} }
// 3. Handle HTTP errors // 3. Handle HTTP errors
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch() let errorData: any
const errorMessage = try {
(errorData as { error?: { message?: string } }).error?.message || response.statusText 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) { if (response.status === 401 || response.status === 403) {
throw new InvokeError( throw new InvokeError(
@@ -111,11 +116,21 @@ export class OpenAIClient implements LLMClient {
} }
// 4. Parse and validate response // 4. Parse and validate response
const data = await response.json() let data: any
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',
error
)
}
const choice = data.choices?.[0] const choice = data.choices?.[0]
if (!choice) { 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 // Check finish_reason
@@ -140,7 +155,7 @@ export class OpenAIClient implements LLMClient {
) )
default: default:
throw new InvokeError( throw new InvokeError(
InvokeErrorTypes.UNKNOWN, InvokeErrorTypes.INVALID_SCHEMA,
`Unexpected finish_reason: ${choice.finish_reason}`, `Unexpected finish_reason: ${choice.finish_reason}`,
undefined, undefined,
data data
@@ -212,11 +227,12 @@ export class OpenAIClient implements LLMClient {
let toolResult: unknown let toolResult: unknown
try { try {
toolResult = await tool.execute(toolInput) toolResult = await tool.execute(toolInput)
} catch (e) { } catch (error: unknown) {
if ((error as any)?.name === 'AbortError') throw error
throw new InvokeError( throw new InvokeError(
InvokeErrorTypes.TOOL_EXECUTION_ERROR, InvokeErrorTypes.TOOL_EXECUTION_ERROR,
`Tool execution failed: ${(e as Error).message}`, `Tool execution failed: ${(error as Error)?.message}`,
e, error,
data data
) )
} }

View File

@@ -1,5 +1,5 @@
/** /**
* Error types and error handling for LLM invocations * Error types and error handling for LLM invocations.
*/ */
export const InvokeErrorTypes = { export const InvokeErrorTypes = {
@@ -10,6 +10,8 @@ export const InvokeErrorTypes = {
NO_TOOL_CALL: 'no_tool_call', // Model did not call tool NO_TOOL_CALL: 'no_tool_call', // Model did not call tool
INVALID_TOOL_ARGS: 'invalid_tool_args', // Tool args don't match schema INVALID_TOOL_ARGS: 'invalid_tool_args', // Tool args don't match schema
TOOL_EXECUTION_ERROR: 'tool_execution_error', // Tool execution error 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', UNKNOWN: 'unknown',
@@ -22,6 +24,18 @@ export const InvokeErrorTypes = {
type InvokeErrorType = (typeof InvokeErrorTypes)[keyof typeof 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.INVALID_RESPONSE,
InvokeErrorTypes.INVALID_SCHEMA,
InvokeErrorTypes.UNKNOWN,
]
export class InvokeError extends Error { export class InvokeError extends Error {
type: InvokeErrorType type: InvokeErrorType
retryable: boolean retryable: boolean
@@ -35,24 +49,8 @@ export class InvokeError extends Error {
super(message) super(message)
this.name = 'InvokeError' this.name = 'InvokeError'
this.type = type this.type = type
this.retryable = this.isRetryable(type, rawError) this.retryable = RETRYABLE_TYPES.includes(type)
this.rawError = rawError this.rawError = rawError
this.rawResponse = rawResponse 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)
}
} }

View File

@@ -50,65 +50,43 @@ export class LLM extends EventTarget {
abortSignal: AbortSignal, abortSignal: AbortSignal,
options?: InvokeOptions options?: InvokeOptions
): Promise<InvokeResult> { ): Promise<InvokeResult> {
return await withRetry( return await withRetry(async () => this.client.invoke(messages, tools, abortSignal, options), {
async () => { maxRetries: this.config.maxRetries,
// in case user aborted before invoking onRetry: (attempt, lastError) => {
if (abortSignal.aborted) throw new Error('AbortError') this.dispatchEvent(
new CustomEvent('retry', {
const result = await this.client.invoke(messages, tools, abortSignal, options) detail: { attempt, maxAttempts: this.config.maxRetries, lastError },
})
return result )
}, },
// 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<T>( async function withRetry<T>(
fn: () => Promise<T>, fn: () => Promise<T>,
settings: { settings: {
maxRetries: number maxRetries: number
onRetry: (attempt: number) => void onRetry: (attempt: number, lastError: Error) => void
onError: (error: Error) => void
} }
): Promise<T> { ): Promise<T> {
let attempt = 0 let attempt = 0
let lastError: Error | null = null while (true) {
while (attempt <= settings.maxRetries) {
if (attempt > 0) {
settings.onRetry(attempt)
await new Promise((resolve) => setTimeout(resolve, 100))
}
try { try {
return await fn() return await fn()
} catch (error: unknown) { } catch (error: unknown) {
// do not retry if aborted by user if ((error as any)?.name === 'AbortError') throw error
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 if (error instanceof InvokeError && !error.retryable) throw error
lastError = error as Error
attempt++ 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)) await new Promise((resolve) => setTimeout(resolve, 100))
} }
} }
throw lastError!
} }