Merge pull request #549 from alibaba/refactor/agent-lifecycle-state-machine
refactor(core)!: rework agent run lifecycle and status semantics
This commit is contained in:
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
|
"agentic",
|
||||||
"contenteditable",
|
"contenteditable",
|
||||||
"deepseek",
|
"deepseek",
|
||||||
"historychange",
|
"historychange",
|
||||||
|
|||||||
@@ -130,6 +130,16 @@ describe.concurrent('PageAgentCore lifecycle', () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
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 () => {
|
it('throws when a task is already running', async () => {
|
||||||
const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse())
|
const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse())
|
||||||
const agent = createAgent(fetchMock)
|
const agent = createAgent(fetchMock)
|
||||||
@@ -137,7 +147,7 @@ describe.concurrent('PageAgentCore lifecycle', () => {
|
|||||||
|
|
||||||
await expect(agent.execute('second')).rejects.toThrow('A task is already running.')
|
await expect(agent.execute('second')).rejects.toThrow('A task is already running.')
|
||||||
|
|
||||||
agent.stop()
|
await agent.stop()
|
||||||
await result
|
await result
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -150,20 +160,30 @@ describe.concurrent('PageAgentCore lifecycle', () => {
|
|||||||
const agent = createAgent(fetchMock)
|
const agent = createAgent(fetchMock)
|
||||||
const { result: firstTask } = await startBlockedTask(agent)
|
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' })
|
await expect(firstTask).resolves.toMatchObject({ success: false, data: 'Task aborted' })
|
||||||
|
|
||||||
const secondTask = await agent.execute('second')
|
const secondTask = await agent.execute('second')
|
||||||
expect(secondTask).toMatchObject({ success: true, data: 'second task' })
|
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())
|
const agent = createAgent(createFetchMock())
|
||||||
|
|
||||||
expect(() => {
|
await expect(agent.stop()).resolves.toBeUndefined()
|
||||||
agent.stop()
|
await expect(agent.stop()).resolves.toBeUndefined()
|
||||||
agent.stop()
|
|
||||||
}).not.toThrow()
|
|
||||||
expect(agent.status).toBe('idle')
|
expect(agent.status).toBe('idle')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -222,17 +242,85 @@ describe.concurrent('PageAgentCore lifecycle', () => {
|
|||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
expect(agent.status).toBe('error')
|
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 () => {
|
||||||
|
const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('all done'))
|
||||||
|
const agent = createAgent(fetchMock, {
|
||||||
|
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', () => {
|
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 fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse())
|
||||||
const agent = createAgent(fetchMock)
|
const agent = createAgent(fetchMock)
|
||||||
const { result: firstTask } = await startBlockedTask(agent)
|
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 expect(agent.execute('too early')).rejects.toThrow('A task is already running.')
|
||||||
|
|
||||||
|
await stopped
|
||||||
await expect(firstTask).resolves.toMatchObject({ success: false, data: 'Task aborted' })
|
await expect(firstTask).resolves.toMatchObject({ success: false, data: 'Task aborted' })
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
@@ -266,10 +354,12 @@ describe.concurrent('PageAgentCore lifecycle', () => {
|
|||||||
const task = agent.execute('run slow tool')
|
const task = agent.execute('run slow tool')
|
||||||
await toolStarted
|
await toolStarted
|
||||||
|
|
||||||
agent.stop()
|
const stopped = agent.stop()
|
||||||
resolveTool()
|
resolveTool()
|
||||||
|
await stopped
|
||||||
|
|
||||||
await expect(task).resolves.toMatchObject({ success: false, data: 'Task aborted' })
|
await expect(task).resolves.toMatchObject({ success: false, data: 'Task aborted' })
|
||||||
|
expect(agent.status).toBe('stopped')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import type {
|
|||||||
MacroToolInput,
|
MacroToolInput,
|
||||||
MacroToolResult,
|
MacroToolResult,
|
||||||
} from './types'
|
} 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 { tool, type PageAgentTool } from './tools'
|
||||||
export type * from './types'
|
export type * from './types'
|
||||||
@@ -42,7 +42,7 @@ export type PageAgentCoreConfig = AgentConfig & { pageController: PageController
|
|||||||
* - loop
|
* - loop
|
||||||
*
|
*
|
||||||
* ## Event System
|
* ## 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)
|
* - `historychange` - History events updated (persistent, part of agent memory)
|
||||||
* - `activity` - Real-time activity feedback (transient, for UI only)
|
* - `activity` - Real-time activity feedback (transient, for UI only)
|
||||||
* - `dispose` - Agent cleanup triggered
|
* - `dispose` - Agent cleanup triggered
|
||||||
@@ -91,6 +91,10 @@ export class PageAgentCore extends EventTarget {
|
|||||||
#abortController = new AbortController()
|
#abortController = new AbortController()
|
||||||
#observations: string[] = []
|
#observations: string[] = []
|
||||||
|
|
||||||
|
/** Resolves when the current run has fully settled. Awaited by `stop()`. */
|
||||||
|
#running: Promise<void> = Promise.resolve()
|
||||||
|
#lastResult: ExecutionResult | null = null
|
||||||
|
|
||||||
/** internal states during a single task execution */
|
/** internal states during a single task execution */
|
||||||
#states = {
|
#states = {
|
||||||
/** Accumulated wait time in seconds */
|
/** Accumulated wait time in seconds */
|
||||||
@@ -147,13 +151,19 @@ export class PageAgentCore extends EventTarget {
|
|||||||
return this.#status
|
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 */
|
/** Emit statuschange event */
|
||||||
#emitStatusChange(): void {
|
#emitStatusChange(): void {
|
||||||
this.dispatchEvent(new Event('statuschange'))
|
this.dispatchEvent(new Event('statuschange'))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Emit historychange event */
|
/** Emit historychange event */
|
||||||
#emitHistoryChange(): void {
|
#emitHistoryChange(pushHistoricalEvent?: HistoricalEvent): void {
|
||||||
|
if (pushHistoricalEvent) this.history.push(pushHistoricalEvent)
|
||||||
this.dispatchEvent(new Event('historychange'))
|
this.dispatchEvent(new Event('historychange'))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,14 +193,22 @@ export class PageAgentCore extends EventTarget {
|
|||||||
this.#observations.push(content)
|
this.#observations.push(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop the current task. Agent remains reusable. */
|
/**
|
||||||
stop() {
|
* Stop the current task and wait until the run has fully settled (including lifecycle hooks).
|
||||||
this.pageController.cleanUpHighlights()
|
* @note never await .stop() in a lifecycle hook.
|
||||||
this.pageController.hideMask()
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (this.#status !== 'running') return
|
||||||
this.#abortController.abort()
|
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<ExecutionResult> {
|
async execute(task: string): Promise<ExecutionResult> {
|
||||||
|
// pre-checks
|
||||||
if (this.disposed) throw new Error('PageAgent has been disposed. Create a new instance.')
|
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 (this.#status === 'running') throw new Error('A task is already running.')
|
||||||
if (!task) throw new Error('Task is required')
|
if (!task) throw new Error('Task is required')
|
||||||
@@ -202,135 +220,158 @@ export class PageAgentCore extends EventTarget {
|
|||||||
this.#observations = []
|
this.#observations = []
|
||||||
this.#states = { totalWaitTime: 0, lastURL: '', browserState: null }
|
this.#states = { totalWaitTime: 0, lastURL: '', browserState: null }
|
||||||
this.#abortController = new AbortController()
|
this.#abortController = new AbortController()
|
||||||
|
const signal = this.#abortController.signal
|
||||||
|
|
||||||
|
let resolveRunning!: () => void
|
||||||
|
this.#running = new Promise<void>((r) => (resolveRunning = r))
|
||||||
|
|
||||||
this.#setStatus('running')
|
this.#setStatus('running')
|
||||||
this.#emitHistoryChange()
|
this.#emitHistoryChange()
|
||||||
|
|
||||||
// Disable ask_user tool if onAskUser is not set
|
// Disable ask_user tool if onAskUser is not set
|
||||||
if (!this.onAskUser) {
|
if (!this.onAskUser) this.tools.delete('ask_user')
|
||||||
this.tools.delete('ask_user')
|
|
||||||
}
|
|
||||||
|
|
||||||
const onBeforeStep = this.config.onBeforeStep
|
const onBeforeStep = this.config.onBeforeStep
|
||||||
const onAfterStep = this.config.onAfterStep
|
const onAfterStep = this.config.onAfterStep
|
||||||
const onBeforeTask = this.config.onBeforeTask
|
const onBeforeTask = this.config.onBeforeTask
|
||||||
const onAfterTask = this.config.onAfterTask
|
const onAfterTask = this.config.onAfterTask
|
||||||
|
const stepDelay = this.config.stepDelay ?? 0.4
|
||||||
try {
|
const maxSteps = this.config.maxSteps
|
||||||
await onBeforeTask?.(this)
|
|
||||||
await this.pageController.showMask()
|
|
||||||
} catch (error) {
|
|
||||||
this.#setStatus('error')
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
let step = 0
|
let step = 0
|
||||||
let taskSuccess: boolean
|
let taskResult: ExecutionResult
|
||||||
let taskResult: string
|
let finalStatus: AgentStatus = 'error'
|
||||||
|
|
||||||
while (true) {
|
await suppress(() => this.pageController.showMask())
|
||||||
try {
|
|
||||||
console.group(`step: ${step}`)
|
|
||||||
|
|
||||||
|
// graceful exit
|
||||||
|
try {
|
||||||
|
await onBeforeTask?.(this)
|
||||||
|
|
||||||
|
while (true) {
|
||||||
await onBeforeStep?.(this, step)
|
await onBeforeStep?.(this, step)
|
||||||
|
|
||||||
// observe
|
// handle internal agent errors
|
||||||
|
try {
|
||||||
|
console.group(`step: ${step}`)
|
||||||
|
|
||||||
console.log(chalk.blue.bold('👀 Observing...'))
|
// @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)
|
||||||
|
|
||||||
this.#states.browserState = await this.pageController.getBrowserState()
|
signal.throwIfAborted()
|
||||||
await this.#handleObservations(step)
|
|
||||||
|
|
||||||
// assemble prompts
|
// observe
|
||||||
|
|
||||||
const messages = [
|
console.log(chalk.blue.bold('👀 Observing...'))
|
||||||
{ role: 'system' as const, content: this.#getSystemPrompt() },
|
|
||||||
{ role: 'user' as const, content: await this.#assembleUserPrompt() },
|
|
||||||
]
|
|
||||||
|
|
||||||
const macroTool = { AgentOutput: this.#packMacroTool() }
|
this.#states.browserState = await this.pageController.getBrowserState()
|
||||||
|
await this.#handleObservations(step)
|
||||||
|
|
||||||
// invoke LLM
|
// assemble prompts
|
||||||
|
|
||||||
console.log(chalk.blue.bold('🧠 Thinking...'))
|
const messages = [
|
||||||
this.#emitActivity({ type: 'thinking' })
|
{ role: 'system' as const, content: this.#getSystemPrompt() },
|
||||||
|
{ role: 'user' as const, content: await this.#assembleUserPrompt() },
|
||||||
|
]
|
||||||
|
|
||||||
const result = await this.#llm.invoke(messages, macroTool, this.#abortController.signal, {
|
const macroTool = { AgentOutput: this.#packMacroTool() }
|
||||||
toolChoiceName: 'AgentOutput',
|
|
||||||
normalizeResponse: (res) => normalizeResponse(res, this.tools),
|
|
||||||
})
|
|
||||||
|
|
||||||
// assemble history
|
// invoke LLM
|
||||||
|
|
||||||
const macroResult = result.toolResult as MacroToolResult
|
console.log(chalk.blue.bold('🧠 Thinking...'))
|
||||||
const input = macroResult.input
|
this.#emitActivity({ type: 'thinking' })
|
||||||
const output = macroResult.output
|
|
||||||
const reflection: Partial<AgentReflection> = {
|
const result = await this.#llm.invoke(messages, macroTool, signal, {
|
||||||
evaluation_previous_goal: input.evaluation_previous_goal,
|
toolChoiceName: 'AgentOutput',
|
||||||
memory: input.memory,
|
normalizeResponse: (res) => normalizeResponse(res, this.tools),
|
||||||
next_goal: input.next_goal,
|
})
|
||||||
}
|
|
||||||
const actionName = Object.keys(input.action)[0]
|
// assemble history
|
||||||
const action: AgentStepEvent['action'] = {
|
|
||||||
name: actionName,
|
const macroResult = result.toolResult as MacroToolResult
|
||||||
input: input.action[actionName],
|
const input = macroResult.input
|
||||||
output: output,
|
const output = macroResult.output
|
||||||
|
const reflection: Partial<AgentReflection> = {
|
||||||
|
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.#lastResult = taskResult
|
||||||
|
finalStatus = '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
|
||||||
|
finalStatus = isAbortError ? 'stopped' : 'error'
|
||||||
|
break
|
||||||
|
} finally {
|
||||||
|
// finally block runs before the break above.
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.history.push({
|
step++
|
||||||
type: 'step',
|
if (step > maxSteps) {
|
||||||
stepIndex: step,
|
const message = 'Step count exceeded maximum limit'
|
||||||
reflection,
|
console.error(message)
|
||||||
action,
|
this.#emitActivity({ type: 'error', message: message })
|
||||||
usage: result.usage,
|
this.#emitHistoryChange({ type: 'error', message: message })
|
||||||
rawResponse: result.rawResponse,
|
taskResult = { success: false, data: message, history: this.history }
|
||||||
rawRequest: result.rawRequest,
|
this.#lastResult = taskResult
|
||||||
} as AgentStepEvent)
|
finalStatus = 'error'
|
||||||
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)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} // while
|
||||||
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++
|
await onAfterTask?.(this, taskResult)
|
||||||
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)
|
return taskResult
|
||||||
|
} catch (error) {
|
||||||
|
this.#emitActivity({ type: 'error', message: String(error) })
|
||||||
|
finalStatus = 'error'
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
await suppress(() => this.pageController.cleanUpHighlights())
|
||||||
|
await suppress(() => this.pageController.hideMask())
|
||||||
|
this.#abortController.abort()
|
||||||
|
resolveRunning()
|
||||||
|
this.#setStatus(finalStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#onDone(taskSuccess)
|
|
||||||
const result: ExecutionResult = {
|
|
||||||
success: taskSuccess,
|
|
||||||
data: taskResult,
|
|
||||||
history: this.history,
|
|
||||||
}
|
|
||||||
await onAfterTask?.(this, result)
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -605,13 +646,6 @@ export class PageAgentCore extends EventTarget {
|
|||||||
return prompt
|
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() {
|
dispose() {
|
||||||
console.log('Disposing PageAgent...')
|
console.log('Disposing PageAgent...')
|
||||||
this.disposed = true
|
this.disposed = true
|
||||||
|
|||||||
@@ -262,9 +262,9 @@ export type HistoricalEvent =
|
|||||||
| AgentErrorEvent
|
| 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.
|
* Agent activity - transient state for immediate UI feedback.
|
||||||
|
|||||||
@@ -129,3 +129,15 @@ export function assert(condition: unknown, message?: string, silent?: boolean):
|
|||||||
throw new Error(errorMessage)
|
throw new Error(errorMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suppress errors from a function.
|
||||||
|
*/
|
||||||
|
export async function suppress<T>(fn: () => T | Promise<T>): Promise<Awaited<T> | undefined> {
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export type Execute = (task: string, config: ExecuteConfig) => Promise<Execution
|
|||||||
`AgentStatus`
|
`AgentStatus`
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
|
type AgentStatus = 'idle' | 'running' | 'completed' | 'error' | 'stopped'
|
||||||
```
|
```
|
||||||
|
|
||||||
`AgentActivity`
|
`AgentActivity`
|
||||||
|
|||||||
@@ -40,13 +40,16 @@ export class MultiPageAgent extends PageAgentCore {
|
|||||||
const experimentalIncludeAllTabs = config.experimentalIncludeAllTabs ?? false
|
const experimentalIncludeAllTabs = config.experimentalIncludeAllTabs ?? false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* 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.
|
* When the agent is in side-panel and user closed the side-panel.
|
||||||
* There is no chance for isAgentRunning to be set false.
|
* There is no chance for isAgentRunning to be set false.
|
||||||
* (unload event doesn't work well in side panel.)
|
* (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.)
|
* (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.
|
* This heartbeat mechanism acts as a backup.
|
||||||
*/
|
*/
|
||||||
let heartBeatInterval: null | number = null
|
let heartBeatInterval: number | null = null
|
||||||
|
|
||||||
super({
|
super({
|
||||||
...config,
|
...config,
|
||||||
@@ -56,27 +59,6 @@ export class MultiPageAgent extends PageAgentCore {
|
|||||||
|
|
||||||
onBeforeTask: async (agent) => {
|
onBeforeTask: async (agent) => {
|
||||||
await tabsController.init(agent.task, { includeInitialTab, experimentalIncludeAllTabs })
|
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) => {
|
onBeforeStep: async (agent) => {
|
||||||
@@ -87,16 +69,28 @@ export class MultiPageAgent extends PageAgentCore {
|
|||||||
|
|
||||||
onDispose: () => {
|
onDispose: () => {
|
||||||
if (heartBeatInterval) {
|
if (heartBeatInterval) {
|
||||||
window.clearInterval(heartBeatInterval)
|
clearInterval(heartBeatInterval)
|
||||||
heartBeatInterval = null
|
heartBeatInterval = null
|
||||||
}
|
}
|
||||||
|
chrome.storage.local.set({ isAgentRunning: false }).catch(console.error)
|
||||||
chrome.storage.local.set({
|
|
||||||
isAgentRunning: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
tabsController.dispose()
|
tabsController.dispose()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function useAgent(): UseAgentResult {
|
|||||||
const handleStatusChange = (e: Event) => {
|
const handleStatusChange = (e: Event) => {
|
||||||
const newStatus = agent.status as AgentStatus
|
const newStatus = agent.status as AgentStatus
|
||||||
setStatus(newStatus)
|
setStatus(newStatus)
|
||||||
if (newStatus === 'idle' || newStatus === 'completed' || newStatus === 'error') {
|
if (newStatus !== 'running') {
|
||||||
setActivity(null)
|
setActivity(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function StatusDot({ status }: { status: AgentStatus }) {
|
|||||||
running: 'bg-blue-500',
|
running: 'bg-blue-500',
|
||||||
completed: 'bg-green-500',
|
completed: 'bg-green-500',
|
||||||
error: 'bg-destructive',
|
error: 'bg-destructive',
|
||||||
|
stopped: 'bg-muted-foreground',
|
||||||
}[status]
|
}[status]
|
||||||
|
|
||||||
const label = {
|
const label = {
|
||||||
@@ -21,6 +22,7 @@ export function StatusDot({ status }: { status: AgentStatus }) {
|
|||||||
running: 'Running',
|
running: 'Running',
|
||||||
completed: 'Done',
|
completed: 'Done',
|
||||||
error: 'Error',
|
error: 'Error',
|
||||||
|
stopped: 'Stopped',
|
||||||
}[status]
|
}[status]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function App() {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
prev === 'running' &&
|
prev === 'running' &&
|
||||||
(status === 'completed' || status === 'error') &&
|
(status === 'completed' || status === 'error' || status === 'stopped') &&
|
||||||
history.length > 0 &&
|
history.length > 0 &&
|
||||||
currentTask
|
currentTask
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface SessionRecord {
|
|||||||
id: string
|
id: string
|
||||||
task: string
|
task: string
|
||||||
history: HistoricalEvent[]
|
history: HistoricalEvent[]
|
||||||
status: 'completed' | 'error'
|
status: 'completed' | 'error' | 'stopped'
|
||||||
createdAt: number
|
createdAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,10 +100,10 @@ export class Panel {
|
|||||||
#handleStatusChange(): void {
|
#handleStatusChange(): void {
|
||||||
const status = this.#agent.status
|
const status = this.#agent.status
|
||||||
|
|
||||||
// Map agent status to UI indicator type
|
// Map agent status to UI indicator. A `completed` run whose result reports
|
||||||
const indicatorType =
|
// failure shows as error; other statuses map to their own indicator.
|
||||||
status === 'running' ? 'thinking' : status === 'idle' ? 'thinking' : status
|
const failed = status === 'completed' && this.#agent.lastResult?.success === false
|
||||||
this.#updateStatusIndicator(indicatorType)
|
this.#updateStatusIndicator(failed ? 'error' : status)
|
||||||
|
|
||||||
// Morph action button: running = stop (■), not running = close (X)
|
// Morph action button: running = stop (■), not running = close (X)
|
||||||
if (status === 'running') {
|
if (status === 'running') {
|
||||||
@@ -121,7 +121,7 @@ export class Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle completion
|
// Handle completion
|
||||||
if (status === 'completed' || status === 'error') {
|
if (status === 'completed' || status === 'error' || status === 'stopped') {
|
||||||
if (!this.#isExpanded) {
|
if (!this.#isExpanded) {
|
||||||
this.#expand()
|
this.#expand()
|
||||||
}
|
}
|
||||||
@@ -376,7 +376,7 @@ export class Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const status = this.#agent.status
|
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
|
// Only show input area after task completion if configured to do so
|
||||||
if (isTaskEnded) {
|
if (isTaskEnded) {
|
||||||
@@ -559,13 +559,23 @@ export class Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#updateStatusIndicator(
|
#updateStatusIndicator(
|
||||||
type: 'thinking' | 'executing' | 'executed' | 'retrying' | 'completed' | 'error'
|
type:
|
||||||
|
| 'idle'
|
||||||
|
| 'running'
|
||||||
|
| 'thinking'
|
||||||
|
| 'executing'
|
||||||
|
| 'executed'
|
||||||
|
| 'retrying'
|
||||||
|
| 'completed'
|
||||||
|
| 'error'
|
||||||
|
| 'stopped'
|
||||||
): void {
|
): 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
|
this.#indicator.className = styles.indicator
|
||||||
|
if (variant !== 'idle' && variant !== 'stopped') {
|
||||||
// Add corresponding status class
|
this.#indicator.classList.add(styles[variant])
|
||||||
this.#indicator.classList.add(styles[type])
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#scrollToBottom(): void {
|
#scrollToBottom(): void {
|
||||||
|
|||||||
@@ -22,14 +22,17 @@ export type AgentActivity =
|
|||||||
* This enables decoupling and allows any agent implementation to work with Panel.
|
* This enables decoupling and allows any agent implementation to work with Panel.
|
||||||
*
|
*
|
||||||
* Events:
|
* Events:
|
||||||
* - 'statuschange': Agent status changed (idle/running/completed/error)
|
* - 'statuschange': Agent status changed
|
||||||
* - 'historychange': Historical events updated (persisted)
|
* - 'historychange': Historical events updated (persisted)
|
||||||
* - 'activity': Transient activity for immediate UI feedback (thinking/executing/etc)
|
* - 'activity': Transient activity for immediate UI feedback (thinking/executing/etc)
|
||||||
* - 'dispose': Agent is being disposed
|
* - 'dispose': Agent is being disposed
|
||||||
*/
|
*/
|
||||||
export interface PanelAgentAdapter extends EventTarget {
|
export interface PanelAgentAdapter extends EventTarget {
|
||||||
/** Current agent status */
|
/** 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 */
|
/** History of agent events */
|
||||||
readonly history: readonly {
|
readonly history: readonly {
|
||||||
@@ -71,7 +74,7 @@ export interface PanelAgentAdapter extends EventTarget {
|
|||||||
execute(task: string): Promise<unknown>
|
execute(task: string): Promise<unknown>
|
||||||
|
|
||||||
/** Stop the current task (agent remains reusable) */
|
/** Stop the current task (agent remains reusable) */
|
||||||
stop(): void
|
stop(): Promise<void>
|
||||||
|
|
||||||
/** Dispose the agent (terminal, cannot be reused) */
|
/** Dispose the agent (terminal, cannot be reused) */
|
||||||
dispose(): void
|
dispose(): void
|
||||||
|
|||||||
@@ -128,8 +128,8 @@ export default function CustomUIDocs() {
|
|||||||
name: 'statuschange',
|
name: 'statuschange',
|
||||||
type: 'Event',
|
type: 'Event',
|
||||||
description: isZh
|
description: isZh
|
||||||
? 'Agent 状态变化 (idle → running → completed/error)'
|
? 'Agent 状态变化 (idle → running → completed/error/stopped)'
|
||||||
: 'Agent status changes (idle → running → completed/error)',
|
: 'Agent status changes (idle → running → completed/error/stopped)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'historychange',
|
name: 'historychange',
|
||||||
|
|||||||
@@ -281,8 +281,8 @@ const result = await agent.execute('Fill in the form with test data')`}
|
|||||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-4">
|
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-4">
|
||||||
<p className="text-amber-800 dark:text-amber-200 text-sm">
|
<p className="text-amber-800 dark:text-amber-200 text-sm">
|
||||||
{isZh
|
{isZh
|
||||||
? '这些接口高度实验性,可能在未来版本中发生变化。'
|
? '这些接口高度实验性,可能在未来版本中发生变化。钩子中抛出的错误会使任务失败并从 execute() 抛出;如不希望影响任务,请在钩子内部自行捕获。'
|
||||||
: 'These APIs are highly experimental and may change in future versions. '}
|
: '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.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<APIReference
|
<APIReference
|
||||||
@@ -325,7 +325,7 @@ const result = await agent.execute('Fill in the form with test data')`}
|
|||||||
properties={[
|
properties={[
|
||||||
{
|
{
|
||||||
name: 'status',
|
name: 'status',
|
||||||
type: "'idle' | 'running' | 'completed' | 'error'",
|
type: "'idle' | 'running' | 'completed' | 'error' | 'stopped'",
|
||||||
description: isZh ? '当前 Agent 执行状态' : 'Current agent execution status',
|
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()',
|
name: 'stop()',
|
||||||
type: 'void',
|
type: 'Promise<void>',
|
||||||
description: isZh
|
description: isZh
|
||||||
? '停止当前任务。Agent 仍可复用。'
|
? '停止当前任务,并在任务完全结束后 resolve。Agent 仍可复用。'
|
||||||
: 'Stop the current task. Agent remains reusable.',
|
: 'Stop the current task; resolves once the run has fully settled. Agent remains reusable.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'dispose()',
|
name: 'dispose()',
|
||||||
@@ -415,8 +415,8 @@ const result = await agent.execute('Fill in the form with test data')`}
|
|||||||
name: 'statuschange',
|
name: 'statuschange',
|
||||||
type: 'Event',
|
type: 'Event',
|
||||||
description: isZh
|
description: isZh
|
||||||
? 'Agent 状态变化时触发 (idle → running → completed/error)'
|
? 'Agent 状态变化时触发 (idle → running → completed/error/stopped)'
|
||||||
: 'Fired when agent status changes (idle → running → completed/error)',
|
: 'Fired when agent status changes (idle → running → completed/error/stopped)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'historychange',
|
name: 'historychange',
|
||||||
|
|||||||
Reference in New Issue
Block a user