feat: robust error handling and type safety

Add type-safe error helpers and remove 'as any' casts.

Changes:
- Add isAbortError() helper for checking abort signals
- Add getEventDetail() helper for CustomEvent extraction
- Fix variable naming in isRetryable() (isAborted vs isAbortError)
- Use proper instanceof checks instead of casting
- Add proper interface for normalized response data
- Type-safe tool execution error messages
This commit is contained in:
linked-danis
2026-03-12 14:07:45 +01:00
parent 734ec47d0b
commit f45f9d020e
5 changed files with 79 additions and 22 deletions
+28 -12
View File
@@ -20,7 +20,15 @@ import type {
MacroToolInput,
MacroToolResult,
} from './types'
import { assert, fetchLlmsTxt, normalizeResponse, uid, waitFor } from './utils'
import {
assert,
fetchLlmsTxt,
getEventDetail,
isAbortError,
normalizeResponse,
uid,
waitFor,
} from './utils'
export { tool, type PageAgentTool } from './tools'
export type * from './types'
@@ -104,27 +112,35 @@ export class PageAgentCore extends EventTarget {
// Listen to LLM retry events
this.#llm.addEventListener('retry', (e) => {
const { attempt, maxAttempts } = (e as CustomEvent).detail
this.#emitActivity({ type: 'retrying', attempt, maxAttempts })
const detail = getEventDetail<{ attempt: number; maxAttempts: number }>(e)
if (!detail) return
this.#emitActivity({
type: 'retrying',
attempt: detail.attempt,
maxAttempts: detail.maxAttempts,
})
// Also push to history for panel rendering
this.history.push({
type: 'retry',
message: `LLM retry attempt ${attempt} of ${maxAttempts}`,
attempt,
maxAttempts,
message: `LLM retry attempt ${detail.attempt} of ${detail.maxAttempts}`,
attempt: detail.attempt,
maxAttempts: detail.maxAttempts,
})
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)
const detail = getEventDetail<{ error: unknown }>(e)
if (!detail) return
const error = detail.error
if (isAbortError(error)) return
const message = error instanceof Error ? error.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,
rawResponse:
error instanceof Error ? (error as InvokeError).rawResponse : undefined,
})
this.#emitHistoryChange()
})
@@ -314,10 +330,10 @@ export class PageAgentCore extends EventTarget {
}
} catch (error: unknown) {
console.groupEnd() // to prevent nested groups
const isAbortError = (error as any)?.rawError?.name === 'AbortError'
const isAborted = isAbortError(error)
console.error('Task failed', error)
const errorMessage = isAbortError ? 'Task stopped' : String(error)
const errorMessage = isAborted ? 'Task stopped' : String(error)
this.#emitActivity({ type: 'error', message: errorMessage })
this.history.push({ type: 'error', message: errorMessage, rawResponse: error })
this.#emitHistoryChange()
+27
View File
@@ -101,3 +101,30 @@ export function assert(condition: unknown, message?: string, silent?: boolean):
throw new Error(errorMessage)
}
}
/**
* Check if an error is an AbortError (from AbortController)
* Handles various forms: Error with name 'AbortError', or rawError property
*/
export function isAbortError(error: unknown): boolean {
if (error instanceof Error && error.name === 'AbortError') return true
if (
typeof error === 'object' &&
error !== null &&
'rawError' in error &&
(error as { rawError?: Error }).rawError?.name === 'AbortError'
)
return true
return false
}
/**
* Safely extract detail from CustomEvent
* @returns The detail object or null if not a CustomEvent
*/
export function getEventDetail<T>(event: Event): T | null {
if (event instanceof CustomEvent) {
return event.detail as T
}
return null
}
+13 -5
View File
@@ -56,9 +56,9 @@ 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)
const isAborted = error instanceof Error && error.name === 'AbortError'
const errorMessage = isAborted ? 'Network request aborted' : 'Network request failed'
if (!isAborted) console.error(error)
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, errorMessage, error)
}
@@ -135,7 +135,15 @@ export class OpenAIClient implements LLMClient {
// Apply normalizeResponse if provided (for fixing format issues automatically)
const normalizedData = options?.normalizeResponse ? options.normalizeResponse(data) : data
const normalizedChoice = (normalizedData as any).choices?.[0]
const normalizedChoice = (
normalizedData as {
choices?: {
message?: {
tool_calls?: { function?: { name?: string; arguments?: string } }[]
}
}[]
}
)?.choices?.[0]
// Get tool name from response
const toolCallName = normalizedChoice?.message?.tool_calls?.[0]?.function?.name
@@ -201,7 +209,7 @@ export class OpenAIClient implements LLMClient {
} catch (e) {
throw new InvokeError(
InvokeErrorType.TOOL_EXECUTION_ERROR,
`Tool execution failed: ${(e as Error).message}`,
`Tool execution failed: ${e instanceof Error ? e.message : String(e)}`,
e,
data
)
+2 -2
View File
@@ -40,8 +40,8 @@ export class InvokeError extends Error {
}
private isRetryable(type: InvokeErrorType, rawError?: unknown): boolean {
const isAbortError = (rawError as any)?.name === 'AbortError'
if (isAbortError) return false
const isAborted = rawError instanceof Error && rawError.name === 'AbortError'
if (isAborted) return false
const retryableTypes: InvokeErrorType[] = [
InvokeErrorType.NETWORK_ERROR,
+9 -3
View File
@@ -93,15 +93,21 @@ async function withRetry<T>(
return await fn()
} catch (error: unknown) {
// do not retry if aborted by user
if ((error as any)?.rawError?.name === 'AbortError') throw error
if (
error instanceof InvokeError &&
error.rawError instanceof Error &&
error.rawError.name === 'AbortError'
) {
throw error
}
console.error(error)
settings.onError(error as Error)
settings.onError(error instanceof Error ? error : new Error(String(error)))
// do not retry if error is not retryable (InvokeError)
if (error instanceof InvokeError && !error.retryable) throw error
lastError = error as Error
lastError = error instanceof Error ? error : new Error(String(error))
attempt++
await new Promise((resolve) => setTimeout(resolve, 100))