feat!: Refine lifecycle hooks; fix abortSignal

- add `stop` method. agent can be reused after stopped
- agent can not be reused after disposed
- extension DO NOT exposes `dispose` anymore. only `stop`.
- update panel for new `stop` method
- fix MultiPageAgent dispose event
- better handling abortSignal
This commit is contained in:
Simon
2026-02-13 17:57:12 +08:00
parent 4dc332a32c
commit dffcb53db9
13 changed files with 80 additions and 68 deletions

View File

@@ -68,6 +68,8 @@ export class PageAgentCore extends EventTarget {
taskId = '' taskId = ''
/** History events */ /** History events */
history: HistoricalEvent[] = [] history: HistoricalEvent[] = []
/** Whether this agent has been disposed */
disposed = false
/** /**
* Callback for when agent needs user input (ask_user tool) * Callback for when agent needs user input (ask_user tool)
@@ -183,7 +185,15 @@ export class PageAgentCore extends EventTarget {
this.#observations.push(content) this.#observations.push(content)
} }
/** Stop the current task. Agent remains reusable. */
stop() {
this.pageController.cleanUpHighlights()
this.pageController.hideMask()
this.#abortController.abort()
}
async execute(task: string): Promise<ExecutionResult> { async execute(task: string): Promise<ExecutionResult> {
if (this.disposed) throw new Error('PageAgent has been disposed. Create a new instance.')
if (!task) throw new Error('Task is required') if (!task) throw new Error('Task is required')
this.task = task this.task = task
this.taskId = uid() this.taskId = uid()
@@ -300,9 +310,13 @@ 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'
console.error('Task failed', error) console.error('Task failed', error)
const errorMessage = 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.#emitHistoryChange()
this.#onDone(false) this.#onDone(false)
const result: ExecutionResult = { const result: ExecutionResult = {
success: false, success: false,
@@ -315,10 +329,13 @@ export class PageAgentCore extends EventTarget {
step++ step++
if (step > this.config.maxSteps) { if (step > this.config.maxSteps) {
const errorMessage = 'Step count exceeded maximum limit'
this.history.push({ type: 'error', message: errorMessage })
this.#emitHistoryChange()
this.#onDone(false) this.#onDone(false)
const result: ExecutionResult = { const result: ExecutionResult = {
success: false, success: false,
data: 'Step count exceeded maximum limit', data: errorMessage,
history: this.history, history: this.history,
} }
await onAfterTask?.(this, result) await onAfterTask?.(this, result)
@@ -601,6 +618,7 @@ export class PageAgentCore extends EventTarget {
dispose() { dispose() {
console.log('Disposing PageAgent...') console.log('Disposing PageAgent...')
this.disposed = true
this.pageController.dispose() this.pageController.dispose()
// this.history = [] // this.history = []
this.#abortController.abort() this.#abortController.abort()

View File

@@ -97,7 +97,7 @@ Parameters:
Returns: `Promise<ExecutionResult>` Returns: `Promise<ExecutionResult>`
#### `PAGE_AGENT_EXT.dispose()` #### `PAGE_AGENT_EXT.stop()`
Stop the current task. Stop the current task.
@@ -124,7 +124,6 @@ export interface ExecuteConfig {
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
} }
export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult> export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
@@ -189,7 +188,7 @@ const result = await window.PAGE_AGENT_EXT!.execute(
### Stop the Current Task ### Stop the Current Task
```typescript ```typescript
window.PAGE_AGENT_EXT!.dispose() window.PAGE_AGENT_EXT!.stop()
``` ```
## Window Type Declaration ## Window Type Declaration
@@ -212,7 +211,6 @@ interface ExecuteConfig {
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
} }
declare global { declare global {
@@ -221,7 +219,7 @@ declare global {
PAGE_AGENT_EXT?: { PAGE_AGENT_EXT?: {
version: string version: string
execute: Execute execute: Execute
dispose: () => void stop: () => void
} }
} }
} }

View File

@@ -97,7 +97,7 @@ token 匹配后,插件会在 `window` 上注入 API。
返回:`Promise<ExecutionResult>` 返回:`Promise<ExecutionResult>`
#### `PAGE_AGENT_EXT.dispose()` #### `PAGE_AGENT_EXT.stop()`
停止当前任务。 停止当前任务。
@@ -124,7 +124,6 @@ export interface ExecuteConfig {
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
} }
export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult> export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
@@ -189,7 +188,7 @@ const result = await window.PAGE_AGENT_EXT!.execute(
### 停止当前任务 ### 停止当前任务
```typescript ```typescript
window.PAGE_AGENT_EXT!.dispose() window.PAGE_AGENT_EXT!.stop()
``` ```
## Window 类型声明 ## Window 类型声明
@@ -212,7 +211,6 @@ interface ExecuteConfig {
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
} }
declare global { declare global {
@@ -221,7 +219,7 @@ declare global {
PAGE_AGENT_EXT?: { PAGE_AGENT_EXT?: {
version: string version: string
execute: Execute execute: Execute
dispose: () => void stop: () => void
} }
} }
} }

View File

@@ -89,8 +89,7 @@ export class MultiPageAgent extends PageAgentCore {
isAgentRunning: false, isAgentRunning: false,
}) })
// no need to dispose tabsController and pageController tabsController.dispose()
// as they do not keep references
}, },
}) })
} }

