Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 19 additions & 13 deletions packages/core/src/PageAgentCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ 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'
Expand Down Expand Up @@ -104,27 +104,30 @@ 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,
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rawResponse is only present on InvokeError, but the current guard uses error instanceof Error and then casts to InvokeError. For plain Error instances this will always produce undefined and hides the intent. Prefer checking error instanceof InvokeError before reading .rawResponse.

Suggested change
rawResponse: error instanceof Error ? (error as InvokeError).rawResponse : undefined,
rawResponse: error instanceof InvokeError ? error.rawResponse : undefined,

Copilot uses AI. Check for mistakes.
})
this.#emitHistoryChange()
})
Expand Down Expand Up @@ -187,9 +190,12 @@ export class PageAgentCore extends EventTarget {

/** Stop the current task. Agent remains reusable. */
stop() {
if (this.#abortController.signal.aborted) return // Already stopped
this.pageController.cleanUpHighlights()
this.pageController.hideMask()
this.#abortController.abort()
this.#setStatus('idle')
this.#abortController = new AbortController()
}

async execute(task: string): Promise<ExecutionResult> {
Expand Down Expand Up @@ -311,10 +317,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()
Expand Down Expand Up @@ -343,7 +349,7 @@ export class PageAgentCore extends EventTarget {
return result
}

await waitFor(0.4) // @TODO: configurable
await waitFor(this.config.stepDelay ?? 0.4)
}
}

Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,12 @@ export interface AgentConfig extends LLMConfig {
* @experimental Use with caution - incorrect prompts may break agent behavior.
*/
customSystemPrompt?: string

/**
* Delay between steps in seconds.
* @default 0.4
*/
stepDelay?: number
}

/**
Expand Down
34 changes: 33 additions & 1 deletion packages/core/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,12 @@ const llmsTxtCache = new Map<string, string | null>()

/** Fetch /llms.txt for a URL's origin. Cached per origin, `null` = tried and not found. */
export async function fetchLlmsTxt(url: string): Promise<string | null> {
const origin = new URL(url).origin
let origin: string
try {
origin = new URL(url).origin
} catch {
return null // Invalid URL
}
if (llmsTxtCache.has(origin)) return llmsTxtCache.get(origin)!

const endpoint = `${origin}/llms.txt`
Expand Down Expand Up @@ -101,3 +106,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
Comment on lines +112 to +122
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isAbortError() currently checks error.name === 'AbortError', but this repo also throws new Error('AbortError') in a few places (message is 'AbortError', name is still 'Error'). Those cases won't be detected, so aborts may be treated as real failures (and may trigger retries/logging). Consider also checking error instanceof Error && error.message === 'AbortError', and similarly for rawError when it’s an object with a name/message field rather than strictly an Error instance.

Suggested change
* 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
* Handles various forms:
* - Error with name 'AbortError'
* - Error with message 'AbortError'
* - Plain objects with name/message 'AbortError'
* - Wrapped errors via a `rawError` property
*/
export function isAbortError(error: unknown): boolean {
// Direct Error instance: check both name and message
if (error instanceof Error) {
if (error.name === 'AbortError' || error.message === 'AbortError') {
return true
}
}
// Error-like objects or wrappers (including { rawError: ... })
if (typeof error === 'object' && error !== null) {
const maybeError = error as { name?: unknown; message?: unknown; rawError?: unknown }
// Plain object with AbortError-like fields
if (maybeError.name === 'AbortError' || maybeError.message === 'AbortError') {
return true
}
// Wrapped error under `rawError`
if ('rawError' in maybeError && maybeError.rawError != null) {
const raw = maybeError.rawError as unknown
if (raw instanceof Error) {
if (raw.name === 'AbortError' || raw.message === 'AbortError') {
return true
}
} else if (typeof raw === 'object') {
const rawObj = raw as { name?: unknown; message?: unknown }
if (rawObj.name === 'AbortError' || rawObj.message === 'AbortError') {
return true
}
}
}
}

Copilot uses AI. Check for mistakes.
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
}
8 changes: 4 additions & 4 deletions packages/llms/src/OpenAIClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,15 @@
signal: abortSignal,
})
} catch (error: unknown) {
const isAbortError = (error as any)?.name === 'AbortError'
const isAborted = error instanceof Error && error.name === 'AbortError'
const errorMessage = isAbortError ? 'Network request aborted' : 'Network request failed'
if (!isAbortError) console.error(error)
Comment on lines 60 to 61
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isAborted is computed, but the following lines still reference isAbortError, which is not defined in this scope. This will fail to compile and also breaks the intended abort/no-log behavior. Use the same variable name consistently for the abort branch and logging condition.

Suggested change
const errorMessage = isAbortError ? 'Network request aborted' : 'Network request failed'
if (!isAbortError) console.error(error)
const errorMessage = isAborted ? 'Network request aborted' : 'Network request failed'
if (!isAborted) console.error(error)

Copilot uses AI. Check for mistakes.
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, errorMessage, error)
}

