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

@@ -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 .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md
2026-07-22-unified-send-and-coalesced-user-messages.md: c9e4a2195f01b72f1d0c16136c666f2e00e484d0
2026-07-22-unified-send-and-coalesced-user-messages.zh.md: fc6d26b421ba30cab0937c6e3a7ef3a41c83d654
2026-07-22-unified-send-and-coalesced-user-messages.md: f32d6ca65d5236e1fabdd177cdf54e36929c853f
2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 3e3176e74eadee958b43fe4d5cc288b7c60559ee

View File

@@ -28,7 +28,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj
**One accepted message keeps one representation.** Durable user-role input and additional model-facing context both use the identified, frozen `UserMessage` directly. The loop stores that value beside private routing state rather than copying its identity, content, or source into another public shape. Steering, injection, and tool-produced context each keep their identified messages in the next-step inbox. The [identified immutable message decision](2026-07-28-identified-immutable-message-values.md) supersedes this note's former `UserMessageData`/`AgentMessage` hierarchy and extends the representation to assistant and tool-result messages.
**Idle wakeup follows insertion.** A waking send reserves the driver and schedules pre-step processing for a microtask after the input enters its target inbox. Every send in one synchronous caller stack therefore enters before claiming starts, while reentrant cancellation or teardown cannot retire before the scheduled pre-step settles. Multiple idle `steer()` calls in that stack form one next-step batch.
**Idle wakeup follows insertion.** A waking send inserts its input, then enters the running driver before returning. The first pre-step may claim that input immediately; later synchronous sends therefore join the running loop and wait for a later boundary. Cancellation belongs to the running turn signal from wakeup onward; no distinct pre-run phase intervenes.
**cancel gains keepInbox.** `cancel(cause, { keepInbox? })`; callers choose the cause explicitly, and `keepInbox: true` aborts the active turn while preserving queued and steering items (no discard event, and un-started work is not dropped).

View File