View File

@@ -84,7 +84,7 @@ export function useAgent(): UseAgentResult {
}, []) }, [])
const stop = useCallback(() => { const stop = useCallback(() => {
agentRef.current?.dispose() agentRef.current?.stop()
}, []) }, [])
const configure = useCallback(async (newConfig: LLMConfig) => { const configure = useCallback(async (newConfig: LLMConfig) => {

View File

@@ -71,7 +71,8 @@ async function exposeAgentToPage() {
try { try {
const { task, config } = payload const { task, config } = payload
// create when used // Dispose old instance before creating new one
multiPageAgent?.dispose()
multiPageAgent = new MultiPageAgent(config) multiPageAgent = new MultiPageAgent(config)
@@ -116,17 +117,6 @@ async function exposeAgentToPage() {
) )
}) })
multiPageAgent.addEventListener('dispose', () => {
window.postMessage(
{
channel: 'PAGE_AGENT_EXT_RESPONSE',
id,
action: 'dispose_event',
},
'*'
)
})
// result // result
const result = await multiPageAgent.execute(task) const result = await multiPageAgent.execute(task)
@@ -155,9 +145,8 @@ async function exposeAgentToPage() {
break break
} }
case 'dispose': { case 'stop': {
// @note stop ongoing processes but can still be re-used later multiPageAgent?.stop()
multiPageAgent?.dispose()
break break
} }

View File

@@ -16,7 +16,6 @@ export interface ExecuteConfig {
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
} }
export default defineUnlistedScript(() => { export default defineUnlistedScript(() => {
@@ -60,12 +59,6 @@ export default defineUnlistedScript(() => {
return return
} }
if (data.action === 'dispose_event' && config.onDispose) {
config.onDispose()
window.removeEventListener('message', handleMessage)
return
}
if (data.action !== 'execute_result') return if (data.action !== 'execute_result') return
// execute_result // execute_result
@@ -104,14 +97,14 @@ export default defineUnlistedScript(() => {
return promise return promise
} }
const dispose = () => { const stop = () => {
const id = getId() const id = getId()
window.postMessage( window.postMessage(
{ {
channel: 'PAGE_AGENT_EXT_REQUEST', channel: 'PAGE_AGENT_EXT_REQUEST',
id, id,
action: 'dispose', action: 'stop',
}, },
'*' '*'
) )
@@ -121,6 +114,6 @@ export default defineUnlistedScript(() => {
;(window as any).PAGE_AGENT_EXT = { ;(window as any).PAGE_AGENT_EXT = {
version: __EXT_VERSION__, version: __EXT_VERSION__,
execute, execute,
dispose, stop,
} }
}) })

View File

@@ -54,8 +54,10 @@ export class OpenAIClient implements LLMClient {
signal: abortSignal, signal: abortSignal,
}) })
} catch (error: unknown) { } catch (error: unknown) {
console.error(error) const isAbortError = (error as any)?.name === 'AbortError'
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, 'Network request failed', error) const errorMessage = isAbortError ? 'Network request aborted' : 'Network request failed'
if (!isAbortError) console.error(error)
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, errorMessage, error)
} }
// 3. Handle HTTP errors // 3. Handle HTTP errors

View File