// 3. Handle HTTP errors
if (!response.ok) {
const errorData = await response.json().catch()
const errorData = await response.json().catch(() => ({ error: { message: response.statusText } }))
const errorMessage =
(errorData as { error?: { message?: string } }).error?.message || response.statusText

Expand Down Expand Up @@ -135,7 +135,7 @@

// 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?: Array<{ message?: { tool_calls?: Array<{ function?: { name?: string; arguments?: string } }> } }> })?.choices?.[0]

Check failure on line 138 in packages/llms/src/OpenAIClient.ts

View workflow job for this annotation

GitHub Actions / test (24)

Array type using 'Array<T>' is forbidden. Use 'T[]' instead

Check failure on line 138 in packages/llms/src/OpenAIClient.ts

View workflow job for this annotation

GitHub Actions / test (24)

Array type using 'Array<T>' is forbidden. Use 'T[]' instead

// Get tool name from response
const toolCallName = normalizedChoice?.message?.tool_calls?.[0]?.function?.name
Expand Down Expand Up @@ -201,7 +201,7 @@
} 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
)
Expand Down
4 changes: 2 additions & 2 deletions packages/llms/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 9 additions & 3 deletions packages/llms/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Comment on lines +97 to +99
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The abort short-circuit in withRetry only triggers for InvokeError.rawError.name === 'AbortError', but LLM.invoke throws new Error('AbortError') when abortSignal.aborted (message is 'AbortError', name is still 'Error'). That means aborted requests will be logged, surfaced via onError, and retried. Consider standardizing abort exceptions to have name === 'AbortError' (e.g., DOMException) or broadening the check to also detect the existing new Error('AbortError') pattern.

Suggested change
error instanceof InvokeError &&
error.rawError instanceof Error &&
error.rawError.name === 'AbortError'
(
error instanceof InvokeError &&
error.rawError instanceof Error &&
(error.rawError.name === 'AbortError' || error.rawError.message === 'AbortError')
) ||
(
error instanceof Error &&
(error.name === 'AbortError' || error.message === 'AbortError')
)

Copilot uses AI. Check for mistakes.
) {
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))
Expand Down
60 changes: 55 additions & 5 deletions packages/page-controller/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,52 @@ async function waitFor(seconds: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
}