@@ -28,7 +28,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`
**一条已接受消息只保留一种表示。** 持久的用户角色输入和附加的模型可见上下文都直接使用带标识且冻结的 `UserMessage`。循环把该值与私有路由状态存放在一起不会将其标识、内容或来源复制到另一种公开形状中。steering、注入和工具产生的上下文都会在 next-step inbox 中保留各自带标识的消息。[带标识的不可变消息值决策](2026-07-28-identified-immutable-message-values.md)取代了本记录此前的 `UserMessageData`/`AgentMessage` 层级,并将这一表示扩展到 assistant 消息和工具结果消息。
**空闲唤醒在插入之后发生。** 会唤醒的发送会先保留驱动器,并在输入进入目标 inbox 后把 pre-step 处理调度到微任务。因此,同一同步调用栈中的每次发送都会在领取开始前进入 inbox而可重入的取消或拆除在已调度的 pre-step 结算前无法完成退役。同一调用栈中多次空闲 `steer()` 会形成一个 next-step 批次
**空闲唤醒在插入之后发生。** 会唤醒的发送会先插入输入,再于返回前进入 running 驱动器。首次 pre-step 可能立即领取该输入;因此,后续同步发送会加入正在运行的循环,并等待更晚的边界。自唤醒开始,取消就归属于 running 轮次信号,中间不会插入独立的预运行 phase
**cancel 新增 keepInbox。** `cancel(cause, { keepInbox? })`;调用方显式选择 cause`keepInbox: true` 会中止活跃轮次,同时保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。

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 .agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md
2026-07-24-separate-context-injection-from-turn-execution.md: a1ae505a54f55d201559d62765abe423694aef6e
2026-07-24-separate-context-injection-from-turn-execution.zh.md: 8a24442b9922a125e86b5478cc3d0d810d027505
2026-07-24-separate-context-injection-from-turn-execution.md: bb28d96cf1494d94ad7a7d4a4714e536c7072d2f
2026-07-24-separate-context-injection-from-turn-execution.zh.md: 73889a946295f88dcf5e5eff01949fe57e90ea64

View File

@@ -26,7 +26,7 @@ Every additional context is an independent `UserMessage` whose `source` records
## Injection lifecycle
`inject()` always inserts context into the non-waking `next-step` inbox and commits that queue mutation as `agent/inbox/spliced`. A collecting or running driver claims it at the nearest later pre-step boundary. An idle driver leaves it pending until `followup()` or `steer()` supplies waking work; cancellation or disposal may discard it first without erasing the durable queue history.
`inject()` always inserts context into the non-waking `next-step` inbox and commits that queue mutation as `agent/inbox/spliced`. A running driver claims it at the nearest later pre-step boundary. An idle driver leaves it pending until `followup()` or `steer()` supplies waking work; cancellation or disposal may discard it first without erasing the durable queue history.
The loop claims the current next-step batch before running `agent/pre-step`, so an injection that arrives after that claim may miss the request already being finalized. The next boundary claims it instead. An enter decision appends its returned messages inside the owning turn before the request consumes them. Context produced during an assistant tool-call batch therefore appears after that batch's complete ordered results.
@@ -62,7 +62,7 @@ This decision preserves the caller-owned framing decision from [unwrapped inject
- `UserMessage` is the shared identified, frozen shape across prompt interception, tool execution, hook bridges, guards, and context producers.
- Prompt-prefix placement, prompt envelopes, and `context/message` are absent from public types, durable events, projection, and UI replay.
- Idle `inject()` immediately appends one durable inbox insertion but no model-visible `user/message`; a later waking delivery may start pre-step processing.
- Collecting and active-turn injection is claimed at the nearest later pre-step boundary, after complete tool-result batches and before the request that consumes it.
- Active-turn injection is claimed at the nearest later pre-step boundary, after complete tool-result batches and before the request that consumes it.
- Rejected or failed pre-step drops its claimed batch; input inserted after the claim remains pending.
- Unit, persistence/resume, invariant, and TUI coverage pin event order, claim ownership, and durable replay.

View File

@@ -26,7 +26,7 @@ agent API 曾用三种相互重叠的方式表示面向模型的补充输入:
## 注入生命周期
`inject()` 始终把上下文插入不会唤醒的 `next-step` inbox并以 `agent/inbox/spliced` 提交该队列变更。collecting 或 running 驱动器会在最近的后续 pre-step 边界领取它。idle 驱动器会让它保持待处理,直至 `followup()``steer()` 提供可唤醒工作;在此之前,取消或 dispose资源释放可能将其丢弃但不会抹除持久队列历史。
`inject()` 始终把上下文插入不会唤醒的 `next-step` inbox并以 `agent/inbox/spliced` 提交该队列变更。运行中的驱动器会在最近的后续 pre-step 边界领取它。idle 驱动器会让它保持待处理,直至 `followup()``steer()` 提供可唤醒工作;在此之前,取消或 dispose资源释放可能将其丢弃但不会抹除持久队列历史。
循环会先领取当前 next-step 批次,再运行 `agent/pre-step`因此领取后到达的注入可能赶不上正在最终确定的请求而由下一次边界领取。enter decision 返回的消息会在所属轮次内、消费它们的请求之前追加。在助手工具调用批次期间产生的上下文因此只会出现在该批次全部有序结果之后。
@@ -62,7 +62,7 @@ enter 分支的 `PreStepDecision.messages` 是拟议步骤的完整批次。wate
- `UserMessage` 是提示词拦截、工具执行、hook bridge、guard 和上下文生产方共享的带标识且冻结的形状。
- 公共类型、持久事件、投影和 UI 回放中均不存在 prompt-prefix 放置方式、提示词封套与 `context/message`
- idle 状态下的 `inject()` 会立即追加一条持久 inbox 插入记录,但不会追加模型可见的 `user/message`;后续可唤醒投递可能开始 pre-step 处理。
- collecting 和活跃轮次中的注入会在最近的后续 pre-step 边界领取,并位于完整工具结果批次之后、消费它的请求之前。
- 活跃轮次中的注入会在最近的后续 pre-step 边界领取,并位于完整工具结果批次之后、消费它的请求之前。
- pre-step reject 或失败会丢弃其已领取批次;领取后插入的 inbox 工作继续保持待处理。
- 单元测试、持久化与 resume 测试、不变量测试和 TUI 覆盖会固定事件顺序、领取归属和持久回放。

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 .agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md
2026-07-30-queued-manual-compaction.md: 05676ef824bc62ddbdbd8895a325570e91e4bafd
2026-07-30-queued-manual-compaction.zh.md: b2e40a42451570887a194c215df07e98aa1bd864
2026-07-30-queued-manual-compaction.md: 4b7a905712a01948146b8830dfc037185162eefc
2026-07-30-queued-manual-compaction.zh.md: 15a42de3536f2da5304e77ce3cb282029856ba6d

View File

@@ -8,7 +8,7 @@ English | [中文](2026-07-30-queued-manual-compaction.zh.md)
Automatic compaction protects the context window, but an interactive user also needs a deterministic way to condense accumulated history before pressure policy fires. Sending `/compact` as prompt text would spend a model turn and let the conversation model reinterpret a direct control action. Implementing it inside one UI would duplicate command discovery, lifecycle logging, cancellation, and backend policy.
The human command arrives between turns and must summarize asynchronously. A prompt accepted during that wait must keep its ordinary identity, FIFO position, and wakeup behavior, but it must not derive a request from history that compaction is about to replace. A status check is insufficient: a waking send schedules the driver's claim as a microtask, leaving a same-tick interval where status still reads idle even though the prompt already has right of way.
The human command arrives between turns and must summarize asynchronously. A prompt accepted during that wait must keep its ordinary identity, FIFO position, and wakeup behavior, but it must not derive a request from history that compaction is about to replace. A separate status check is insufficient because another caller can wake the driver between that check and the compaction operation claiming the idle phase.
Compaction also needs one mutual-exclusion fact shared by manual, pressure, overflow, and explicit-range entry points. A process-local flag alone cannot explain a crash-recovered log, while a summarize-first transaction leaves no durable evidence during the expensive interval. Conversely, treating marker pairs as exclusive containers would forbid valid idle injection even though injection is explicitly non-waking and immediate between turns.
@@ -22,15 +22,15 @@ This note extends the [compaction capability seam](2026-06-18-compaction-capabil
The command plugin tracks each real handler promise independently of the command executor's abort-aware wait. Its composite lifecycle effect unregisters `/compact` before asynchronously draining handlers that already started, so root teardown reaches quiescence only after backend close and flush work settles.
The seam's `ManualCompactAgentContext` adds only `reserveTurnAdmission()` to the session and routing facts compaction already needs. Retention, balancing, summarization, marker ordering, replacement, and durability remain backend responsibilities.
The seam's `ManualCompactAgentContext` adds only `runMaintenance()` to the session and routing facts compaction already needs. Retention, balancing, summarization, marker ordering, replacement, and durability remain backend responsibilities.
### Idle turn admission is synchronously reservable
### Idle maintenance is synchronously claimed
`Agent.reserveTurnAdmission(): (() => void) | undefined` claims the boundary before the next ordinary turn. It succeeds only when the driver is idle, no reservation exists, and no accepted waking item already owns the next turn, including a wake whose claim is still a pending microtask.
`Agent.runMaintenance(task)` starts only from the idle phase and claims that phase before invoking the task. A waking send starts the loop immediately when idle, so whichever operation claims the phase first owns the boundary.
The reservation does not create a second queue. Later sends keep their `InboxItemId`, placement, FIFO order, and wakeup facts. `acceptsNextStep` remains false, so waking next-step input becomes an ordinary queued follow-up rather than steering. Release is idempotent and re-arms the existing driver path. `inject()` is not withheld.
Maintenance does not create a second queue. Later sends keep their `MessageId`, placement, FIFO order, and wakeup facts. Waking input remains queued until maintenance settles, then starts the existing driver path; `inject()` remains non-waking.
`whenIdle()` treats a reservation as unfinished activity, including when it holds a waking item. Lifecycle teardown still drains the driver's own activity promise rather than awaiting an external operation, so disposal can cancel and unwind without depending on the reservation holder.
`whenIdle()` treats maintenance and any waking work released behind it as unfinished activity. Cancellation aborts the agent-owned maintenance signal, and lifecycle teardown drains the same activity boundary before disposal completes.
### One parameterized transaction owns every bracket
@@ -81,7 +81,7 @@ That reference also carried client-side replacement-anchor machinery to preserve
## Alternatives considered
**Check `agent.status` without reserving admission.** Rejected because an accepted waking send can still be waiting on its claim microtask while status reads idle.
**Check `agent.status` before starting maintenance.** Rejected because the check and phase claim would be separate operations; a waking send could start the driver between them.
**Queue the command itself.** Rejected because `/compact` is direct control, not model input, and a prompt already accepted first must retain right of way rather than being reordered around a second command queue.

View File

@@ -8,7 +8,7 @@ Status: implemented
自动压缩compaction可以保护上下文窗口但交互用户还需要一种确定性方法在压力策略触发前压缩累积的历史。把 `/compact` 作为提示词文本发送会消耗一个模型轮次,还会让会话模型重新解释一项直接控制操作。在某个 UI 内实现该功能,则会重复命令发现、生命周期日志记录、取消与后端策略。
面向用户的命令在轮次之间到达并且必须异步生成摘要。在等待期间获接纳的提示词必须保留普通身份、FIFO 位置与唤醒行为,但不得从即将被压缩替换的历史派生请求。检查状态并不足够:唤醒发送会把驱动器的认领安排为 microtask因此在同一 tick 内存在一段间隔,此时状态仍显示 idle但提示词已经拥有优先权
面向用户的命令在轮次之间到达并且必须异步生成摘要。在等待期间获接纳的提示词必须保留普通身份、FIFO 位置与唤醒行为,但不得从即将被压缩替换的历史派生请求。单独检查状态并不足够,因为另一调用方可能在该检查与压缩操作认领 idle phase 之间唤醒驱动器
手动、压力、溢出和显式范围入口点还需要共享同一项互斥事实。仅使用进程本地标志无法解释一份崩溃恢复后的日志,而先摘要再记录的事务在开销较大的等待期间不会留下持久证据。反过来,把标记对视为排他容器又会禁止有效的空闲注入,尽管注入按定义不会唤醒,并且会在轮次之间立即执行。
@@ -22,15 +22,15 @@ Status: implemented
命令插件会独立跟踪每个实际处理器 promise不依赖命令执行器的中止感知等待。其复合生命周期 effect 先注销 `/compact`,再异步等待所有已开始的处理器结算,因此根级 teardown 只有在后端的闭合与 flush 工作结算后才会完全停稳。
该 seam 的 `ManualCompactAgentContext` 只在压缩已需使用的会话与路由事实之上增加 `reserveTurnAdmission()`。保留、平衡、摘要、标记排序、替换与持久性仍由后端负责。
该 seam 的 `ManualCompactAgentContext` 只在压缩已需使用的会话与路由事实之上增加 `runMaintenance()`。保留、平衡、摘要、标记排序、替换与持久性仍由后端负责。
### 可以同步预留空闲轮次接纳
### 同步认领空闲维护阶段
`Agent.reserveTurnAdmission(): (() => void) | undefined` 会在下一个普通轮次之前认领边界。仅当驱动器空闲、没有既存预留,而且尚无已获接纳的唤醒项拥有下一轮次时,它才会成功;仍在等待 microtask 认领的唤醒项也包括在内
`Agent.runMaintenance(task)` 只能从 idle phase 启动,并会在调用任务前认领该 phase。会唤醒的发送会在 idle 时立即启动循环,因此先认领 phase 的操作会拥有该边界
该预留不会创建第二个队列。之后发送的项保留其 `InboxItemId`、位置、FIFO 顺序与唤醒信息。`acceptsNextStep` 保持 false因此唤醒的 next-step 输入会成为普通的排队 follow-up而不是 steering中途引导。释放操作可幂等调用并重新启用既有驱动器路径`inject()` 不受阻塞
维护阶段不会创建第二个队列。之后发送的项保留其 `MessageId`、位置、FIFO 顺序与唤醒信息。会唤醒的输入会保持排队,直至维护任务结算,再启动既有驱动器路径`inject()` 仍然不会唤醒驱动器
`whenIdle()` 会把预留视为尚未完成的活动,包括预留持有唤醒项的情况。生命周期 teardown 仍会排空驱动器自身的 activity promise而不会等待外部操作因此 dispose资源释放可以执行取消并完成退出清理无需依赖预留持有方
`whenIdle()` 会把维护任务及其结算后释放的所有唤醒工作视为尚未完成的活动。取消会中止 agent 自有的维护信号,生命周期 teardown 则会在 dispose资源释放完成前排空同一个活动边界
### 一个参数化事务拥有每一对标记
@@ -81,7 +81,7 @@ DSH 有意在调用摘要器前记录 `compact/start`。缓慢或崩溃的尝试
## 曾考虑的替代方案
**检查 `agent.status`,不预留接纳** 不予采用,因为已获接纳的唤醒发送可能仍在等待其认领 microtask而状态仍显示 idle
**在启动维护任务前检查 `agent.status`。** 不予采用,因为检查与 phase 认领会成为两个独立操作;会唤醒发送可能在二者之间启动驱动器
**把命令本身加入队列。** 不予采用,因为 `/compact` 是直接控制而非模型输入;先获接纳的提示词必须保留优先权,不能围绕第二个命令队列重新排序。

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 docs/architecture.md
architecture.md: bdc8a01d0acd87faf0f6afdcf89df406f6fe31b8
architecture.zh.md: cad5544fa234fec9e30bd39cccc2f00c8c320b56
architecture.md: e852506d94fa67da92819879dba4dc546fce540b
architecture.zh.md: b4b34b29c75996dc79188943dc1999db71e1710c

View File

@@ -78,7 +78,7 @@ choose declarative identity and fresh/resume path
-> enter session + agent -> session/created -> agent/created
-> enable driving -> agent/session-start(source) -> start driver
forever:
wait for waking inbox work
waking inbox insertion starts the driver before send returns
-> emit agent/status(running) if starting an interval
-> 'turn/start'
claim next-step input plus one next-turn message

View File

@@ -78,7 +78,7 @@ choose declarative identity and fresh/resume path
-> enter session + agent -> session/created -> agent/created
-> enable driving -> agent/session-start(source) -> start driver
forever:
wait for waking inbox work
waking inbox insertion starts the driver before send returns
-> emit agent/status(running) if starting an interval
-> 'turn/start'
claim next-step input plus one next-turn message

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 docs/core-data-structures/core.md
core.md: d31d07556d9cb7fd47d9dd5bd5617821bcb10482
core.zh.md: d1773fd8991e99321149acb727669e8f0cde8d00
core.md: ea5900ba4271869d3fef1c9d1014f26a166b4418
core.zh.md: f6f5295a66633b181b0791f839d3014e245c4eb3

View File

@@ -537,9 +537,9 @@ 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>
@@ -571,8 +571,8 @@ 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.
@@ -581,8 +581,8 @@ 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.

View File

@@ -545,9 +545,9 @@ 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>
@@ -579,8 +579,8 @@ 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.
@@ -589,8 +589,8 @@ 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.

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.