@@ -34,12 +34,15 @@ 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) this.retryable = this.isRetryable(type, rawError)
this.rawError = rawError this.rawError = rawError
this.rawResponse = rawResponse this.rawResponse = rawResponse
} }
private isRetryable(type: InvokeErrorType): boolean { private isRetryable(type: InvokeErrorType, rawError?: unknown): boolean {
const isAbortError = (rawError as any)?.name === 'AbortError'
if (isAbortError) return false
const retryableTypes: InvokeErrorType[] = [ const retryableTypes: InvokeErrorType[] = [
InvokeErrorType.NETWORK_ERROR, InvokeErrorType.NETWORK_ERROR,
InvokeErrorType.RATE_LIMIT, InvokeErrorType.RATE_LIMIT,

View File

@@ -12,6 +12,7 @@ const enUS = {
question: 'Question: {{question}}', question: 'Question: {{question}}',
waitingPlaceholder: 'Waiting for task to start...', waitingPlaceholder: 'Waiting for task to start...',
stop: 'Stop', stop: 'Stop',
close: 'Close',
expand: 'Expand history', expand: 'Expand history',
collapse: 'Collapse history', collapse: 'Collapse history',
step: 'Step {{number}} · {{time}}{{duration}}', step: 'Step {{number}} · {{time}}{{duration}}',
@@ -59,6 +60,7 @@ const zhCN = {
question: '询问: {{question}}', question: '询问: {{question}}',
waitingPlaceholder: '等待任务开始...', waitingPlaceholder: '等待任务开始...',
stop: '终止', stop: '终止',
close: '关闭',
expand: '展开历史', expand: '展开历史',
collapse: '收起历史', collapse: '收起历史',
step: '步骤 {{number}} · {{time}}{{duration}}', step: '步骤 {{number}} · {{time}}{{duration}}',

View File

@@ -33,7 +33,7 @@ export class Panel {
#statusText: HTMLElement #statusText: HTMLElement
#historySection: HTMLElement #historySection: HTMLElement
#expandButton: HTMLElement #expandButton: HTMLElement
#stopButton: HTMLElement #actionButton: HTMLElement
#inputSection: HTMLElement #inputSection: HTMLElement
#taskInput: HTMLInputElement #taskInput: HTMLInputElement
@@ -76,7 +76,7 @@ export class Panel {
this.#statusText = this.#wrapper.querySelector(`.${styles.statusText}`)! this.#statusText = this.#wrapper.querySelector(`.${styles.statusText}`)!
this.#historySection = this.#wrapper.querySelector(`.${styles.historySection}`)! this.#historySection = this.#wrapper.querySelector(`.${styles.historySection}`)!
this.#expandButton = this.#wrapper.querySelector(`.${styles.expandButton}`)! this.#expandButton = this.#wrapper.querySelector(`.${styles.expandButton}`)!
this.#stopButton = this.#wrapper.querySelector(`.${styles.stopButton}`)! this.#actionButton = this.#wrapper.querySelector(`.${styles.stopButton}`)!
this.#inputSection = this.#wrapper.querySelector(`.${styles.inputSectionWrapper}`)! this.#inputSection = this.#wrapper.querySelector(`.${styles.inputSectionWrapper}`)!
this.#taskInput = this.#wrapper.querySelector(`.${styles.taskInput}`)! this.#taskInput = this.#wrapper.querySelector(`.${styles.taskInput}`)!
@@ -105,6 +105,15 @@ export class Panel {
status === 'running' ? 'thinking' : status === 'idle' ? 'thinking' : status status === 'running' ? 'thinking' : status === 'idle' ? 'thinking' : status
this.#updateStatusIndicator(indicatorType) this.#updateStatusIndicator(indicatorType)
// Morph action button: running = stop (■), not running = close (X)
if (status === 'running') {
this.#actionButton.textContent = '■'
this.#actionButton.title = this.#i18n.t('ui.panel.stop')
} else {
this.#actionButton.textContent = 'X'
this.#actionButton.title = this.#i18n.t('ui.panel.close')
}
// Show/hide based on status // Show/hide based on status
if (status === 'running') { if (status === 'running') {
this.show() this.show()
@@ -266,10 +275,14 @@ export class Panel {
} }
/** /**
* Stop Agent * Action button handler: stop when running, close (dispose) when idle
*/ */
#stopAgent(): void { #handleActionButton(): void {
this.#agent.dispose() if (this.#agent.status === 'running') {
this.#agent.stop()
} else {
this.#agent.dispose()
}
} }
/** /**
@@ -383,7 +396,7 @@ export class Panel {
<button class="${styles.controlButton} ${styles.expandButton}" title="${this.#i18n.t('ui.panel.expand')}"> <button class="${styles.controlButton} ${styles.expandButton}" title="${this.#i18n.t('ui.panel.expand')}">
</button> </button>
<button class="${styles.controlButton} ${styles.stopButton}" title="${this.#i18n.t('ui.panel.stop')}"> <button class="${styles.controlButton} ${styles.stopButton}" title="${this.#i18n.t('ui.panel.close')}">
X X
</button> </button>
</div> </div>
@@ -420,10 +433,10 @@ export class Panel {
this.#toggle() this.#toggle()
}) })
// Stop button // Action button (stop / close)
this.#stopButton.addEventListener('click', (e) => { this.#actionButton.addEventListener('click', (e) => {
e.stopPropagation() e.stopPropagation()
this.#stopAgent() this.#handleActionButton()
}) })
// Submit on Enter key in input field // Submit on Enter key in input field

View File

@@ -68,6 +68,9 @@ export interface PanelAgentAdapter extends EventTarget {
/** Execute a task */ /** Execute a task */
execute(task: string): Promise<unknown> execute(task: string): Promise<unknown>
/** Dispose the agent */ /** Stop the current task (agent remains reusable) */
stop(): void
/** Dispose the agent (terminal, cannot be reused) */
dispose(): void dispose(): void
} }

View File

@@ -206,7 +206,6 @@ interface ExecuteConfig {
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
} }
type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult> type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
@@ -217,7 +216,7 @@ declare global {
PAGE_AGENT_EXT?: { PAGE_AGENT_EXT?: {
version: string version: string
execute: Execute execute: Execute
dispose: () => void stop: () => void
} }
} }
}` }`
@@ -237,7 +236,6 @@ interface ExecuteConfig {
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
} }
type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult> type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
@@ -248,7 +246,7 @@ declare global {
PAGE_AGENT_EXT?: { PAGE_AGENT_EXT?: {
version: string version: string
execute: Execute execute: Execute
dispose: () => void stop: () => void
} }
} }
}` }`
@@ -271,8 +269,7 @@ const result = await window.PAGE_AGENT_EXT.execute(
// includeInitialTab: false, // 设为 false 排除初始标签页 // includeInitialTab: false, // 设为 false 排除初始标签页
onStatusChange: status => console.log('状态变化:', status), onStatusChange: status => console.log('状态变化:', status),
onActivity: activity => console.log('活动:', activity), onActivity: activity => console.log('活动:', activity),
onHistoryUpdate: history => console.log('历史更新:', history), onHistoryUpdate: history => console.log('历史更新:', history)
onDispose: () => console.log('已停止')
} }
) )
@@ -287,8 +284,7 @@ const result = await window.PAGE_AGENT_EXT.execute(
// includeInitialTab: false, // Set to false to exclude initial tab // includeInitialTab: false, // Set to false to exclude initial tab
onStatusChange: status => console.log('Status change:', status), onStatusChange: status => console.log('Status change:', status),
onActivity: activity => console.log('Activity:', activity), onActivity: activity => console.log('Activity:', activity),
onHistoryUpdate: history => console.log('History update:', history), onHistoryUpdate: history => console.log('History update:', history)
onDispose: () => console.log('Disposed')
} }
) )
@@ -297,20 +293,18 @@ console.log(result) // Task execution result`
language="javascript" language="javascript"
/> />
<h3 className="text-xl font-semibold mt-6 mb-3">PAGE_AGENT_EXT.dispose()</h3> <h3 className="text-xl font-semibold mt-6 mb-3">PAGE_AGENT_EXT.stop()</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4"> <p className="text-gray-600 dark:text-gray-300 mb-4">
{isZh {isZh ? '停止当前正在运行的任务。' : 'Stop the current running task.'}
? '停止当前正在运行的任务。停止后 Agent 可以重新使用。'
: 'Stop the current running task. The agent can be reused after disposal.'}
</p> </p>
<CodeEditor <CodeEditor
code={ code={
isZh isZh
? `// 停止当前任务 ? `// 停止当前任务
window.PAGE_AGENT_EXT.dispose()` window.PAGE_AGENT_EXT.stop()`
: `// Stop current task execution : `// Stop current task execution
window.PAGE_AGENT_EXT.dispose()` window.PAGE_AGENT_EXT.stop()`
} }
language="javascript" language="javascript"
/> />