/**
* Find a scrollable container without scanning the entire DOM.
* Tries common selectors first, then falls back to viewport elements.
*/
function findScrollableContainer(
canScroll: (el: HTMLElement | null) => boolean
): HTMLElement | undefined {
// Try common scrollable container selectors first (fast path)
const commonSelectors = [
'[data-scrollable="true"]',
'.scrollable',
'.overflow-auto',
'.overflow-scroll',
'[class*="scroll"]',
'main',
'article',
'section',
'aside',
]

for (const selector of commonSelectors) {
const containers = document.querySelectorAll<HTMLElement>(selector)
for (const container of containers) {
if (canScroll(container)) return container
}
}

// Fallback: check elements near viewport center (more likely to be visible)
const viewportElements = document.elementsFromPoint(
window.innerWidth / 2,
window.innerHeight / 2
) as HTMLElement[]

for (const el of viewportElements) {
if (canScroll(el)) return el
// Check ancestors up to 3 levels
let parent = el.parentElement
for (let i = 0; i < 3 && parent; i++) {
if (canScroll(parent)) return parent
parent = parent.parentElement
}
}

return undefined
}

// ======= dom utils =======

export async function movePointerToElement(element: HTMLElement) {
Expand Down Expand Up @@ -213,14 +259,18 @@ export async function selectOptionElement(selectElement: HTMLSelectElement, opti
await waitFor(0.1) // Wait to ensure change event processing completes
}

interface ScrollableElement extends HTMLElement {
scrollIntoViewIfNeeded?: (centerIfNeeded?: boolean) => void
}

export async function scrollIntoViewIfNeeded(element: HTMLElement) {
const el = element as any
if (el.scrollIntoViewIfNeeded) {
const el = element as ScrollableElement
if (typeof el.scrollIntoViewIfNeeded === 'function') {
el.scrollIntoViewIfNeeded()
// await waitFor(0.5) // Animation playback
} else {
// @todo visibility check
el.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'nearest' })
element.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'nearest' })
// await waitFor(0.5) // Animation playback
}
}
Expand Down Expand Up @@ -299,7 +349,7 @@ export async function scrollVertically(

el = canScroll(el)
? el
: Array.from(document.querySelectorAll<HTMLElement>('*')).find(canScroll) ||
: findScrollableContainer(canScroll) ||
(document.scrollingElement as HTMLElement) ||
(document.documentElement as HTMLElement)

Expand Down Expand Up @@ -427,7 +477,7 @@ export async function scrollHorizontally(

el = canScroll(el)
? el
: Array.from(document.querySelectorAll<HTMLElement>('*')).find(canScroll) ||
: findScrollableContainer(canScroll) ||
(document.scrollingElement as HTMLElement) ||
(document.documentElement as HTMLElement)

Expand Down
12 changes: 7 additions & 5 deletions packages/page-controller/src/dom/dom_tree/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export default (

const HIGHLIGHT_CONTAINER_ID = 'playwright-highlight-container'

// Add a WeakMap cache for XPath strings
// Add a WeakMap cache for XPath strings (WeakMap to avoid memory leaks with DOM nodes)
const xpathCache = new WeakMap()

// // Initialize once and reuse
Expand Down Expand Up @@ -1604,10 +1604,12 @@ export default (
// Call the dedicated highlighting function
nodeWasHighlighted = handleHighlighting(nodeData, node, parentIframe, isParentHighlighted)

/**
* @edit direct dom ref
*/
nodeData.ref = node
/**
* @edit direct dom ref
* @note This creates a memory reference that persists until the next updateTree() call.
* PageController.dispose() clears flatTree to release these references.
*/
nodeData.ref = node
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions packages/page-controller/src/mask/SimulatorMask.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@

display: none;
}

.wrapper.visible {
display: block;
}
4 changes: 2 additions & 2 deletions packages/page-controller/src/mask/SimulatorMask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export class SimulatorMask {
this.motion?.start()
this.motion?.fadeIn()

this.wrapper.style.display = 'block'
this.wrapper.classList.add(styles.visible)

// Initialize cursor position
this.#currentCursorX = window.innerWidth / 2
Expand All @@ -172,7 +172,7 @@ export class SimulatorMask {
this.#cursor.classList.remove(cursorStyles.clicking)

setTimeout(() => {
this.wrapper.style.display = 'none'
this.wrapper.classList.remove(styles.visible)
}, 800) // Match the animation duration
}

Expand Down
Loading