From 052a302a08413fa926e97fb78d57153a19efc0fd Mon Sep 17 00:00:00 2001 From: Simon <10131203+gaomeng1900@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:33:12 +0800 Subject: [PATCH 01/12] refactor(core)!: rework agent run lifecycle and status semantics BREAKING CHANGE: stop() is now async and resolves after the run fully settles; status decouples from task outcome (new 'stopped' state, LLM self-reported failure now ends as 'completed'). Lifecycle hooks re-throw instead of being folded into the result; agent errors go to history. Adds agent.lastResult. --- .vscode/settings.json | 1 + packages/core/src/PageAgentCore.test.ts | 120 ++++++++- packages/core/src/PageAgentCore.ts | 227 ++++++++++-------- packages/core/src/types.ts | 4 +- packages/extension/src/components/misc.tsx | 2 + packages/ui/src/panel/Panel.ts | 32 ++- packages/ui/src/panel/types.ts | 9 +- .../docs/advanced/page-agent-core/page.tsx | 8 +- 8 files changed, 268 insertions(+), 135 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index a2c1a48..77902a6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "cSpell.words": [ + "agentic", "contenteditable", "deepseek", "historychange", diff --git a/packages/core/src/PageAgentCore.test.ts b/packages/core/src/PageAgentCore.test.ts index 1739026..0518752 100644 --- a/packages/core/src/PageAgentCore.test.ts +++ b/packages/core/src/PageAgentCore.test.ts @@ -130,6 +130,16 @@ describe.concurrent('PageAgentCore lifecycle', () => { expect(fetchMock).toHaveBeenCalledTimes(1) }) + it('completes (not errors) when the LLM reports task failure', async () => { + const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('gave up', false)) + const agent = createAgent(fetchMock) + + const result = await agent.execute('do something') + + expect(result).toMatchObject({ success: false, data: 'gave up' }) + expect(agent.status).toBe('completed') + }) + it('throws when a task is already running', async () => { const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse()) const agent = createAgent(fetchMock) @@ -137,7 +147,7 @@ describe.concurrent('PageAgentCore lifecycle', () => { await expect(agent.execute('second')).rejects.toThrow('A task is already running.') - agent.stop() + await agent.stop() await result }) }) @@ -150,20 +160,30 @@ describe.concurrent('PageAgentCore lifecycle', () => { const agent = createAgent(fetchMock) const { result: firstTask } = await startBlockedTask(agent) - agent.stop() + await agent.stop() + expect(agent.status).toBe('stopped') await expect(firstTask).resolves.toMatchObject({ success: false, data: 'Task aborted' }) const secondTask = await agent.execute('second') expect(secondTask).toMatchObject({ success: true, data: 'second task' }) + expect(agent.status).toBe('completed') }) - it('is a no-op when no task is running', () => { + it('resolves only after the run has fully settled', async () => { + const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse()) + const agent = createAgent(fetchMock) + const { result } = await startBlockedTask(agent) + + await agent.stop() + expect(agent.status).toBe('stopped') + await expect(result).resolves.toMatchObject({ success: false }) + }) + + it('is a no-op when no task is running', async () => { const agent = createAgent(createFetchMock()) - expect(() => { - agent.stop() - agent.stop() - }).not.toThrow() + await expect(agent.stop()).resolves.toBeUndefined() + await expect(agent.stop()).resolves.toBeUndefined() expect(agent.status).toBe('idle') }) }) @@ -222,17 +242,95 @@ describe.concurrent('PageAgentCore lifecycle', () => { expect(result.success).toBe(false) expect(agent.status).toBe('error') }) + + it('re-throws and sets error status when onBeforeTask throws', async () => { + const agent = createAgent(createFetchMock(), { + onBeforeTask: async () => { + throw new Error('setup failed') + }, + }) + + await expect(agent.execute('do something')).rejects.toThrow('setup failed') + expect(agent.status).toBe('error') + expect(agent.history.some((e) => e.type === 'error')).toBe(false) + }) + + it('re-throws and sets error status when onAfterTask throws', async () => { + const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('all done')) + const agent = createAgent(fetchMock, { + onAfterTask: async () => { + throw new Error('teardown failed') + }, + }) + + await expect(agent.execute('do something')).rejects.toThrow('teardown failed') + expect(agent.status).toBe('error') + }) + + it('stays reusable after onBeforeTask throws', async () => { + const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('second')) + let failOnce = true + const agent = createAgent(fetchMock, { + onBeforeTask: async () => { + if (failOnce) { + failOnce = false + throw new Error('setup failed') + } + }, + }) + + await expect(agent.execute('first')).rejects.toThrow('setup failed') + const result = await agent.execute('second') + expect(result).toMatchObject({ success: true, data: 'second' }) + }) + + it('re-throws and sets error status when onBeforeStep throws', async () => { + const agent = createAgent(createFetchMock(), { + onBeforeStep: async () => { + throw new Error('before step failed') + }, + }) + + await expect(agent.execute('do something')).rejects.toThrow('before step failed') + expect(agent.status).toBe('error') + expect(agent.history.some((e) => e.type === 'error')).toBe(false) + }) + + it('re-throws and sets error status when onAfterStep throws', async () => { + // `done` breaks before onAfterStep, so use a non-terminal action. + const fetchMock = createFetchMock().mockResolvedValueOnce( + agentResponse({ action: { noop: {} } }) + ) + const agent = createAgent(fetchMock, { + customTools: { + noop: tool({ + description: 'No-op.', + inputSchema: z.object({}), + execute: async () => 'ok', + }), + }, + onAfterStep: async () => { + throw new Error('after step failed') + }, + }) + + await expect(agent.execute('do something')).rejects.toThrow('after step failed') + expect(agent.status).toBe('error') + expect(agent.history.some((e) => e.type === 'error')).toBe(false) + }) }) describe('cancellation edge cases', () => { - it('rejects a new task while a stopped task is settling', async () => { + it('rejects a new task while a stop is still settling', async () => { const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse()) const agent = createAgent(fetchMock) const { result: firstTask } = await startBlockedTask(agent) - agent.stop() + const stopped = agent.stop() await expect(agent.execute('too early')).rejects.toThrow('A task is already running.') + + await stopped await expect(firstTask).resolves.toMatchObject({ success: false, data: 'Task aborted' }) expect(fetchMock).toHaveBeenCalledTimes(1) }) @@ -266,10 +364,12 @@ describe.concurrent('PageAgentCore lifecycle', () => { const task = agent.execute('run slow tool') await toolStarted - agent.stop() + const stopped = agent.stop() resolveTool() + await stopped await expect(task).resolves.toMatchObject({ success: false, data: 'Task aborted' }) + expect(agent.status).toBe('stopped') }) }) }) diff --git a/packages/core/src/PageAgentCore.ts b/packages/core/src/PageAgentCore.ts index 9189389..35c1529 100644 --- a/packages/core/src/PageAgentCore.ts +++ b/packages/core/src/PageAgentCore.ts @@ -42,7 +42,7 @@ export type PageAgentCoreConfig = AgentConfig & { pageController: PageController * - loop * * ## Event System - * - `statuschange` - Agent status transitions (idle → running → completed/error) + * - `statuschange` - Agent status transitions (idle → running → completed/error/stopped) * - `historychange` - History events updated (persistent, part of agent memory) * - `activity` - Real-time activity feedback (transient, for UI only) * - `dispose` - Agent cleanup triggered @@ -91,6 +91,10 @@ export class PageAgentCore extends EventTarget { #abortController = new AbortController() #observations: string[] = [] + /** Resolves when the current run has fully settled. Awaited by `stop()`. */ + #running: Promise = Promise.resolve() + #lastResult: ExecutionResult | null = null + /** internal states during a single task execution */ #states = { /** Accumulated wait time in seconds */ @@ -147,13 +151,19 @@ export class PageAgentCore extends EventTarget { return this.#status } + /** Result of the most recent run, or `null` before the first run completes. */ + get lastResult(): ExecutionResult | null { + return this.#lastResult + } + /** Emit statuschange event */ #emitStatusChange(): void { this.dispatchEvent(new Event('statuschange')) } /** Emit historychange event */ - #emitHistoryChange(): void { + #emitHistoryChange(pushHistoricalEvent?: HistoricalEvent): void { + if (pushHistoricalEvent) this.history.push(pushHistoricalEvent) this.dispatchEvent(new Event('historychange')) } @@ -183,14 +193,22 @@ export class PageAgentCore extends EventTarget { this.#observations.push(content) } - /** Stop the current task. Agent remains reusable. */ - stop() { - this.pageController.cleanUpHighlights() - this.pageController.hideMask() + /** + * Stop the current task and wait until the run has fully settled. + * Once resolved, `status` is `stopped` and the agent can be reused. + */ + async stop(): Promise { + if (this.#status !== 'running') return this.#abortController.abort() + await this.#running } + /** + * external errors (pre-checks/config/hooks) will threw; + * agent errors will be caught and added to history, and return a failed result + */ async execute(task: string): Promise { + // pre-checks if (this.disposed) throw new Error('PageAgent has been disposed. Create a new instance.') if (this.#status === 'running') throw new Error('A task is already running.') if (!task) throw new Error('Task is required') @@ -206,131 +224,137 @@ export class PageAgentCore extends EventTarget { this.#setStatus('running') this.#emitHistoryChange() + let resolveRunning!: () => void + this.#running = new Promise((resolve) => (resolveRunning = resolve)) + // Disable ask_user tool if onAskUser is not set - if (!this.onAskUser) { - this.tools.delete('ask_user') - } + 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 + // graceful exit try { - await onBeforeTask?.(this) await this.pageController.showMask() - } catch (error) { - this.#setStatus('error') - throw error - } - let step = 0 - let taskSuccess: boolean - let taskResult: string + await onBeforeTask?.(this) - while (true) { - try { - console.group(`step: ${step}`) + let step = 0 + let taskResult: ExecutionResult + while (true) { await onBeforeStep?.(this, step) - // observe + // handle internal agent errors + try { + console.group(`step: ${step}`) - console.log(chalk.blue.bold('👀 Observing...')) + // observe - this.#states.browserState = await this.pageController.getBrowserState() - await this.#handleObservations(step) + console.log(chalk.blue.bold('👀 Observing...')) - // assemble prompts + this.#states.browserState = await this.pageController.getBrowserState() + await this.#handleObservations(step) - const messages = [ - { role: 'system' as const, content: this.#getSystemPrompt() }, - { role: 'user' as const, content: await this.#assembleUserPrompt() }, - ] + // assemble prompts - const macroTool = { AgentOutput: this.#packMacroTool() } + const messages = [ + { role: 'system' as const, content: this.#getSystemPrompt() }, + { role: 'user' as const, content: await this.#assembleUserPrompt() }, + ] - // invoke LLM + const macroTool = { AgentOutput: this.#packMacroTool() } - console.log(chalk.blue.bold('🧠 Thinking...')) - this.#emitActivity({ type: 'thinking' }) + // invoke LLM - const result = await this.#llm.invoke(messages, macroTool, this.#abortController.signal, { - toolChoiceName: 'AgentOutput', - normalizeResponse: (res) => normalizeResponse(res, this.tools), - }) + console.log(chalk.blue.bold('🧠 Thinking...')) + this.#emitActivity({ type: 'thinking' }) - // assemble history + const result = await this.#llm.invoke(messages, macroTool, this.#abortController.signal, { + toolChoiceName: 'AgentOutput', + normalizeResponse: (res) => normalizeResponse(res, this.tools), + }) - const macroResult = result.toolResult as MacroToolResult - const input = macroResult.input - const output = macroResult.output - const reflection: Partial = { - evaluation_previous_goal: input.evaluation_previous_goal, - memory: input.memory, - next_goal: input.next_goal, + // assemble history + + const macroResult = result.toolResult as MacroToolResult + const input = macroResult.input + const output = macroResult.output + const reflection: Partial = { + evaluation_previous_goal: input.evaluation_previous_goal, + memory: input.memory, + next_goal: input.next_goal, + } + const actionName = Object.keys(input.action)[0] + const action: AgentStepEvent['action'] = { + name: actionName, + input: input.action[actionName], + output: output, + } + + this.#emitHistoryChange({ + type: 'step', + stepIndex: step, + reflection, + action, + usage: result.usage, + rawResponse: result.rawResponse, + rawRequest: result.rawRequest, + }) + + if (actionName === 'done') { + const success = action.input?.success ?? false + const data = action.input?.text || 'no text provided' + console.log(chalk.green.bold('Task completed'), success, data) + taskResult = { success, data, history: this.history } + this.#setStatus('completed') + break + } + } catch (error: unknown) { + const isAbortError = (error as any)?.name === 'AbortError' + if (!isAbortError) console.error('Task failed', error) + const message = isAbortError ? 'Task aborted' : String(error) + this.#emitActivity({ type: 'error', message: message }) + this.#emitHistoryChange({ type: 'error', message: message, rawResponse: error }) + taskResult = { success: false, data: message, history: this.history } + this.#setStatus(isAbortError ? 'stopped' : 'error') + break + } finally { + console.groupEnd() } - const actionName = Object.keys(input.action)[0] - const action: AgentStepEvent['action'] = { - name: actionName, - input: input.action[actionName], - output: output, - } - - this.history.push({ - type: 'step', - stepIndex: step, - reflection, - action, - usage: result.usage, - rawResponse: result.rawResponse, - rawRequest: result.rawRequest, - } as AgentStepEvent) - this.#emitHistoryChange() await onAfterStep?.(this, this.history) - console.groupEnd() - - if (actionName === 'done') { - taskSuccess = action.input?.success ?? false - taskResult = action.input?.text || 'no text provided' - console.log(chalk.green.bold('Task completed'), taskSuccess, taskResult) + step++ + if (step > this.config.maxSteps) { + const message = 'Step count exceeded maximum limit' + console.error(message) + this.#emitActivity({ type: 'error', message: message }) + this.#emitHistoryChange({ type: 'error', message: message }) + taskResult = { success: false, data: message, history: this.history } + this.#setStatus('error') break } - } catch (error: unknown) { - console.groupEnd() - const isAbortError = (error as any)?.name === 'AbortError' - if (!isAbortError) console.error('Task failed', error) - taskResult = isAbortError ? 'Task aborted' : String(error) - taskSuccess = false - this.#emitActivity({ type: 'error', message: taskResult }) - this.history.push({ type: 'error', message: taskResult, rawResponse: error }) - this.#emitHistoryChange() - break - } - step++ - if (step > this.config.maxSteps) { - taskResult = 'Step count exceeded maximum limit' - taskSuccess = false - this.#emitActivity({ type: 'error', message: taskResult }) - this.history.push({ type: 'error', message: taskResult }) - this.#emitHistoryChange() - break - } + await waitFor(this.config.stepDelay ?? 0.4) + } // while - await waitFor(this.config.stepDelay ?? 0.4) + await onAfterTask?.(this, taskResult) + + this.#lastResult = taskResult + return taskResult + } catch (error) { + this.#emitActivity({ type: 'error', message: String(error) }) + this.#setStatus('error') + throw error + } finally { + this.pageController.cleanUpHighlights() + this.pageController.hideMask() + this.#abortController.abort() + resolveRunning() } - - this.#onDone(taskSuccess) - const result: ExecutionResult = { - success: taskSuccess, - data: taskResult, - history: this.history, - } - await onAfterTask?.(this, result) - return result } /** @@ -605,13 +629,6 @@ export class PageAgentCore extends EventTarget { return prompt } - #onDone(success = true) { - this.pageController.cleanUpHighlights() - this.pageController.hideMask() // No await - fire and forget - this.#setStatus(success ? 'completed' : 'error') - this.#abortController.abort() - } - dispose() { console.log('Disposing PageAgent...') this.disposed = true diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 9daff76..cdce4cb 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -262,9 +262,9 @@ export type HistoricalEvent = | AgentErrorEvent /** - * Agent execution status + * Agent lifecycle status. */ -export type AgentStatus = 'idle' | 'running' | 'completed' | 'error' +export type AgentStatus = 'idle' | 'running' | 'completed' | 'error' | 'stopped' /** * Agent activity - transient state for immediate UI feedback. diff --git a/packages/extension/src/components/misc.tsx b/packages/extension/src/components/misc.tsx index 66bfa48..1779e02 100644 --- a/packages/extension/src/components/misc.tsx +++ b/packages/extension/src/components/misc.tsx @@ -14,6 +14,7 @@ export function StatusDot({ status }: { status: AgentStatus }) { running: 'bg-blue-500', completed: 'bg-green-500', error: 'bg-destructive', + stopped: 'bg-muted-foreground', }[status] const label = { @@ -21,6 +22,7 @@ export function StatusDot({ status }: { status: AgentStatus }) { running: 'Running', completed: 'Done', error: 'Error', + stopped: 'Stopped', }[status] return ( diff --git a/packages/ui/src/panel/Panel.ts b/packages/ui/src/panel/Panel.ts index 3972839..a65d934 100644 --- a/packages/ui/src/panel/Panel.ts +++ b/packages/ui/src/panel/Panel.ts @@ -100,10 +100,10 @@ export class Panel { #handleStatusChange(): void { const status = this.#agent.status - // Map agent status to UI indicator type - const indicatorType = - status === 'running' ? 'thinking' : status === 'idle' ? 'thinking' : status - this.#updateStatusIndicator(indicatorType) + // Map agent status to UI indicator. A `completed` run whose result reports + // failure shows as error; other statuses map to their own indicator. + const failed = status === 'completed' && this.#agent.lastResult?.success === false + this.#updateStatusIndicator(failed ? 'error' : status) // Morph action button: running = stop (■), not running = close (X) if (status === 'running') { @@ -121,7 +121,7 @@ export class Panel { } // Handle completion - if (status === 'completed' || status === 'error') { + if (status === 'completed' || status === 'error' || status === 'stopped') { if (!this.#isExpanded) { this.#expand() } @@ -376,7 +376,7 @@ export class Panel { } const status = this.#agent.status - const isTaskEnded = status === 'completed' || status === 'error' + const isTaskEnded = status === 'completed' || status === 'error' || status === 'stopped' // Only show input area after task completion if configured to do so if (isTaskEnded) { @@ -559,13 +559,23 @@ export class Panel { } #updateStatusIndicator( - type: 'thinking' | 'executing' | 'executed' | 'retrying' | 'completed' | 'error' + type: + | 'idle' + | 'running' + | 'thinking' + | 'executing' + | 'executed' + | 'retrying' + | 'completed' + | 'error' + | 'stopped' ): void { - // Clear all status classes + // `running` animates like thinking; `idle`/`stopped` use the neutral base. + const variant = type === 'running' ? 'thinking' : type this.#indicator.className = styles.indicator - - // Add corresponding status class - this.#indicator.classList.add(styles[type]) + if (variant !== 'idle' && variant !== 'stopped') { + this.#indicator.classList.add(styles[variant]) + } } #scrollToBottom(): void { diff --git a/packages/ui/src/panel/types.ts b/packages/ui/src/panel/types.ts index 9e65b8a..d3ac663 100644 --- a/packages/ui/src/panel/types.ts +++ b/packages/ui/src/panel/types.ts @@ -22,14 +22,17 @@ export type AgentActivity = * This enables decoupling and allows any agent implementation to work with Panel. * * Events: - * - 'statuschange': Agent status changed (idle/running/completed/error) + * - 'statuschange': Agent status changed * - 'historychange': Historical events updated (persisted) * - 'activity': Transient activity for immediate UI feedback (thinking/executing/etc) * - 'dispose': Agent is being disposed */ export interface PanelAgentAdapter extends EventTarget { /** Current agent status */ - readonly status: 'idle' | 'running' | 'completed' | 'error' + readonly status: 'idle' | 'running' | 'completed' | 'error' | 'stopped' + + /** Result of the most recent run, or `null` before the first run completes */ + readonly lastResult: { success: boolean } | null /** History of agent events */ readonly history: readonly { @@ -71,7 +74,7 @@ export interface PanelAgentAdapter extends EventTarget { execute(task: string): Promise /** Stop the current task (agent remains reusable) */ - stop(): void + stop(): Promise /** Dispose the agent (terminal, cannot be reused) */ dispose(): void diff --git a/packages/website/src/pages/docs/advanced/page-agent-core/page.tsx b/packages/website/src/pages/docs/advanced/page-agent-core/page.tsx index 4a08017..70e7770 100644 --- a/packages/website/src/pages/docs/advanced/page-agent-core/page.tsx +++ b/packages/website/src/pages/docs/advanced/page-agent-core/page.tsx @@ -325,7 +325,7 @@ const result = await agent.execute('Fill in the form with test data')`} properties={[ { name: 'status', - type: "'idle' | 'running' | 'completed' | 'error'", + type: "'idle' | 'running' | 'completed' | 'error' | 'stopped'", description: isZh ? '当前 Agent 执行状态' : 'Current agent execution status', }, { @@ -378,10 +378,10 @@ const result = await agent.execute('Fill in the form with test data')`} }, { name: 'stop()', - type: 'void', + type: 'Promise', description: isZh - ? '停止当前任务。Agent 仍可复用。' - : 'Stop the current task. Agent remains reusable.', + ? '停止当前任务,并在任务完全结束后 resolve。Agent 仍可复用。' + : 'Stop the current task; resolves once the run has fully settled. Agent remains reusable.', }, { name: 'dispose()', From 1deaa2147e827c0e3dd354b839e4b13dc5fe1497 Mon Sep 17 00:00:00 2001 From: Simon <10131203+gaomeng1900@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:39:35 +0800 Subject: [PATCH 02/12] fix: mke sure `taskResult` and `onAfterStep` wont get lost --- packages/core/src/PageAgentCore.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/core/src/PageAgentCore.ts b/packages/core/src/PageAgentCore.ts index 35c1529..c8c92b7 100644 --- a/packages/core/src/PageAgentCore.ts +++ b/packages/core/src/PageAgentCore.ts @@ -309,23 +309,31 @@ export class PageAgentCore extends EventTarget { const data = action.input?.text || 'no text provided' console.log(chalk.green.bold('Task completed'), success, data) taskResult = { success, data, history: this.history } + this.#lastResult = taskResult this.#setStatus('completed') break } } catch (error: unknown) { + // catch block must not throw error. otherwise the error may be overridden if finally block also throws error. + const isAbortError = (error as any)?.name === 'AbortError' if (!isAbortError) console.error('Task failed', error) const message = isAbortError ? 'Task aborted' : String(error) this.#emitActivity({ type: 'error', message: message }) this.#emitHistoryChange({ type: 'error', message: message, rawResponse: error }) taskResult = { success: false, data: message, history: this.history } + this.#lastResult = taskResult this.#setStatus(isAbortError ? 'stopped' : 'error') break } finally { - console.groupEnd() - } + // finally block runs before the break above. - await onAfterStep?.(this, this.history) + console.groupEnd() + // @note hook may throw error. + // which will override the `break` above and be handled as an external error. + // as expected. + await onAfterStep?.(this, this.history) + } step++ if (step > this.config.maxSteps) { @@ -334,6 +342,7 @@ export class PageAgentCore extends EventTarget { this.#emitActivity({ type: 'error', message: message }) this.#emitHistoryChange({ type: 'error', message: message }) taskResult = { success: false, data: message, history: this.history } + this.#lastResult = taskResult this.#setStatus('error') break } @@ -343,7 +352,6 @@ export class PageAgentCore extends EventTarget { await onAfterTask?.(this, taskResult) - this.#lastResult = taskResult return taskResult } catch (error) { this.#emitActivity({ type: 'error', message: String(error) }) From c2d6a864f8ef6b42d03dfb35e1117fd8a20edf0e Mon Sep 17 00:00:00 2001 From: Simon <10131203+gaomeng1900@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:26:05 +0800 Subject: [PATCH 03/12] refactor(core): settle terminal status only after run cleanup completes Defer the terminal statuschange to the outer finally via settleRun, closing the window where a listener could re-enter execute() during teardown. Also check abort at step start so aborts during stepDelay settle as `stopped`. --- packages/core/src/PageAgentCore.ts | 32 ++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/core/src/PageAgentCore.ts b/packages/core/src/PageAgentCore.ts index c8c92b7..804d8e2 100644 --- a/packages/core/src/PageAgentCore.ts +++ b/packages/core/src/PageAgentCore.ts @@ -195,7 +195,6 @@ export class PageAgentCore extends EventTarget { /** * Stop the current task and wait until the run has fully settled. - * Once resolved, `status` is `stopped` and the agent can be reused. */ async stop(): Promise { if (this.#status !== 'running') return @@ -224,8 +223,14 @@ export class PageAgentCore extends EventTarget { this.#setStatus('running') this.#emitHistoryChange() - let resolveRunning!: () => void - this.#running = new Promise((resolve) => (resolveRunning = resolve)) + let settleRun!: (status: AgentStatus) => void + this.#running = new Promise( + (resolve) => + (settleRun = (status) => { + this.#setStatus(status) + resolve() + }) + ) // Disable ask_user tool if onAskUser is not set if (!this.onAskUser) this.tools.delete('ask_user') @@ -235,15 +240,16 @@ export class PageAgentCore extends EventTarget { const onBeforeTask = this.config.onBeforeTask const onAfterTask = this.config.onAfterTask + let step = 0 + let taskResult: ExecutionResult + let finalStatus: AgentStatus = 'error' + // graceful exit try { await this.pageController.showMask() await onBeforeTask?.(this) - let step = 0 - let taskResult: ExecutionResult - while (true) { await onBeforeStep?.(this, step) @@ -251,6 +257,10 @@ export class PageAgentCore extends EventTarget { try { console.group(`step: ${step}`) + // Abort may have fired between steps (e.g. during stepDelay). + // Check inside the try so it is classified as `stopped`, and skip the observe phase. + this.#abortController.signal.throwIfAborted() + // observe console.log(chalk.blue.bold('👀 Observing...')) @@ -310,7 +320,7 @@ export class PageAgentCore extends EventTarget { console.log(chalk.green.bold('Task completed'), success, data) taskResult = { success, data, history: this.history } this.#lastResult = taskResult - this.#setStatus('completed') + finalStatus = 'completed' break } } catch (error: unknown) { @@ -323,7 +333,7 @@ export class PageAgentCore extends EventTarget { this.#emitHistoryChange({ type: 'error', message: message, rawResponse: error }) taskResult = { success: false, data: message, history: this.history } this.#lastResult = taskResult - this.#setStatus(isAbortError ? 'stopped' : 'error') + finalStatus = isAbortError ? 'stopped' : 'error' break } finally { // finally block runs before the break above. @@ -343,7 +353,7 @@ export class PageAgentCore extends EventTarget { this.#emitHistoryChange({ type: 'error', message: message }) taskResult = { success: false, data: message, history: this.history } this.#lastResult = taskResult - this.#setStatus('error') + finalStatus = 'error' break } @@ -355,13 +365,13 @@ export class PageAgentCore extends EventTarget { return taskResult } catch (error) { this.#emitActivity({ type: 'error', message: String(error) }) - this.#setStatus('error') + finalStatus = 'error' throw error } finally { this.pageController.cleanUpHighlights() this.pageController.hideMask() this.#abortController.abort() - resolveRunning() + settleRun(finalStatus) } } From 8f9a637bdb53b5029fd36dffadb962bb420192b2 Mon Sep 17 00:00:00 2001 From: Simon <10131203+gaomeng1900@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:26:42 +0800 Subject: [PATCH 04/12] refactor(core): suppress mask/highlight errors instead of failing the run Visual feedback failures (showMask, hideMask, cleanUpHighlights) are non-critical; log them instead of aborting the task or masking the original error during teardown. --- packages/core/src/PageAgentCore.ts | 10 +++++----- packages/core/src/utils/index.ts | 12 ++++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/core/src/PageAgentCore.ts b/packages/core/src/PageAgentCore.ts index 804d8e2..a61eda3 100644 --- a/packages/core/src/PageAgentCore.ts +++ b/packages/core/src/PageAgentCore.ts @@ -21,7 +21,7 @@ import type { MacroToolInput, MacroToolResult, } from './types' -import { assert, fetchLlmsTxt, normalizeResponse, uid, waitFor } from './utils' +import { assert, fetchLlmsTxt, normalizeResponse, suppress, uid, waitFor } from './utils' export { tool, type PageAgentTool } from './tools' export type * from './types' @@ -244,10 +244,10 @@ export class PageAgentCore extends EventTarget { let taskResult: ExecutionResult let finalStatus: AgentStatus = 'error' + await suppress(() => this.pageController.showMask()) + // graceful exit try { - await this.pageController.showMask() - await onBeforeTask?.(this) while (true) { @@ -368,8 +368,8 @@ export class PageAgentCore extends EventTarget { finalStatus = 'error' throw error } finally { - this.pageController.cleanUpHighlights() - this.pageController.hideMask() + suppress(() => this.pageController.cleanUpHighlights()) + suppress(() => this.pageController.hideMask()) this.#abortController.abort() settleRun(finalStatus) } diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index f030864..cecbaa9 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -129,3 +129,15 @@ export function assert(condition: unknown, message?: string, silent?: boolean): throw new Error(errorMessage) } } + +/** + * Suppress errors from a function. + */ +export function suppress(fn: () => T): T | undefined { + try { + return fn() + } catch (error) { + console.error(error) + return undefined + } +} From 4690aefec5944dc1eef250e0e9639fdb58c22e2f Mon Sep 17 00:00:00 2001 From: Simon <10131203+gaomeng1900@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:21:11 +0800 Subject: [PATCH 05/12] refactor(core): decouple run settling from terminal status transition Resolve #running before the terminal statuschange so the settle signal can never be lost to re-entrant listeners. Hooks keep middleware semantics: a throwing hook fails the run; integrations that don't want this should suppress errors in their own hooks. Also make suppress() async-aware so rejected promises (e.g. showMask) are actually caught. --- packages/core/src/PageAgentCore.ts | 19 +++++++------------ packages/core/src/utils/index.ts | 4 ++-- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/packages/core/src/PageAgentCore.ts b/packages/core/src/PageAgentCore.ts index a61eda3..1294140 100644 --- a/packages/core/src/PageAgentCore.ts +++ b/packages/core/src/PageAgentCore.ts @@ -194,7 +194,8 @@ export class PageAgentCore extends EventTarget { } /** - * Stop the current task and wait until the run has fully settled. + * Stop the current task and wait until the run has fully settled (including lifecycle hooks). + * @note never await .stop() in a lifecycle hook. */ async stop(): Promise { if (this.#status !== 'running') return @@ -223,14 +224,8 @@ export class PageAgentCore extends EventTarget { this.#setStatus('running') this.#emitHistoryChange() - let settleRun!: (status: AgentStatus) => void - this.#running = new Promise( - (resolve) => - (settleRun = (status) => { - this.#setStatus(status) - resolve() - }) - ) + let resolveRunning!: () => void + this.#running = new Promise((r) => (resolveRunning = r)) // Disable ask_user tool if onAskUser is not set if (!this.onAskUser) this.tools.delete('ask_user') @@ -257,8 +252,7 @@ export class PageAgentCore extends EventTarget { try { console.group(`step: ${step}`) - // Abort may have fired between steps (e.g. during stepDelay). - // Check inside the try so it is classified as `stopped`, and skip the observe phase. + // inside the try: abort between steps must settle as `stopped` this.#abortController.signal.throwIfAborted() // observe @@ -371,7 +365,8 @@ export class PageAgentCore extends EventTarget { suppress(() => this.pageController.cleanUpHighlights()) suppress(() => this.pageController.hideMask()) this.#abortController.abort() - settleRun(finalStatus) + resolveRunning() + this.#setStatus(finalStatus) } } diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index cecbaa9..fe74fb1 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -133,9 +133,9 @@ export function assert(condition: unknown, message?: string, silent?: boolean): /** * Suppress errors from a function. */ -export function suppress(fn: () => T): T | undefined { +export async function suppress(fn: () => T | Promise): Promise | undefined> { try { - return fn() + return await fn() } catch (error) { console.error(error) return undefined From 255e8fc8616ef1be49f1a18c48db1dcb61536a0b Mon Sep 17 00:00:00 2001 From: Simon <10131203+gaomeng1900@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:29:54 +0800 Subject: [PATCH 06/12] chore: update docs --- .../website/src/pages/docs/advanced/custom-ui/page.tsx | 4 ++-- .../src/pages/docs/advanced/page-agent-core/page.tsx | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/website/src/pages/docs/advanced/custom-ui/page.tsx b/packages/website/src/pages/docs/advanced/custom-ui/page.tsx index 3ebc83c..788a32d 100644 --- a/packages/website/src/pages/docs/advanced/custom-ui/page.tsx +++ b/packages/website/src/pages/docs/advanced/custom-ui/page.tsx @@ -128,8 +128,8 @@ export default function CustomUIDocs() { name: 'statuschange', type: 'Event', description: isZh - ? 'Agent 状态变化 (idle → running → completed/error)' - : 'Agent status changes (idle → running → completed/error)', + ? 'Agent 状态变化 (idle → running → completed/error/stopped)' + : 'Agent status changes (idle → running → completed/error/stopped)', }, { name: 'historychange', diff --git a/packages/website/src/pages/docs/advanced/page-agent-core/page.tsx b/packages/website/src/pages/docs/advanced/page-agent-core/page.tsx index 70e7770..40f5cf1 100644 --- a/packages/website/src/pages/docs/advanced/page-agent-core/page.tsx +++ b/packages/website/src/pages/docs/advanced/page-agent-core/page.tsx @@ -281,8 +281,8 @@ const result = await agent.execute('Fill in the form with test data')`}

{isZh - ? '这些接口高度实验性,可能在未来版本中发生变化。' - : 'These APIs are highly experimental and may change in future versions. '} + ? '这些接口高度实验性,可能在未来版本中发生变化。钩子中抛出的错误会使任务失败并从 execute() 抛出;如不希望影响任务,请在钩子内部自行捕获。' + : 'These APIs are highly experimental and may change in future versions. Errors thrown from hooks fail the run and propagate from execute(); catch errors inside the hook if the task should not be affected.'}

Date: Thu, 11 Jun 2026 19:15:27 +0800 Subject: [PATCH 07/12] fix(core): harden run settlement edge cases from review - install #running before the `running` statuschange fires, so a listener calling stop() immediately awaits the current run - await async mask/highlight cleanup before settling: once settled, the agent must be safely reusable - make the inter-step delay abortable so stop() settles promptly; abort during the delay is classified as `stopped` --- packages/core/src/PageAgentCore.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/core/src/PageAgentCore.ts b/packages/core/src/PageAgentCore.ts index 1294140..9438498 100644 --- a/packages/core/src/PageAgentCore.ts +++ b/packages/core/src/PageAgentCore.ts @@ -220,13 +220,14 @@ export class PageAgentCore extends EventTarget { this.#observations = [] this.#states = { totalWaitTime: 0, lastURL: '', browserState: null } this.#abortController = new AbortController() - - this.#setStatus('running') - this.#emitHistoryChange() + const signal = this.#abortController.signal let resolveRunning!: () => void this.#running = new Promise((r) => (resolveRunning = r)) + this.#setStatus('running') + this.#emitHistoryChange() + // Disable ask_user tool if onAskUser is not set if (!this.onAskUser) this.tools.delete('ask_user') @@ -234,6 +235,8 @@ export class PageAgentCore extends EventTarget { const onAfterStep = this.config.onAfterStep const onBeforeTask = this.config.onBeforeTask const onAfterTask = this.config.onAfterTask + const stepDelay = this.config.stepDelay ?? 0.4 + const maxSteps = this.config.maxSteps let step = 0 let taskResult: ExecutionResult @@ -252,8 +255,9 @@ export class PageAgentCore extends EventTarget { try { console.group(`step: ${step}`) - // inside the try: abort between steps must settle as `stopped` - this.#abortController.signal.throwIfAborted() + if (step > 0) await waitFor(stepDelay, signal) + + signal.throwIfAborted() // observe @@ -276,7 +280,7 @@ export class PageAgentCore extends EventTarget { console.log(chalk.blue.bold('🧠 Thinking...')) this.#emitActivity({ type: 'thinking' }) - const result = await this.#llm.invoke(messages, macroTool, this.#abortController.signal, { + const result = await this.#llm.invoke(messages, macroTool, signal, { toolChoiceName: 'AgentOutput', normalizeResponse: (res) => normalizeResponse(res, this.tools), }) @@ -340,7 +344,7 @@ export class PageAgentCore extends EventTarget { } step++ - if (step > this.config.maxSteps) { + if (step > maxSteps) { const message = 'Step count exceeded maximum limit' console.error(message) this.#emitActivity({ type: 'error', message: message }) @@ -350,8 +354,6 @@ export class PageAgentCore extends EventTarget { finalStatus = 'error' break } - - await waitFor(this.config.stepDelay ?? 0.4) } // while await onAfterTask?.(this, taskResult) @@ -362,8 +364,8 @@ export class PageAgentCore extends EventTarget { finalStatus = 'error' throw error } finally { - suppress(() => this.pageController.cleanUpHighlights()) - suppress(() => this.pageController.hideMask()) + await suppress(() => this.pageController.cleanUpHighlights()) + await suppress(() => this.pageController.hideMask()) this.#abortController.abort() resolveRunning() this.#setStatus(finalStatus) From c19891926b774b3b3b7b80b7229191bb7b730e3d Mon Sep 17 00:00:00 2001 From: Simon <10131203+gaomeng1900@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:15:48 +0800 Subject: [PATCH 08/12] refactor(ext): drive heartbeat and running flag from statuschange Project agent status into chrome.storage via a statuschange listener instead of pairing setup/teardown across lifecycle hooks. A throwing hook can no longer leak the heartbeat or strand isAgentRunning, and rejected concurrent execute() calls never touch the active run's state. --- .../extension/src/agent/MultiPageAgent.ts | 66 ++++++++----------- 1 file changed, 27 insertions(+), 39 deletions(-) diff --git a/packages/extension/src/agent/MultiPageAgent.ts b/packages/extension/src/agent/MultiPageAgent.ts index aa768bc..2418782 100644 --- a/packages/extension/src/agent/MultiPageAgent.ts +++ b/packages/extension/src/agent/MultiPageAgent.ts @@ -39,15 +39,6 @@ export class MultiPageAgent extends PageAgentCore { const includeInitialTab = config.includeInitialTab ?? true const experimentalIncludeAllTabs = config.experimentalIncludeAllTabs ?? false - /** - * When the agent is in side-panel and user closed the side-panel. - * There is no chance for isAgentRunning to be set false. - * (unload event doesn't work well in side panel.) - * (I'm trying not to use long-lived connection because the lifecycle of a sw is hard to predict.) - * This heartbeat mechanism acts as a backup. - */ - let heartBeatInterval: null | number = null - super({ ...config, pageController: pageController as any, @@ -56,27 +47,6 @@ export class MultiPageAgent extends PageAgentCore { onBeforeTask: async (agent) => { await tabsController.init(agent.task, { includeInitialTab, experimentalIncludeAllTabs }) - - heartBeatInterval = window.setInterval(() => { - chrome.storage.local.set({ - agentHeartbeat: Date.now(), - }) - }, 1_000) - - await chrome.storage.local.set({ - isAgentRunning: true, - }) - }, - - onAfterTask: async () => { - if (heartBeatInterval) { - window.clearInterval(heartBeatInterval) - heartBeatInterval = null - } - - await chrome.storage.local.set({ - isAgentRunning: false, - }) }, onBeforeStep: async (agent) => { @@ -86,17 +56,35 @@ export class MultiPageAgent extends PageAgentCore { }, onDispose: () => { - if (heartBeatInterval) { - window.clearInterval(heartBeatInterval) - heartBeatInterval = null - } - - chrome.storage.local.set({ - isAgentRunning: false, - }) - tabsController.dispose() }, }) + + /** + * Project agent status into chrome.storage. The content script polls + * `isAgentRunning` + `agentHeartbeat` (eventually consistent by design). + * + * When the agent is in side-panel and user closed the side-panel. + * There is no chance for isAgentRunning to be set false. + * (unload event doesn't work well in side panel.) + * (I'm trying not to use long-lived connection because the lifecycle of a sw is hard to predict.) + * This heartbeat mechanism acts as a backup. + */ + let heartBeatInterval: number | null = null + + this.addEventListener('statuschange', () => { + const running = this.status === 'running' + + if (running && !heartBeatInterval) { + heartBeatInterval = window.setInterval(() => { + void chrome.storage.local.set({ agentHeartbeat: Date.now() }) + }, 1_000) + } else if (!running && heartBeatInterval) { + clearInterval(heartBeatInterval) + heartBeatInterval = null + } + + chrome.storage.local.set({ isAgentRunning: running }).catch(console.error) + }) } } From e270ba15b509573f65fdd15c9fe952cb5d351f60 Mon Sep 17 00:00:00 2001 From: Simon <10131203+gaomeng1900@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:15:58 +0800 Subject: [PATCH 09/12] fix(ext): clear stale activity on any non-running status The side panel kept showing the last activity card when a run settled as `stopped`; clear it for every terminal status instead of an allowlist. --- packages/extension/src/agent/useAgent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/extension/src/agent/useAgent.ts b/packages/extension/src/agent/useAgent.ts index cfc7702..23c8ba3 100644 --- a/packages/extension/src/agent/useAgent.ts +++ b/packages/extension/src/agent/useAgent.ts @@ -80,7 +80,7 @@ export function useAgent(): UseAgentResult { const handleStatusChange = (e: Event) => { const newStatus = agent.status as AgentStatus setStatus(newStatus) - if (newStatus === 'idle' || newStatus === 'completed' || newStatus === 'error') { + if (newStatus !== 'running') { setActivity(null) } } From edb769b8267c7270223065107b3fbd9df24872ba Mon Sep 17 00:00:00 2001 From: Simon <10131203+gaomeng1900@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:53:30 +0800 Subject: [PATCH 10/12] fix(ext): handle stopped lifecycle state --- packages/extension/docs/extension_api.md | 2 +- .../extension/src/agent/MultiPageAgent.ts | 30 +++++++++++-------- .../src/entrypoints/sidepanel/App.tsx | 2 +- packages/extension/src/lib/db.ts | 2 +- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/packages/extension/docs/extension_api.md b/packages/extension/docs/extension_api.md index ab661d2..bb48461 100644 --- a/packages/extension/docs/extension_api.md +++ b/packages/extension/docs/extension_api.md @@ -131,7 +131,7 @@ export type Execute = (task: string, config: ExecuteConfig) => Promise { + if (heartBeatInterval) { + clearInterval(heartBeatInterval) + heartBeatInterval = null + } + chrome.storage.local.set({ isAgentRunning: false }).catch(console.error) + tabsController.dispose() }, }) - /** - * Project agent status into chrome.storage. The content script polls - * `isAgentRunning` + `agentHeartbeat` (eventually consistent by design). - * - * When the agent is in side-panel and user closed the side-panel. - * There is no chance for isAgentRunning to be set false. - * (unload event doesn't work well in side panel.) - * (I'm trying not to use long-lived connection because the lifecycle of a sw is hard to predict.) - * This heartbeat mechanism acts as a backup. - */ - let heartBeatInterval: number | null = null - this.addEventListener('statuschange', () => { const running = this.status === 'running' diff --git a/packages/extension/src/entrypoints/sidepanel/App.tsx b/packages/extension/src/entrypoints/sidepanel/App.tsx index 8fe5c17..e54948f 100644 --- a/packages/extension/src/entrypoints/sidepanel/App.tsx +++ b/packages/extension/src/entrypoints/sidepanel/App.tsx @@ -39,7 +39,7 @@ export default function App() { if ( prev === 'running' && - (status === 'completed' || status === 'error') && + (status === 'completed' || status === 'error' || status === 'stopped') && history.length > 0 && currentTask ) { diff --git a/packages/extension/src/lib/db.ts b/packages/extension/src/lib/db.ts index 9e520a8..e0db44c 100644 --- a/packages/extension/src/lib/db.ts +++ b/packages/extension/src/lib/db.ts @@ -8,7 +8,7 @@ export interface SessionRecord { id: string task: string history: HistoricalEvent[] - status: 'completed' | 'error' + status: 'completed' | 'error' | 'stopped' createdAt: number } From 4828f9f7260ddf714b5da112a2dc67914077e5c7 Mon Sep 17 00:00:00 2001 From: Simon <10131203+gaomeng1900@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:06:35 +0800 Subject: [PATCH 11/12] chore: update tests --- packages/core/src/PageAgentCore.test.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/core/src/PageAgentCore.test.ts b/packages/core/src/PageAgentCore.test.ts index 0518752..5dc8010 100644 --- a/packages/core/src/PageAgentCore.test.ts +++ b/packages/core/src/PageAgentCore.test.ts @@ -297,18 +297,8 @@ describe.concurrent('PageAgentCore lifecycle', () => { }) it('re-throws and sets error status when onAfterStep throws', async () => { - // `done` breaks before onAfterStep, so use a non-terminal action. - const fetchMock = createFetchMock().mockResolvedValueOnce( - agentResponse({ action: { noop: {} } }) - ) + const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('all done')) const agent = createAgent(fetchMock, { - customTools: { - noop: tool({ - description: 'No-op.', - inputSchema: z.object({}), - execute: async () => 'ok', - }), - }, onAfterStep: async () => { throw new Error('after step failed') }, From 4e881f7eb07690121d1d8c3af740f7634d481063 Mon Sep 17 00:00:00 2001 From: Simon <10131203+gaomeng1900@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:31:01 +0800 Subject: [PATCH 12/12] chore: add notes for a pending design decision --- packages/core/src/PageAgentCore.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/PageAgentCore.ts b/packages/core/src/PageAgentCore.ts index 9438498..86af83f 100644 --- a/packages/core/src/PageAgentCore.ts +++ b/packages/core/src/PageAgentCore.ts @@ -255,6 +255,8 @@ export class PageAgentCore extends EventTarget { try { console.group(`step: ${step}`) + // @note It's convenient to treat stepDelay as part of the next step. + // Maybe move it to a dedicated try block for better semantics? if (step > 0) await waitFor(stepDelay, signal) signal.throwIfAborted()