refactor(agent-loop): start waking work directly

This commit is contained in:
_Kerman
2026-08-04 21:02:28 +08:00
parent 674278cb17
commit e50c4aca03
23 changed files with 103 additions and 105 deletions

View File

@@ -41,7 +41,6 @@ type Phase =
lastTurn: number
wakeRequested: boolean
}
| { kind: 'collecting'; abort: AbortController; lastTurn: number }
| { kind: 'running'; abort: AbortController; turn: number; step: number }
type StepEndReason = Extract<TurnEndReason, { kind: 'completed' | 'max-tokens' }>
@@ -109,7 +108,7 @@ export class ReactLoopAgent implements Agent {
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])
if (wakeup) this.scheduleKick()
if (wakeup) this.wakeDriver()
}
followup(input: UserMessage): void {
@@ -148,14 +147,14 @@ export class ReactLoopAgent implements Agent {
return await task(maintenance.abort.signal)
} finally {
this.setPhase({ kind: 'idle', lastTurn: maintenance.lastTurn })
if (maintenance.wakeRequested) this.scheduleKick()
if (maintenance.wakeRequested) this.wakeDriver()
done.resolve()
}
})()
}
/** Schedule one driver, or remember its wake behind maintenance. */
private scheduleKick(): void {
/** Start one driver, or remember its wake behind maintenance. */
private wakeDriver(): void {
if (this.phase.kind === 'maintenance') {
if (!this.phase.abort.signal.aborted) this.phase.wakeRequested = true
return
@@ -163,10 +162,8 @@ export class ReactLoopAgent implements Agent {
if (this.phase.kind !== 'idle') return
const driver = Promise.withResolvers<void>()
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)
})
this.setPhase({ kind: 'running', abort: new AbortController(), turn: this.phase.lastTurn, step: 0 })
this.loopCtx.agents.withInitiator(this, () => this.kick()).then(driver.resolve, driver.reject)
}
async whenIdle(): Promise<void> {
@@ -221,14 +218,11 @@ export class ReactLoopAgent implements Agent {
/** Open one turn before claiming its first proposed step. */
private async turn(): Promise<boolean> {
if (this.phase.kind === 'idle' || this.phase.kind === 'maintenance') {
if (this.phase.kind !== 'running') {
this.throwError(new Error(`agent "${this.id}": turn without driver reservation`))
}
const abort = this.phase.kind === 'collecting' ? this.phase.abort : new AbortController()
const { signal } = abort
const lastTurn = this.phase.kind === 'collecting' ? this.phase.lastTurn : this.phase.turn
const phase = { kind: 'running' as const, abort, turn: lastTurn, step: 0 }
this.setPhase(phase)
const phase = this.phase
const { signal } = phase.abort
signal.throwIfAborted()
const turn = phase.turn + 1
try {
@@ -301,7 +295,10 @@ export class ReactLoopAgent implements Agent {
this.throwError(error)
}
}
return this.inbox.hasPending
if (!this.inbox.hasPending) return false
phase.abort = new AbortController()
phase.step = 0
return true
}
private async step(assembly: PromptAssembly): Promise<StepEndReason | null> {

View File

@@ -72,8 +72,8 @@ describe('Agent.cancel()', () => {
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
})
it('cancel({ keepInbox: true }) preserves queued work and emits no discard', async () => {
const adapter = new MockAdapter([textResponse('preserved reply'), textResponse('wake reply')])
it('cancel({ keepInbox: true }) does not restore work already claimed by a waking send', async () => {
const adapter = new MockAdapter([textResponse('wake reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -81,21 +81,23 @@ describe('Agent.cancel()', () => {
content: [{ type: 'text', text: 'preserved' }],
source: { kind: 'user' },
}))
// Abort the collecting activity while preserving its queued item.
// A waking send starts and claims synchronously, so keepInbox has no
// pending item to preserve by the time this cancellation runs.
agent.cancel({ kind: 'user' }, { keepInbox: true })
expect(agent.session.events.some(event =>
event.type === 'agent/inbox/spliced' && event.data.outcome === 'canceled')).toBe(false)
await agent.whenIdle()
expect(agent.inbox.nextTurn).toHaveLength(1)
expect(agent.inbox.nextTurn).toHaveLength(0)
expect(userTexts(agent)).toEqual([])
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason)
.toEqual({ kind: 'aborted', reason: { kind: 'user' } })
// The preserved item still runs once a later follow-up wakes the driver.
const idle = waitForIdle(ctx, agent)
send(agent, 'wake it')
await idle
expect(userTexts(agent)).toEqual(['preserved', 'wake it'])
expect(adapter.requests).toHaveLength(2)
expect(userTexts(agent)).toEqual(['wake it'])
expect(adapter.requests).toHaveLength(1)
})
it('cancel({ keepInbox: true }) parks queued work after an active turn aborts', async () => {
@@ -124,23 +126,22 @@ describe('Agent.cancel()', () => {
expect(adapter.requests).toHaveLength(3)
})
it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => {
it('cancel after waking send closes its synchronously opened turn without a step', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// followup() queues synchronously (status still idle, loop microtask not yet
// resumed). Cancel in that pre-step window: the queued turn must not run.
send(agent, 'drop me first')
send(agent, 'drop me second')
agent.cancel({ kind: 'user' })
// Give the loop a chance to wake and process the cancel.
await new Promise(r => setTimeout(r, 30))
// No turn was opened — the queued prompt was dropped, never recorded.
expect(userTexts(agent)).toEqual([])
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(0)
expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason)
.toEqual({ kind: 'aborted', reason: { kind: 'user' } })
expect(agent.status).toBe('idle')
})
@@ -218,7 +219,7 @@ describe('Agent.cancel()', () => {
await expect(Promise.race([
replacementObservation,
new Promise((_resolve, reject) => setTimeout(() => { reject(new Error('whenIdle hung after idle-listener cancel')) }, 1000)),
])).resolves.toEqual({ status: 'idle', requests: 1, turns: 1 })
])).resolves.toEqual({ status: 'idle', requests: 1, turns: 2 })
const idle = waitForIdle(ctx, agent)
send(agent, 'later')
@@ -511,10 +512,10 @@ describe('Agent.cancel()', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'A') // queues A (status still idle, loop microtask pending)
const idle = agent.whenIdle() // registers a waiter (idle + hasQueued → no fast path)
agent.cancel({ kind: 'user' }) // arms marker, clears A
send(agent, 'B') // B races in before the loop resumes
send(agent, 'A')
const idle = agent.whenIdle()
agent.cancel({ kind: 'user' })
send(agent, 'B')
await idle
expect(userTexts(agent)).toEqual([])
@@ -524,7 +525,7 @@ describe('Agent.cancel()', () => {
send(agent, 'C')
await replacementIdle
expect(userTexts(agent)).toEqual(['B', 'C'])
expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(2)
expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(3)
})
it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => {

View File

@@ -191,10 +191,11 @@ describe('abort during tool execution ends the turn', () => {
const adapter = new MockAdapter([textResponse('must not run')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-empty-batch'), { provider: 'mock', model: 'mock' })
ctx.on('agent/pre-step', (subject, _messages, _context, next) => {
if (subject !== agent) return next()
return Promise.resolve({ kind: 'enter', messages: [] })
})
send(agent, 'go')
// The wake microtask has not run yet: remove the only pending message so
// the admission batch is empty.
agent.inbox.remove(agent.inbox.nextTurn[0]!.id)
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.filter(event => event.type === 'turn/start'

View File

@@ -25,11 +25,7 @@ async function harness(adapter: MockAdapter, persona = '') {
return ctx
}
/**
* Wait for the agent's NEXT transition to idle. Always event-based: callers
* invoke this right after send(), when the loop hasn't woken yet (status is
* still 'idle' synchronously), so polling the current status would lie.
*/
/** Wait for the agent's next transition to idle after a waking send. */
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
@@ -527,13 +523,15 @@ describe('agent loop', () => {
expect(flat).toContain('change of plans')
})
it('coalesces same-tick idle steering into one turn', async () => {
const adapter = new MockAdapter([textResponse('first')])
it('starts idle steering synchronously and enters later steering at the next step', async () => {
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const idle = waitForIdle(ctx, agent)
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'first idle steer' }], source: { kind: 'user' } }))
expect(agent.status).toBe('running')
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'second idle steer' }], source: { kind: 'user' } }))
await idle
@@ -544,9 +542,10 @@ describe('agent loop', () => {
[{ type: 'text', text: 'first idle steer' }],
[{ type: 'text', text: 'second idle steer' }],
])
expect(adapter.requests).toHaveLength(1)
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('first idle steer')
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('second idle steer')
expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('second idle steer')
expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('second idle steer')
})
it('stops after a throwing pre-step listener and retains later steering until a wakeup', async () => {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent/README.md
README.md: e47356370b23abfd8790a5210a6e11d4e7505627
README.zh.md: a2629732a04051e6b39642c190a2e3a687f850d9
README.md: a3781f1ab466e6825a80c529c261b3a4ea262092
README.zh.md: 56cb12aaa4859819e53baf7f1850600dd9adac63

View File

@@ -64,8 +64,8 @@ The handle every plugin programs against:
- `agent.inbox` — the agent-owned projection of durable `agent/inbox/spliced` events. `nextTurn` and `nextStep` expose pending `UserMessage` values. `append`, `prepend`, `replace`, `remove`, `clear`, and `splice` mutate them; `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists. Replacement may change identity and publishes the old message as discarded followed by the new message as inserted. Ordinary removals and `clear()` are durable cancellations and emit `agent/inbox/discarded`. `claim(target)` atomically removes the next proposed batch with pure deletion splices; the loop then emits `agent/inbox/claimed`. `MessageId` is the only occurrence identity and must remain unique while pending.
- `agent.followup(message)` — queue an ordinary `next-turn` message and wake the driver. It returns no completion handle; the message id identifies inbox insertion, claim, and discard facts, not a later output or `turn/end`.
- `agent.steer(message)` — queue waking `next-step` input. An idle driver schedules a turn; collecting and running drivers consume it at their next step boundary.
- `agent.inject(message)` — queue non-waking `next-step` context. A collecting or running driver claims it at the nearest later pre-step boundary; an idle driver leaves it pending until `followup()` or `steer()` wakes the driver. It may miss a request whose pre-step already claimed its batch.
- `agent.steer(message)` — queue waking `next-step` input. An idle agent starts a turn synchronously; a running driver consumes later steering at its next step boundary.
- `agent.inject(message)` — queue non-waking `next-step` context. A running driver claims it at the nearest later pre-step boundary; an idle driver leaves it pending until `followup()` or `steer()` wakes the driver. It may miss a request whose pre-step already claimed its batch.
- `agent.cancel(cause, options?)` — cancel the active driver and, unless `options.keepInbox`, durably cancel all pending inbox work. Idle cancellation is a no-op.
- `agent.whenIdle()` — observe whole-agent quiescence, including replacement work scheduled before the current driver retires. It does not settle any particular message.
- `agent.session`, `agent.status`, `agent.options`, `agent.id`, `agent.ctx`

View File

@@ -64,8 +64,8 @@ inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserte
- `agent.inbox`agent 所拥有的持久 `agent/inbox/spliced` 事件投影。`nextTurn``nextStep` 暴露待处理的 `UserMessage` 值。`append``prepend``replace``remove``clear``splice` 用于变更队列;`replace(messageId, newMessage)``remove(messageId)` 通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。普通删除和 `clear()` 都是持久取消,并发出 `agent/inbox/discarded``claim(target)` 通过纯删除 splice 原子移除下一个候选批次,随后由循环发出 `agent/inbox/claimed``MessageId` 是唯一的入队项标识,在消息待处理期间必须保持唯一。
- `agent.followup(message)`:将一条普通 `next-turn` 消息排队并唤醒驱动器。它不返回完成 handle消息 id 标识 inbox 的插入、领取与丢弃事实,而不标识之后的输出或 `turn/end`
- `agent.steer(message)`:将会唤醒的 `next-step` 输入排队。空闲驱动器会调度一个轮次collecting 和 running 驱动器会在各自的下一步骤边界消费该输入
- `agent.inject(message)`:将不会唤醒的 `next-step` 上下文排队。collecting 或 running 驱动器会在最近的后续 pre-step 边界领取它idle 驱动器则会让它保持待处理,直至 `followup()``steer()` 唤醒驱动器。若某次请求的 pre-step 已经领取完批次,它可能赶不上该请求。
- `agent.steer(message)`:将会唤醒的 `next-step` steering中途引导输入排队。agent 空闲时会同步启动一个轮次;驱动器运行期间收到的后续 steering 会在下一步骤边界消费。
- `agent.inject(message)`:将不会唤醒的 `next-step` 上下文排队。运行中的驱动器会在最近的后续 pre-step 边界领取它idle 驱动器则会让它保持待处理,直至 `followup()``steer()` 唤醒驱动器。若某次请求的 pre-step 已经领取完批次,它可能赶不上该请求。
- `agent.cancel(cause, options?)`:取消活跃驱动器,并在未设置 `options.keepInbox` 时持久取消全部待处理 inbox 工作。空闲取消是空操作。
- `agent.whenIdle()`:观察整个 agent 达到完全停稳,包括当前驱动器退役前调度的替代工作。它不结算任何特定消息。
- `agent.session``agent.status``agent.options``agent.id``agent.ctx`

View File

@@ -41,8 +41,8 @@ export interface CancelOptions {
/**
* An agent's lifecycle state, emitted on every transition as `agent/status`:
* `idle` means no driver is scheduled or active; `running` begins when a
* cancellable pre-step processing is scheduled and lasts while the driver drains,
* `idle` means no driver is active; `running` begins when waking input starts
* cancellable pre-step processing and lasts while the driver drains,
* closes, or checkpoints turns. Disposal removes the agent from its registry;
* it is not a third observable status.
*/
@@ -109,9 +109,9 @@ export interface Agent {
/**
* Resolve after the current whole-agent activity reaches quiescence. This
* follows replacement work scheduled before the observed driver retires,
* follows replacement work started before the observed driver retires,
* but does not identify the settlement of any particular message.
* @returns fulfillment after no scheduled or active driver remains.
* @returns fulfillment after no active driver or maintenance task remains.
*/
whenIdle(): Promise<void>
@@ -143,8 +143,8 @@ export interface Agent {
followup(message: UserMessage): void
/**
* Submit steering for the nearest step. An idle driver schedules a turn;
* collecting and running drivers consume it at their next step boundary.
* Submit steering for the nearest step. An idle driver starts a turn;
* a running driver consumes it at its next step boundary.
* A rejected step leaves steering parked in the inbox until the next
* wake; cancellation or disposal may discard pending steering.
* @param message - identified steering content and its producer provenance.
@@ -153,8 +153,8 @@ export interface Agent {
/**
* Queue model-facing context for the next pre-step without waking the
* driver. Collecting and running drivers claim it at the nearest later
* step boundary; idle drivers leave it pending until follow-up or steering
* driver. A running driver claims it at the nearest later step boundary;
* idle drivers leave it pending until follow-up or steering
* wakes them. It may miss a request whose pre-step already claimed its
* batch. Cancellation or disposal may discard pending context.
* @param message - identified injected context and its producer provenance.