Files
page-agent/packages/core/src/PageAgentCore.ts
T

602 lines
16 KiB
TypeScript
Raw Normal View History

2025-09-29 16:33:15 +08:00
/**
* Copyright (C) 2025 Alibaba Group Holding Limited
* All rights reserved.
*/
import { InvokeError, LLM, type Tool } from '@page-agent/llms'
import type { PageController } from '@page-agent/page-controller'
2025-09-29 16:33:15 +08:00
import chalk from 'chalk'
import * as zod from 'zod'
2025-09-29 16:33:15 +08:00
import { type PageAgentConfig } from './config'
2026-01-19 20:11:53 +08:00
import { DEFAULT_MAX_STEPS } from './config/constants'
2025-09-29 16:33:15 +08:00
import SYSTEM_PROMPT from './prompts/system_prompt.md?raw'
2025-10-21 18:47:16 +08:00
import { tools } from './tools'
import type {
2026-01-17 23:30:12 +08:00
AgentActivity,
AgentReflection,
AgentStatus,
AgentStepEvent,
2026-01-17 23:30:12 +08:00
ExecutionResult,
HistoricalEvent,
MacroToolInput,
MacroToolResult,
} from './types'
import { assert, normalizeResponse, trimLines, uid, waitFor } from './utils'
2025-09-29 16:33:15 +08:00
export { type PageAgentConfig }
2025-10-21 19:29:13 +08:00
export { tool, type PageAgentTool } from './tools'
export type * from './types'
2025-10-17 18:43:41 +08:00
2026-01-17 23:30:37 +08:00
/**
2026-02-10 15:28:50 +08:00
* AI agent for browser automation.
2026-01-17 23:30:37 +08:00
*
* @remarks
* ## Event System
* - `statuschange` - Agent status transitions (idle → running → completed/error)
* - `historychange` - History events updated (persistent, part of agent memory)
* - `activity` - Real-time activity feedback (transient, for UI only)
* - `dispose` - Agent cleanup triggered
*
* ## Information Streams
* 1. **History Events** (`history` array)
* - Persistent event stream that forms agent's memory
* - Included in LLM context across steps
* - Types: steps, observations, user takeovers, llm errors
*
* 2. **Activity Events** (via `activity` event)
* - Transient UI feedback during task execution
* - NOT included in LLM context
* - Types: thinking, executing, executed, retrying, error
*/
export class PageAgentCore extends EventTarget {
2026-02-10 15:45:04 +08:00
readonly id = uid()
readonly config: PageAgentConfig & { maxSteps: number }
readonly tools: typeof tools
/** PageController for DOM operations */
readonly pageController: PageController
2025-09-29 16:33:15 +08:00
task = ''
2025-10-21 21:31:35 +08:00
taskId = ''
2026-02-10 15:45:04 +08:00
/** History events */
history: HistoricalEvent[] = []
/**
* Callback for when agent needs user input (ask_user tool)
* If not set, ask_user tool will be disabled
* @example onAskUser: (q) => window.prompt(q) || ''
*/
onAskUser?: (question: string) => Promise<string>
2026-02-10 15:45:04 +08:00
#status: AgentStatus = 'idle'
2025-09-29 16:33:15 +08:00
#llm: LLM
#abortController = new AbortController()
2026-02-09 21:05:29 +08:00
#observations: string[] = []
2025-09-29 16:33:15 +08:00
2026-02-10 15:45:04 +08:00
/** internal states of a single task execution */
2026-01-15 20:28:39 +08:00
states = {
2026-02-10 15:45:04 +08:00
/** Accumulated wait time in seconds */
2026-01-15 20:28:39 +08:00
totalWaitTime: 0,
/** Last known URL for detecting navigation */
lastURL: '',
}
constructor(config: PageAgentConfig & { pageController: PageController }) {
2025-09-29 16:33:15 +08:00
super()
2026-01-19 20:11:53 +08:00
this.config = { ...config, maxSteps: config.maxSteps || DEFAULT_MAX_STEPS }
this.#llm = new LLM(this.config)
2025-10-21 19:29:13 +08:00
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
this.#emitActivity({ type: 'retrying', attempt, maxAttempts })
// Also push to history for panel rendering
this.history.push({
2026-01-22 15:16:15 +08:00
type: 'retry',
message: `LLM retry attempt ${attempt} of ${maxAttempts}`,
attempt,
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)
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()
})
2025-10-21 19:29:13 +08:00
if (this.config.customTools) {
for (const [name, tool] of Object.entries(this.config.customTools)) {
if (tool === null) {
this.tools.delete(name)
continue
}
this.tools.set(name, tool)
}
}
2025-09-29 16:33:15 +08:00
2025-10-23 19:59:17 +08:00
if (!this.config.experimentalScriptExecutionTool) {
this.tools.delete('execute_javascript')
}
2025-09-29 16:33:15 +08:00
}
/** Get current agent status */
get status(): AgentStatus {
return this.#status
}
/** Emit statuschange event */
#emitStatusChange(): void {
this.dispatchEvent(new Event('statuschange'))
}
/** Emit historychange event */
#emitHistoryChange(): void {
this.dispatchEvent(new Event('historychange'))
}
/**
* Emit activity event - for transient UI feedback
* @param activity - Current agent activity
*/
#emitActivity(activity: AgentActivity): void {
this.dispatchEvent(new CustomEvent('activity', { detail: activity }))
}
/** Update status and emit event */
#setStatus(status: AgentStatus): void {
if (this.#status !== status) {
this.#status = status
this.#emitStatusChange()
}
}
2026-01-15 19:18:25 +08:00
/**
2026-02-09 21:05:29 +08:00
* Push a observation message to the history event stream.
* This will be visible in <agent_history> and remain persistent in memory across steps.
* @experimental @internal
* @note history change will be emitted before next step starts
2026-01-15 19:18:25 +08:00
*/
pushObservation(content: string): void {
2026-02-09 21:05:29 +08:00
this.#observations.push(content)
2026-01-15 19:18:25 +08:00
}
2025-09-29 16:33:15 +08:00
async execute(task: string): Promise<ExecutionResult> {
if (!task) throw new Error('Task is required')
this.task = task
2025-10-21 21:31:35 +08:00
this.taskId = uid()
2025-09-29 16:33:15 +08:00
// Disable ask_user tool if onAskUser is not set
if (!this.onAskUser) {
this.tools.delete('ask_user')
}
const onBeforeStep = this.config.onBeforeStep
const onAfterStep = this.config.onAfterStep
const onBeforeTask = this.config.onBeforeTask
const onAfterTask = this.config.onAfterTask
await onBeforeTask?.(this)
// Show mask
await this.pageController.showMask()
2025-09-29 16:33:15 +08:00
if (this.#abortController) {
this.#abortController.abort()
this.#abortController = new AbortController()
}
this.history = []
this.#setStatus('running')
this.#emitHistoryChange()
2025-09-29 16:33:15 +08:00
2026-01-15 20:28:39 +08:00
// Reset states
this.states = {
totalWaitTime: 0,
lastURL: '',
}
let step = 0
while (true) {
try {
console.group(`step: ${step}`)
2025-09-29 16:33:15 +08:00
2026-02-09 21:05:29 +08:00
await this.#systemObservations(step)
if (this.#observations.length > 0) {
for (const content of this.#observations) {
this.history.push({ type: 'observation', content })
}
this.#observations = []
this.#emitHistoryChange()
}
2026-01-15 20:28:39 +08:00
await onBeforeStep?.(this, step)
2025-10-21 19:39:06 +08:00
2025-09-29 16:33:15 +08:00
// abort
if (this.#abortController.signal.aborted) throw new Error('AbortError')
// status
2025-09-29 16:33:15 +08:00
console.log(chalk.blue('Thinking...'))
this.#emitActivity({ type: 'thinking' })
2025-09-29 16:33:15 +08:00
// invoke LLM
const messages = [
{ role: 'system' as const, content: this.#getSystemPrompt() },
{ role: 'user' as const, content: await this.#assembleUserPrompt() },
]
const tools = { AgentOutput: this.#packMacroTool() }
const result = await this.#llm.invoke(messages, tools, this.#abortController.signal, {
toolChoiceName: 'AgentOutput',
normalizeResponse,
})
// assemble history event
2025-09-29 16:33:15 +08:00
2025-10-17 18:43:41 +08:00
const macroResult = result.toolResult as MacroToolResult
const input = macroResult.input
const output = macroResult.output
const reflection: Partial<AgentReflection> = {
evaluation_previous_goal: input.evaluation_previous_goal,
memory: input.memory,
next_goal: input.next_goal,
2025-09-29 16:33:15 +08:00
}
const actionName = Object.keys(input.action)[0]
const action: AgentStepEvent['action'] = {
2025-09-29 16:33:15 +08:00
name: actionName,
input: input.action[actionName],
output: output,
}
this.history.push({
2026-01-15 19:18:25 +08:00
type: 'step',
2026-01-21 16:59:22 +08:00
stepIndex: step,
reflection,
2025-09-29 16:33:15 +08:00
action,
usage: result.usage,
2026-01-20 19:56:40 +08:00
rawResponse: result.rawResponse,
rawRequest: result.rawRequest,
} as AgentStepEvent)
this.#emitHistoryChange()
2025-09-29 16:33:15 +08:00
//
await onAfterStep?.(this, this.history)
2025-09-29 16:33:15 +08:00
console.log(chalk.green('Step finished:'), actionName)
console.groupEnd()
// finish task if done
2025-10-21 19:39:06 +08:00
2025-09-29 16:33:15 +08:00
if (actionName === 'done') {
2025-10-17 18:43:41 +08:00
const success = action.input?.success ?? false
const text = action.input?.text || 'no text provided'
2025-09-29 16:33:15 +08:00
console.log(chalk.green.bold('Task completed'), success, text)
this.#onDone(success)
const result: ExecutionResult = {
2025-09-29 16:33:15 +08:00
success,
data: text,
history: this.history,
}
await onAfterTask?.(this, result)
return result
2025-09-29 16:33:15 +08:00
}
} catch (error: unknown) {
console.groupEnd() // to prevent nested groups
console.error('Task failed', error)
const errorMessage = String(error)
this.#emitActivity({ type: 'error', message: errorMessage })
this.#onDone(false)
const result: ExecutionResult = {
success: false,
data: errorMessage,
history: this.history,
}
await onAfterTask?.(this, result)
return result
2025-09-29 16:33:15 +08:00
}
step++
if (step > this.config.maxSteps) {
this.#onDone(false)
const result: ExecutionResult = {
success: false,
data: 'Step count exceeded maximum limit',
history: this.history,
}
await onAfterTask?.(this, result)
return result
2025-09-29 16:33:15 +08:00
}
}
}
/**
* Merge all tools into a single MacroTool with the following input:
* - thinking: string
* - evaluation_previous_goal: string
* - memory: string
* - next_goal: string
* - action: { toolName: toolInput }
* where action must be selected from tools defined in this.tools
*/
2025-10-17 18:43:41 +08:00
#packMacroTool(): Tool<MacroToolInput, MacroToolResult> {
2025-09-29 16:33:15 +08:00
const tools = this.tools
2025-10-21 21:31:35 +08:00
2025-09-29 16:33:15 +08:00
const actionSchemas = Array.from(tools.entries()).map(([toolName, tool]) => {
2025-12-24 19:28:59 +08:00
return zod.object({ [toolName]: tool.inputSchema }).describe(tool.description)
2025-09-29 16:33:15 +08:00
})
2025-10-17 18:43:41 +08:00
const actionSchema = zod.union(
actionSchemas as unknown as [zod.ZodType, zod.ZodType, ...zod.ZodType[]]
)
const macroToolSchema = zod.object({
// thinking: zod.string().optional(),
evaluation_previous_goal: zod.string().optional(),
memory: zod.string().optional(),
next_goal: zod.string().optional(),
action: actionSchema,
})
2025-09-29 16:33:15 +08:00
return {
2026-02-09 21:04:39 +08:00
description: 'You MUST call this tool every step!',
2025-10-17 18:43:41 +08:00
inputSchema: macroToolSchema as zod.ZodType<MacroToolInput>,
execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
// abort
if (this.#abortController.signal.aborted) throw new Error('AbortError')
console.log(chalk.blue.bold('MacroTool execute'), input)
const action = input.action
const toolName = Object.keys(action)[0]
const toolInput = action[toolName]
2025-09-29 16:33:15 +08:00
// Build reflection text, only include non-empty fields
const reflectionLines: string[] = []
if (input.evaluation_previous_goal)
reflectionLines.push(`✅: ${input.evaluation_previous_goal}`)
if (input.memory) reflectionLines.push(`💾: ${input.memory}`)
if (input.next_goal) reflectionLines.push(`🎯: ${input.next_goal}`)
const reflectionText = reflectionLines.length > 0 ? reflectionLines.join('\n') : ''
if (reflectionText) {
console.log(reflectionText)
}
2025-10-17 18:43:41 +08:00
// Find the corresponding tool
const tool = tools.get(toolName)
assert(tool, `Tool ${toolName} not found. (@note should have been caught before this!!!)`)
console.log(chalk.blue.bold(`Executing tool: ${toolName}`), toolInput)
// Emit executing activity
this.#emitActivity({ type: 'executing', tool: toolName, input: toolInput })
2025-09-29 16:33:15 +08:00
2025-10-17 18:43:41 +08:00
const startTime = Date.now()
2025-09-29 16:33:15 +08:00
2025-10-17 18:43:41 +08:00
// Execute tool, bind `this` to PageAgent
const result = await tool.execute.bind(this)(toolInput)
2025-10-17 18:43:41 +08:00
const duration = Date.now() - startTime
console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result)
// Emit executed activity
this.#emitActivity({
type: 'executed',
tool: toolName,
input: toolInput,
output: result,
duration,
})
// Reset wait time for non-wait tools
if (toolName !== 'wait') {
2026-01-15 20:28:39 +08:00
this.states.totalWaitTime = 0
2025-10-17 18:43:41 +08:00
}
// Return structured result
return {
input,
output: result,
}
},
2025-09-29 16:33:15 +08:00
}
}
/**
* Get system prompt, dynamically replace language settings based on configured language
*/
#getSystemPrompt(): string {
if (this.config.customSystemPrompt) {
return this.config.customSystemPrompt
}
2025-09-29 16:33:15 +08:00
const targetLanguage = this.config.language === 'zh-CN' ? '中文' : 'English'
const systemPrompt = SYSTEM_PROMPT.replace(
2025-09-29 16:33:15 +08:00
/Default working language: \*\*.*?\*\*/,
`Default working language: **${targetLanguage}**`
)
return systemPrompt
}
2026-01-10 18:44:09 +08:00
/**
* Get instructions from config and format as XML block
*/
async #getInstructions(): Promise<string> {
const { instructions } = this.config
if (!instructions) return ''
const systemInstructions = instructions.system?.trim()
const url = await this.pageController.getCurrentUrl()
2026-01-10 19:19:20 +08:00
let pageInstructions: string | undefined
if (instructions.getPageInstructions) {
try {
pageInstructions = instructions.getPageInstructions(url)?.trim()
} catch (error) {
console.error(
chalk.red('[PageAgent] Failed to execute getPageInstructions callback:'),
error
)
}
}
2026-01-10 18:44:09 +08:00
if (!systemInstructions && !pageInstructions) return ''
let result = '<instructions>\n'
if (systemInstructions) {
result += `<system_instructions>\n${systemInstructions}\n</system_instructions>\n`
}
if (pageInstructions) {
result += `<page_instructions>\n${pageInstructions}\n</page_instructions>\n`
}
result += '</instructions>\n\n'
return result
}
2026-01-15 20:28:39 +08:00
/**
2026-02-09 21:05:29 +08:00
* Generate system observations before each step
2026-01-15 20:28:39 +08:00
* - URL change detection
* - Too many steps warning
* @todo loop detection
* @todo console error
*/
2026-02-09 21:05:29 +08:00
async #systemObservations(stepCount: number): Promise<void> {
2026-01-15 20:28:39 +08:00
// Detect URL change
const currentURL = await this.pageController.getCurrentUrl()
if (currentURL !== this.states.lastURL) {
2026-01-15 20:28:39 +08:00
this.pushObservation(`Page navigated to → ${currentURL}`)
this.states.lastURL = currentURL
2026-01-19 20:50:50 +08:00
await waitFor(0.5) // wait for page to stabilize
2026-01-15 20:28:39 +08:00
}
// Warn about remaining steps
2026-01-19 20:11:53 +08:00
const remaining = this.config.maxSteps - stepCount
2026-01-15 20:28:39 +08:00
if (remaining === 5) {
this.pushObservation(
`⚠️ Only ${remaining} steps remaining. Consider wrapping up or calling done with partial results.`
)
} else if (remaining === 2) {
this.pushObservation(
`⚠️ Critical: Only ${remaining} steps left! You must finish the task or call done immediately.`
)
}
}
async #assembleUserPrompt(): Promise<string> {
2025-09-29 16:33:15 +08:00
let prompt = ''
2026-01-10 18:44:09 +08:00
// <instructions> (optional)
prompt += await this.#getInstructions()
2025-09-29 16:33:15 +08:00
// <agent_state>
// - <user_request>
// - <step_info>
// <agent_state>
2026-01-15 19:18:25 +08:00
const stepCount = this.history.filter((e) => e.type === 'step').length
2025-09-29 16:33:15 +08:00
prompt += `<agent_state>
<user_request>
${this.task}
</user_request>
<step_info>
2026-01-19 20:11:53 +08:00
Step ${stepCount + 1} of ${this.config.maxSteps} max possible steps
Current date and time: ${new Date().toLocaleString()}
2025-09-29 16:33:15 +08:00
</step_info>
</agent_state>
`
// <agent_history>
2026-01-15 19:18:25 +08:00
// - <step_N> for steps
// - <sys> for observations and system messages
prompt += '\n<agent_history>\n'
2026-01-15 19:18:25 +08:00
let stepIndex = 0
for (const event of this.history) {
if (event.type === 'step') {
stepIndex++
prompt += `<step_${stepIndex}>
Evaluation of Previous Step: ${event.reflection.evaluation_previous_goal}
Memory: ${event.reflection.memory}
Next Goal: ${event.reflection.next_goal}
2026-01-15 19:18:25 +08:00
Action Results: ${event.action.output}
</step_${stepIndex}>
`
2026-01-15 19:18:25 +08:00
} else if (event.type === 'observation') {
prompt += `<sys>${event.content}</sys>\n`
} else if (event.type === 'user_takeover') {
prompt += `<sys>User took over control and made changes to the page.</sys>\n`
} else if (event.type === 'error') {
// Error events are mainly for panel rendering, not included in LLM context
// to avoid polluting the agent's reasoning with transient errors
2026-01-15 19:18:25 +08:00
}
}
prompt += '</agent_history>\n\n'
2025-09-29 16:33:15 +08:00
// <browser_state>
prompt += await this.#getBrowserState()
2025-09-29 16:33:15 +08:00
return trimLines(prompt)
}
#onDone(success = true) {
this.pageController.cleanUpHighlights()
this.pageController.hideMask() // No await - fire and forget
this.#setStatus(success ? 'completed' : 'error')
2025-09-29 16:33:15 +08:00
this.#abortController.abort()
}
async #getBrowserState(): Promise<string> {
const state = await this.pageController.getBrowserState()
let content = state.content
if (this.config.transformPageContent) {
content = await this.config.transformPageContent(content)
}
2025-09-29 16:33:15 +08:00
return trimLines(`<browser_state>
${state.header}
${content}
${state.footer}
</browser_state>
2025-09-29 16:33:15 +08:00
`)
}
dispose() {
2025-09-29 16:33:15 +08:00
console.log('Disposing PageAgent...')
this.pageController.dispose()
// this.history = []
this.#abortController.abort()
2025-10-21 21:31:35 +08:00
// Emit dispose event for UI cleanup
this.dispatchEvent(new Event('dispose'))
this.config.onDispose?.(this)
2025-09-29 16:33:15 +08:00
}
}