refactor(agent): run maintenance between turns
This commit is contained in:
@@ -35,6 +35,12 @@ import { executeToolCalls } from './tool-calls.ts'
|
||||
|
||||
type Phase =
|
||||
| { kind: 'idle'; lastTurn: number }
|
||||
| {
|
||||
kind: 'maintenance'
|
||||
abort: AbortController
|
||||
lastTurn: number
|
||||
wakeRequested: boolean
|
||||
}
|
||||
| { kind: 'collecting'; abort: AbortController; lastTurn: number }
|
||||
| { kind: 'running'; abort: AbortController; turn: number; step: number }
|
||||
|
||||
@@ -57,7 +63,7 @@ function requestProposal(header: EpochHeader): LlmCallConfig {
|
||||
export class ReactLoopAgent implements Agent {
|
||||
readonly inbox: Inbox
|
||||
private phase: Phase
|
||||
private driverDone: Promise<void> = Promise.resolve()
|
||||
private activityDone: Promise<void> = Promise.resolve()
|
||||
|
||||
/** The agent-scoped registration boundary; the lifecycle owner unwinds it after the driver exits. */
|
||||
readonly scope: Scope
|
||||
@@ -85,7 +91,7 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
|
||||
get status(): AgentStatus {
|
||||
return this.phase.kind === 'idle' ? 'idle' : 'running'
|
||||
return this.phase.kind === 'idle' || this.phase.kind === 'maintenance' ? 'idle' : 'running'
|
||||
}
|
||||
|
||||
/** Commit a phase and publish its externally visible status transition. */
|
||||
@@ -99,7 +105,7 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
|
||||
send(message: UserMessage, target: InboxTarget, wakeup: boolean): void {
|
||||
// Waking input cannot join an aborted pre-step or turn, so it starts the next turn.
|
||||
// Waking input cannot join an aborted activity, so it starts the next turn.
|
||||
const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted
|
||||
const resolvedTarget = wakingAfterAbort ? 'next-turn' : target
|
||||
this.inbox.splice(resolvedTarget, Infinity, 0, [message])
|
||||
@@ -119,15 +125,44 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
|
||||
cancel(cause: AgentCancelCause, options: CancelOptions = {}): void {
|
||||
if (!options.keepInbox) this.inbox.clear()
|
||||
if (!options.keepInbox) {
|
||||
this.inbox.clear()
|
||||
if (this.phase.kind === 'maintenance') this.phase.wakeRequested = false
|
||||
}
|
||||
if (this.phase.kind !== 'idle') this.phase.abort.abort(cause)
|
||||
}
|
||||
|
||||
/** Reserve a driver before deferring idle pre-step processing. */
|
||||
runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
||||
if (this.phase.kind !== 'idle') throw new Error(`agent "${this.id}" already has active work`)
|
||||
const done = Promise.withResolvers<void>()
|
||||
const maintenance: Phase = {
|
||||
kind: 'maintenance',
|
||||
abort: new AbortController(),
|
||||
lastTurn: this.phase.lastTurn,
|
||||
wakeRequested: false,
|
||||
}
|
||||
this.setPhase(maintenance)
|
||||
this.activityDone = done.promise
|
||||
return (async () => {
|
||||
try {
|
||||
return await task(maintenance.abort.signal)
|
||||
} finally {
|
||||
this.setPhase({ kind: 'idle', lastTurn: maintenance.lastTurn })
|
||||
if (maintenance.wakeRequested) this.scheduleKick()
|
||||
done.resolve()
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
/** Schedule one driver, or remember its wake behind maintenance. */
|
||||
private scheduleKick(): void {
|
||||
if (this.phase.kind === 'maintenance') {
|
||||
if (!this.phase.abort.signal.aborted) this.phase.wakeRequested = true
|
||||
return
|
||||
}
|
||||
if (this.phase.kind !== 'idle') return
|
||||
const driver = Promise.withResolvers<void>()
|
||||
this.driverDone = driver.promise
|
||||
this.activityDone = driver.promise
|
||||
this.setPhase({ kind: 'collecting', abort: new AbortController(), lastTurn: this.phase.lastTurn })
|
||||
queueMicrotask(() => {
|
||||
this.loopCtx.agents.withInitiator(this, () => this.kick()).then(driver.resolve, driver.reject)
|
||||
@@ -135,10 +170,10 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
|
||||
async whenIdle(): Promise<void> {
|
||||
let driver: Promise<void>
|
||||
let activity: Promise<void>
|
||||
do {
|
||||
await (driver = this.driverDone)
|
||||
} while (driver !== this.driverDone)
|
||||
await (activity = this.activityDone)
|
||||
} while (activity !== this.activityDone)
|
||||
}
|
||||
|
||||
/** Report one failure at its live boundary, then preserve it for driver containment. */
|
||||
@@ -184,7 +219,7 @@ export class ReactLoopAgent implements Agent {
|
||||
|
||||
/** Claimed input stays unowned until `turn/start` commits. */
|
||||
private async turn(): Promise<boolean> {
|
||||
if (this.phase.kind === 'idle') {
|
||||
if (this.phase.kind === 'idle' || this.phase.kind === 'maintenance') {
|
||||
this.throwError(new Error(`agent "${this.id}": turn without driver reservation`))
|
||||
}
|
||||
const abort = this.phase.kind === 'collecting' ? this.phase.abort : new AbortController()
|
||||
|
||||
@@ -100,9 +100,9 @@ export interface Agent {
|
||||
|
||||
/**
|
||||
* Clear queued and steering work — unless `keepInbox` — and abort the active
|
||||
* turn. The first cause wins for the active turn. Idle cancellation is a
|
||||
* no-op and does not arm later work.
|
||||
* @param cause - the stable caller intent carried by the current turn signal.
|
||||
* turn or between-turn task. The first cause wins for that activity. With no
|
||||
* active activity, cancellation is a no-op and does not arm later work.
|
||||
* @param cause - the stable caller intent carried by the active operation signal.
|
||||
* @param options - cancellation options; `keepInbox` preserves pending work.
|
||||
*/
|
||||
cancel(cause: AgentCancelCause, options?: CancelOptions): void
|
||||
@@ -115,6 +115,17 @@ export interface Agent {
|
||||
*/
|
||||
whenIdle(): Promise<void>
|
||||
|
||||
/**
|
||||
* Run one non-turn maintenance task from the true idle phase. The task starts
|
||||
* synchronously after claiming that phase; later waking input remains in the
|
||||
* inbox until the task settles, while public status stays `idle`.
|
||||
* `whenIdle()` follows both the task and any waking work released behind it.
|
||||
* @param task - operation whose fulfillment or rejection is preserved, with a signal aborted by {@link cancel}.
|
||||
* @throws synchronously when turn-driving or another maintenance task already owns the agent.
|
||||
* @returns the task promise.
|
||||
*/
|
||||
runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>
|
||||
|
||||
/**
|
||||
* Route identified input to an inbox boundary and optionally wake the driver.
|
||||
* Waking input submitted after active cancellation is queued for the next turn.
|
||||
|
||||
Reference in New Issue
Block a user