fix(agent-loop): second review pass — late-steering discard, dead-branch, catalog leaks
Address a second fresh-eye review of the review fixes: - MAJOR: late steering that lands after runTurn returns terminally stopped (e.g. during the post-turn flush) was drained by runLoop and dropped without a discard, leaving a dangling outstanding id the negative-only invariant can't catch. Emit agent/inbox/discard for it, symmetric with the in-turn terminal-stop drop. - remove the dead cancel() idle-settle branch: whenIdle's fast path already resolves for a lone quiet item, so no waiter is ever left for it to settle. Document why. - gen-cordis-api classShape now drops private/protected/#private members and strips getter/setter bodies, so Session no longer leaks private fields and getter bodies into the model catalog. - document that AgentMessage intentionally omits meta (durable-only). Adds a regression test for the late-steering discard.
This commit is contained in:
14 files changed
+122
-69
No files matched your search
+2
-2
@@ -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
|
||||
2026-07-22-unified-send-and-coalesced-user-messages.md: e969012c8172bbba78f04943be46b7dc866ccb7f
|
||||
2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 35cc183a7c7bfd8a649557e50e6e7d379f8f8193
|
||||
2026-07-22-unified-send-and-coalesced-user-messages.md: 9eb355128b217a0ea8dc09daf4e83334f6aeaa10
|
||||
2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 679c9100aa49777b7b601725bdfc077f5f2dab0e
|
||||
+1
-1
@@ -36,7 +36,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj
|
||||
|
||||
The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The cost: `Agent` became an abstract class, so object-literal test fakes must supply `followup` and cannot spread a class-typed value without re-casting (prototype methods are non-enumerable); the goal fold's channel split moved from event type to `source.round`; and every consumer that filtered `context/message` now filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged — an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`.
|
||||
|
||||
`wakeup` is the "should the model run" signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal. `SendOptions.meta` on a queued or steering send is carried onto the durable `user/message`/`steering/message`, matching injection. Every FIFO exit publishes exactly one lifecycle event: a terminal `agent/turn-stop` that drops pending steering now emits `agent/inbox/discard` for it, and a loop-authored continuation reason is snapshotted and frozen like a public send. `gen-cordis-api` collects exported classes (body-stripped) so the now-class `Agent` and its transitive shapes still appear in the model-facing API catalog.
|
||||
`wakeup` is the "should the model run" signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is ever left hanging). `SendOptions.meta` on a queued or steering send is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally not on the live `AgentMessage` event, which carries only routing facts. Every FIFO exit publishes exactly one lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` for it — both at the in-turn stop point and on the post-turn drain of late steering — and a loop-authored continuation reason is snapshotted and frozen like a public send. `gen-cordis-api` collects exported classes (public members, body-stripped) so the now-class `Agent` and its transitive shapes still appear in the model-facing API catalog.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`
|
||||
|
||||
投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。代价是:`Agent` 变成了抽象类,因此对象字面量形式的测试替身必须提供 `followup`,且无法在不重新做类型转换的情况下展开一个类类型的值(原型方法不可枚举);goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变——空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。
|
||||
|
||||
`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默。排队 send 或 steering send 上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致。每一次 FIFO 退出都恰好发布一个生命周期事件:一次会丢弃待处理 steering 项的终止性 `agent/turn-stop` 现在会为它发出 `agent/inbox/discard`,而由 loop 生成的继续原因会像一次对外 send 那样被快照并冻结。`gen-cordis-api` 收集导出的类(剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。
|
||||
`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会有等待者被永久挂起)。排队 send 或 steering send 上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 事件上,后者只携带路由事实。每一次 FIFO 退出都恰好发布一个生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`——既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时——而由 loop 生成的继续原因会像一次对外 send 那样被快照并冻结。`gen-cordis-api` 收集导出的类(公开成员,剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。
|
||||
|
||||
## 相关
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/steering work is clear
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:353`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:355`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/created` — emit
|
||||
|
||||
@@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:293`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/disposed` — emit
|
||||
|
||||
@@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/error` — emit
|
||||
|
||||
@@ -96,7 +96,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:501`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:503`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/inbox/dequeue` — emit
|
||||
|
||||
@@ -117,7 +117,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary,
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:335`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/inbox/discard` — emit
|
||||
|
||||
@@ -138,7 +138,7 @@ Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/t
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:343`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/inbox/enqueue` — emit
|
||||
|
||||
@@ -161,7 +161,7 @@ A detached, frozen item entered the agent's inbox (queued or steering FIFO). Sou
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:323`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:325`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/post-step` — serial
|
||||
|
||||
@@ -184,7 +184,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:451`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:453`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/pre-step` — serial
|
||||
|
||||
@@ -207,7 +207,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:382`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:384`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/prompt-submit` — waterfall
|
||||
|
||||
@@ -234,7 +234,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:398`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:400`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/request` — waterfall
|
||||
|
||||
@@ -259,7 +259,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:412`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:414`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/request-error` — waterfall
|
||||
|
||||
@@ -285,7 +285,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:466`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:468`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/session-prefix` — waterfall
|
||||
|
||||
@@ -311,7 +311,7 @@ Compose request-only messages placed before derived history. The frozen result i
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:427`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:429`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/session-start` — emit
|
||||
|
||||
@@ -333,7 +333,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:366`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:368`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/status` — emit
|
||||
|
||||
@@ -353,7 +353,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:313`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/step-result` — waterfall
|
||||
|
||||
@@ -376,7 +376,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:439`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:441`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/turn-continuation` — waterfall
|
||||
|
||||
@@ -398,7 +398,7 @@ Override whether the turn continues. The default continues after tool calls or s
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:477`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:479`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/turn-stop` — serial
|
||||
|
||||
@@ -420,7 +420,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:488`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:490`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
## `agent-loop/*`
|
||||
|
||||
|
||||
@@ -428,7 +428,9 @@ The `agent/inbox/*` live events carry one accepted message; injection bypasses t
|
||||
* message's enqueue, dequeue, and discard events. Source defaults are already
|
||||
* applied, so these are the exact values the item was accepted with. `steering`
|
||||
* is true for a `next-step` item drained between steps; a `next-turn` item is
|
||||
* claimed at a turn boundary.
|
||||
* claimed at a turn boundary. `SendOptions.meta` is intentionally omitted: it is
|
||||
* durable model-hidden state that lands on the eventual `user/message`/
|
||||
* `steering/message`, not live-event routing data.
|
||||
*/
|
||||
interface AgentMessage {
|
||||
/** The id `send` returned for this message. */
|
||||
|
||||
@@ -8,24 +8,24 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| Event | Mode | Declared in | Dispatchers | Listeners |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
|
||||
| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:353`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
|
||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:293`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:501`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
|
||||
| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) |
|
||||
| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:343`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) |
|
||||
| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:323`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:451`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) |
|
||||
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:382`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) |
|
||||
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:398`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
|
||||
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:412`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) |
|
||||
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:466`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) |
|
||||
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:427`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:366`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
|
||||
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:439`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:477`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) |
|
||||
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:488`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) |
|
||||
| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:355`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
|
||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:503`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
|
||||
| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:335`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) |
|
||||
| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) |
|
||||
| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:325`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:453`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) |
|
||||
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:384`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) |
|
||||
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:400`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
|
||||
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:414`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) |
|
||||
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:468`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) |
|
||||
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:429`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:368`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:313`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
|
||||
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:441`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:479`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) |
|
||||
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:490`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) |
|
||||
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
|
||||
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) |
|
||||
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
|
||||
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
@@ -1733,7 +1733,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'Session',
|
||||
declaration: 'export class Session {\n private log: SessionEvent[];\n private readonly surfaceManager;\n get surface(): SessionSurface {\n return this.surfaceManager;\n }\n readonly header: SessionHeader;\n get id(): SessionId {\n return this.header.id;\n }\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n private eventsSnapshot: readonly SessionEvent[] | undefined;\n get events(): readonly SessionEvent[] {\n this.eventsSnapshot ??= Object.freeze([...this.log]);\n return this.eventsSnapshot;\n }\n get seq(): number {\n return this.log.length;\n }\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n private headerFold: EpochHeader | undefined;\n private headerFoldSeq;\n requestHeader(): EpochHeader | undefined;\n private derived: Message[];\n private derivedNodes;\n private derivedGeneration;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
|
||||
declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionEvent',
|
||||
|
||||
@@ -347,11 +347,6 @@ export class ReactLoopAgent extends Agent {
|
||||
agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedCause)
|
||||
}
|
||||
if (!keepInbox) {
|
||||
// Whether the parked driver was already scheduled to run: a waking item
|
||||
// woke `waitForQueued`, so the loop WILL resume and settle idle waiters
|
||||
// itself through the pre-run-cancel path (possibly after a replacement
|
||||
// prompt). Only a lone quiet item leaves the loop truly parked.
|
||||
const willResume = this.#inbox.hasWakingQueued
|
||||
// Snapshot before clearing so the discard notification carries the exact
|
||||
// dropped items; a replacement synchronously enqueued by an
|
||||
// `agent/cancel-requested` observer belongs to the next turn, not here.
|
||||
@@ -362,15 +357,12 @@ export class ReactLoopAgent extends Agent {
|
||||
const items = discarded.map(({ message, steering }) => agentMessage(message, steering))
|
||||
agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items)
|
||||
}
|
||||
// Clearing a parked quiet (`wakeup:false`) item reaches quiescence with no
|
||||
// status transition and without waking the parked driver, so settle any
|
||||
// `whenIdle` waiter here. When a waking item was present the loop resumes
|
||||
// and settles itself; while `running` (including the post-turn flush
|
||||
// window) the driver still owns the eventual idle transition. So settle
|
||||
// only for a parked, non-running agent whose sole cleared work was quiet.
|
||||
if (cancellation === undefined && !willResume && this._status !== 'running') {
|
||||
this.settleIdleWaiters()
|
||||
}
|
||||
// No idle-waiter settle here: a `whenIdle` waiter exists only while the
|
||||
// agent is `running` or a waking item is queued, and neither is left
|
||||
// quiescent by clearing the inbox — a lone quiet item takes `whenIdle`'s
|
||||
// fast path (no waiter), a waking item keeps the woken driver running,
|
||||
// and a running agent owns its own idle transition (including the
|
||||
// post-turn flush window).
|
||||
}
|
||||
cancellation?.request(resolvedCause)
|
||||
}
|
||||
|
||||
@@ -264,9 +264,17 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
|
||||
handle.clearTurnCancellation(cancellation)
|
||||
}
|
||||
|
||||
// Late steering becomes queued input unless terminal policy stopped the turn.
|
||||
for (const message of handle.inbox.drainSteering()) {
|
||||
if (!terminalStopped) handle.inbox.enqueue(message)
|
||||
// Late steering (arriving after runTurn returns, e.g. during the post-turn
|
||||
// flush) becomes queued input — unless terminal policy stopped the turn, in
|
||||
// which case it is dropped and must publish a discard so its enqueue is
|
||||
// still matched (the invariant only catches a NEGATIVE count, not a leak).
|
||||
const lateSteering = handle.inbox.drainSteering()
|
||||
if (terminalStopped) {
|
||||
if (lateSteering.length > 0) {
|
||||
events.emit('agent/inbox/discard', lateSteering.map(message => agentMessage(message, true)))
|
||||
}
|
||||
} else {
|
||||
for (const message of lateSteering) handle.inbox.enqueue(message)
|
||||
}
|
||||
|
||||
// Park at idle unless a waking item still wants the model to run; a lone
|
||||
|
||||
@@ -117,4 +117,39 @@ describe('inbox FIFO-conservation invariant', () => {
|
||||
expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false)
|
||||
expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false)
|
||||
})
|
||||
|
||||
it('stays balanced when late steering lands after a terminal stop (post-turn flush window)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let enqueues = 0
|
||||
const discards: number[] = []
|
||||
ctx.on('agent/inbox/enqueue', (subject) => { if (subject === agent) enqueues += 1 })
|
||||
ctx.on('agent/inbox/discard', (subject, messages) => { if (subject === agent) discards.push(messages.length) })
|
||||
|
||||
// Terminal-stop the turn, then steer during the post-turn flush window
|
||||
// (status is still running). That late steer is drained by runLoop and
|
||||
// dropped because the turn terminally stopped; it must still be discarded so
|
||||
// its enqueue is matched (the drain sits on a different code path than the
|
||||
// in-turn terminal-stop drop).
|
||||
ctx.on('agent/turn-stop', subject => (subject === agent ? { action: 'stop' as const } : undefined))
|
||||
let steered = false
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session !== agent.session || steered) return
|
||||
steered = true
|
||||
agent.steer([{ type: 'text', text: 'late' }], { source: { kind: 'plugin', plugin: 'late' } })
|
||||
})
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The prompt plus the late steer both enqueued; both are matched (the prompt
|
||||
// dequeued, the late steer discarded) so no id is left outstanding.
|
||||
expect(enqueues).toBe(2)
|
||||
expect(discards).toEqual([1])
|
||||
expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false)
|
||||
expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -89,7 +89,9 @@ export function AgentMessageId(id: string): AgentMessageId {
|
||||
* message's enqueue, dequeue, and discard events. Source defaults are already
|
||||
* applied, so these are the exact values the item was accepted with. `steering`
|
||||
* is true for a `next-step` item drained between steps; a `next-turn` item is
|
||||
* claimed at a turn boundary.
|
||||
* claimed at a turn boundary. `SendOptions.meta` is intentionally omitted: it is
|
||||
* durable model-hidden state that lands on the eventual `user/message`/
|
||||
* `steering/message`, not live-event routing data.
|
||||
*/
|
||||
export interface AgentMessage {
|
||||
/** The id `send` returned for this message. */
|
||||
|
||||
@@ -36,20 +36,34 @@ function quote(value: string): string {
|
||||
* program against, so it belongs in the type closure alongside interfaces.
|
||||
*/
|
||||
function classShape(node: ts.ClassDeclaration): ts.ClassDeclaration {
|
||||
const members = node.members.map((member): ts.ClassElement => {
|
||||
const isNonPublic = (member: ts.ClassElement): boolean =>
|
||||
(ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined)?.some(m =>
|
||||
m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword) ?? false
|
||||
const members = node.members.flatMap((member): ts.ClassElement[] => {
|
||||
// A model-facing type shape carries only the public surface — drop private,
|
||||
// protected, and #private members, and strip every kept member's body.
|
||||
if (isNonPublic(member) || (ts.isPropertyDeclaration(member) && ts.isPrivateIdentifier(member.name))) return []
|
||||
if (ts.isMethodDeclaration(member)) {
|
||||
return ts.factory.updateMethodDeclaration(
|
||||
return [ts.factory.updateMethodDeclaration(
|
||||
member, member.modifiers, member.asteriskToken, member.name, member.questionToken,
|
||||
member.typeParameters, member.parameters, member.type, undefined)
|
||||
member.typeParameters, member.parameters, member.type, undefined)]
|
||||
}
|
||||
if (ts.isConstructorDeclaration(member)) {
|
||||
return ts.factory.updateConstructorDeclaration(member, member.modifiers, member.parameters, undefined)
|
||||
return [ts.factory.updateConstructorDeclaration(member, member.modifiers, member.parameters, undefined)]
|
||||
}
|
||||
if (ts.isGetAccessorDeclaration(member)) {
|
||||
return [ts.factory.updateGetAccessorDeclaration(
|
||||
member, member.modifiers, member.name, member.parameters, member.type, undefined)]
|
||||
}
|
||||
if (ts.isSetAccessorDeclaration(member)) {
|
||||
return [ts.factory.updateSetAccessorDeclaration(
|
||||
member, member.modifiers, member.name, member.parameters, undefined)]
|
||||
}
|
||||
if (ts.isPropertyDeclaration(member)) {
|
||||
return ts.factory.updatePropertyDeclaration(
|
||||
member, member.modifiers, member.name, member.questionToken ?? member.exclamationToken, member.type, undefined)
|
||||
return [ts.factory.updatePropertyDeclaration(
|
||||
member, member.modifiers, member.name, member.questionToken ?? member.exclamationToken, member.type, undefined)]
|
||||
}
|
||||
return member
|
||||
return [member]
|
||||
})
|
||||
return ts.factory.updateClassDeclaration(
|
||||
node, node.modifiers, node.name, node.typeParameters, node.heritageClauses, members)
|
||||
|
||||
Reference in New Issue
Block a user