From 9152858fd727ebf15f9288365d6aa6a23ecf6ee8 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 27 Jul 2026 18:58:27 +0800 Subject: [PATCH] fix(agent-loop): preserve deferred message boundaries --- ...send-and-coalesced-user-messages.i18n.yaml | 4 +- ...nified-send-and-coalesced-user-messages.md | 6 +- ...ied-send-and-coalesced-user-messages.zh.md | 6 +- docs/cordis-catalog/events.md | 30 ++--- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 5 +- docs/core-data-structures/core.zh.md | 5 +- docs/event-producer-consumer.md | 30 ++--- packages/core/agent-loop/src/agent.ts | 50 ++++++- .../tests/contract-regressions.spec.ts | 2 +- .../agent-loop/tests/coverage-edges.spec.ts | 28 +++- .../agent-loop/tests/interception.spec.ts | 123 +++++++++++++++++- packages/core/agent-loop/tests/loop.spec.ts | 23 ++-- packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 2 +- packages/core/agent/README.zh.md | 2 +- packages/core/agent/src/types.ts | 5 +- 17 files changed, 253 insertions(+), 76 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index d3b67d90c2..418637f08f 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -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: 63af2a34a331e0008009457d5dbc5b9381795ace -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: baf81b064899a58ac3d370187135ac99afbbdec7 +2026-07-22-unified-send-and-coalesced-user-messages.md: 6936fbfa04c0fdaf1a8786c0465c193e9c285243 +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 3af14359fa01e92f63ae3b3e51dced9a97f6419f diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index 63af2a34a3..6936fbfa04 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -24,9 +24,11 @@ Separately, `context/message` and `user/message` had converged: the surface proj **Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items) type their `AgentMessage` payload with only the accepted message's returned `id`, content, and source. Enqueue separately carries the resolved `queued | steering` placement captured by the producer at acceptance time, so observers and reconnect mirrors never reconstruct routing from later status or session history. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including steering submitted by an `agent/turn-stopping` listener, so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. -**Admission accepts next-step input without becoming a turn.** The loop opens a private next-step acceptance window before `agent/prompt-submit`, keeps it open through the turn, and closes it before `turn/end`. Steering and injection received during admission therefore remain together in the outbox and join an allowed turn. If admission blocks or fails, a context-only caller batch takes idle injection's immediate append, while steering and context staged beside it remain available to retry; neither path writes the rejected prompt or hook-produced context. Closing the window before `turn/end` preserves the rule that reentrant late steering becomes an independent queued turn. `Agent.acceptsNextStep` exposes whether a `next-step` send would currently join this window; `status` remains the broader activity signal rather than a routing predicate. +**Admission accepts next-step input without becoming a turn.** The loop opens a private next-step acceptance window before `agent/prompt-submit`, keeps it open through the turn, and closes it before `turn/end`. Steering and injection received during admission therefore remain together in the outbox and join an allowed turn. If admission blocks or fails, a context-only caller batch takes idle injection's immediate append, while steering and context staged beside it remain available to retry; neither path writes the rejected prompt. When a later prompt is admitted, retained outbox input enters its turn before that prompt, while input accepted during the current admission remains after the prompt. Closing the window before `turn/end` preserves the rule that reentrant late steering becomes an independent queued turn. `Agent.acceptsNextStep` exposes whether a `next-step` send would currently join this window; `status` remains the broader activity signal rather than a routing predicate. -**One accepted message keeps one representation.** Durable user-role input and additional model-facing context both use `UserMessageData { content, source }` directly; public `AgentMessage` extends it with the correlation `id`, and the loop-private `PendingMessage` extends that with `wakeup`. A queued message that becomes steering enters the outbox as the same `PendingMessage` object, while injected and tool-produced context enters as plain `UserMessageData`. The outbox therefore stores their union directly instead of wrapping steering beside a duplicate copy of its content and source. Provider-native assistant messages remain adapter-owned output types and do not participate in this input hierarchy. +**One accepted message keeps one representation.** Durable user-role input and additional model-facing context both use `UserMessageData { content, source }` directly; public `AgentMessage` extends it with the correlation `id`, and the loop-private `PendingMessage` extends that with `wakeup`. The loop clones and freezes `UserMessageData` before publication, queueing, or immediate append, so later caller or observer mutation cannot change the accepted value. A queued message that becomes steering enters the outbox as the same `PendingMessage` object, while injected and tool-produced context enters as plain `UserMessageData`. The outbox therefore stores their union directly instead of wrapping steering beside a duplicate copy of its content and source. Provider-native assistant messages remain adapter-owned output types and do not participate in this input hierarchy. + +**Idle wakeup follows acceptance.** Before publishing enqueue, a waking queued send installs quiescence ownership and schedules driver admission for a microtask that runs after the id returns. Every send in one synchronous caller stack therefore resolves placement against the same pre-admission state, while reentrant cancellation or teardown cannot retire before the scheduled admission settles. Two idle `steer()` calls remain two FIFO turns instead of the first opening an admission window that captures the second. **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). diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index baf81b0648..3af14359fa 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -24,9 +24,11 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` **三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都将各自的 `AgentMessage` 载荷类型限定为仅包含被接受消息所返回的 `id`、内容和来源。enqueue 还会单独携带生产方在接受消息时捕获的已解析 `queued | steering` 放置方式,因此观察方和重连镜像永远不必根据后续状态或会话历史重建路由。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括 `agent/turn-stopping` 监听器提交的 steering,因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 -**准入接受 next-step 输入,但不会因此成为一个轮次。** 循环会在 `agent/prompt-submit` 前打开一个私有的 next-step 接受窗口,使其贯穿整个轮次,并在 `turn/end` 前关闭。因此,在准入期间收到的 steering 和注入会一起留在 outbox 中并加入获准轮次。如果准入被阻止或失败,仅含调用方上下文的批次会采用空闲注入的立即追加行为,而 steering 及与其一同暂存的上下文仍可重试;两种路径都不会写入被拒绝的提示词或钩子产生的上下文。在 `turn/end` 前关闭窗口,可以保留这样的规则:可重入的晚到 steering 会成为一个独立的排队轮次。`Agent.acceptsNextStep` 会公开一次 `next-step` 发送当前是否会加入该窗口;`status` 仍是更宽泛的活动信号,而非路由判据。 +**准入接受 next-step 输入,但不会因此成为一个轮次。** 循环会在 `agent/prompt-submit` 前打开一个私有的 next-step 接受窗口,使其贯穿整个轮次,并在 `turn/end` 前关闭。因此,在准入期间收到的 steering 和注入会一起留在 outbox 中并加入获准轮次。如果准入被阻止或失败,仅含调用方上下文的批次会采用空闲注入的立即追加行为,而 steering 及与其一同暂存的上下文仍可重试;两种路径都不会写入被拒绝的提示词。后续提示词获准时,保留在 outbox 中的输入会先于该提示词进入其轮次,而当前准入期间接受的输入则留在提示词之后。在 `turn/end` 前关闭窗口,可以保留这样的规则:可重入的晚到 steering 会成为一个独立的排队轮次。`Agent.acceptsNextStep` 会公开一次 `next-step` 发送当前是否会加入该窗口;`status` 仍是更宽泛的活动信号,而非路由判据。 -**一条已接受消息只保留一种表示。** 持久的用户角色输入和附加的模型可见上下文都直接使用 `UserMessageData { content, source }`;公开的 `AgentMessage` 在此基础上增加用于关联的 `id`,循环私有的 `PendingMessage` 再增加 `wakeup`。一条成为 steering 的排队消息会以同一个 `PendingMessage` 对象进入 outbox,而注入和工具产生的上下文则以普通 `UserMessageData` 进入。因此,outbox 直接存储这两种类型的联合,而不再把 steering 与一份重复的内容和来源副本包装在一起。提供方原生的助手消息仍是适配器拥有的输出类型,不参与这套输入层级。 +**一条已接受消息只保留一种表示。** 持久的用户角色输入和附加的模型可见上下文都直接使用 `UserMessageData { content, source }`;公开的 `AgentMessage` 在此基础上增加用于关联的 `id`,循环私有的 `PendingMessage` 再增加 `wakeup`。循环会在发布、入队或立即追加前克隆并冻结 `UserMessageData`,因此调用方或观察方后续的修改无法改变已接受的值。一条成为 steering 的排队消息会以同一个 `PendingMessage` 对象进入 outbox,而注入和工具产生的上下文则以普通 `UserMessageData` 进入。因此,outbox 直接存储这两种类型的联合,而不再把 steering 与一份重复的内容和来源副本包装在一起。提供方原生的助手消息仍是适配器拥有的输出类型,不参与这套输入层级。 + +**空闲唤醒在接受之后发生。** 在发布 enqueue 前,一次会唤醒驱动器的排队发送会先取得完全停稳所有权,并把驱动器准入调度到一个会在该次发送返回 id 后运行的微任务中。因此,同一同步调用栈中的每次发送都会基于同一份准入前状态解析放置方式,而可重入的取消或拆除在已调度的准入结算前无法完成退役。空闲时的两次 `steer()` 调用会保留为两个 FIFO 轮次,而不会因第一次调用打开准入窗口而把第二次吸纳进去。 **cancel 新增 keepInbox。** `cancel(cause, { keepInbox? })`;调用方显式选择 cause,且 `keepInbox: true` 会中止活跃轮次,同时保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 50558f9b9a..32de7a06c5 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/outbox work is cleared 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:313`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:314`](../../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:252`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:253`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:261`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:262`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:424`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:425`](../../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:291`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:292`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -140,7 +140,7 @@ Pending inbox items were dropped without delivering them, so every enqueued id r 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:303`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -162,7 +162,7 @@ An item entered the queued or steering inbox. `placement` is the acceptance-time Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [InboxPlacement](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:281`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:282`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -186,7 +186,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message or 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:341`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:342`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -210,7 +210,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach 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:367`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:368`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -236,7 +236,7 @@ Handle a model-request failure after its failed step has closed but before the f Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../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:383`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -258,7 +258,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:326`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:327`](../../packages/core/agent/src/types.ts) ### `agent/settled` — emit @@ -283,7 +283,7 @@ One drain chain reached its terminal turn: that turn's `turn/end` is already com Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:411`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:412`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -303,7 +303,7 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s 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:270`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:271`](../../packages/core/agent/src/types.ts) ### `agent/step` — serial @@ -327,7 +327,7 @@ Awaited serial checkpoint before EVERY request of a turn is built (the first as Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:354`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:355`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -353,7 +353,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:397`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:398`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index b4427e3f8c..8beb9f39a9 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -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: b961cde21198803af1a0029e9d8e946d84ef410c -core.zh.md: a54c39ed9b41a7946cecfb6c67bfee3e8f5b894b +core.md: a246cd7a4afe0d36b30a5db9312fca2fee4d99fe +core.zh.md: eddfe8111ec03fe4a77a5a7ca1b6f04c777f2163 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index b961cde211..a246cd7a4a 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -465,8 +465,8 @@ The `agent/inbox/*` live events carry one accepted message; injection bypasses t /** * One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live * events. `id` is the value `send` returned to the caller, stable across this - * message's enqueue, dequeue, and discard events. Its content and source are - * the exact input values accepted by the agent. + * message's enqueue, dequeue, and discard events. The agent snapshots and + * freezes the accepted content and source before enqueue observers receive it. */ interface AgentMessage extends UserMessageData { /** The id `send` returned for this message. */ @@ -531,6 +531,7 @@ interface Agent { * immediately without opening a turn. If admission closes without a turn, * a context-only boundary appends immediately; context staged beside * steering remains pending with it. + * The agent snapshots and freezes `input` before publishing or queueing it. * @param input - model-facing content and its producer provenance. * @param options - target queue and wakeup decision. * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index a54c39ed9b..eddfe8111e 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -473,8 +473,8 @@ type AgentMessageId = Branded<'AgentMessageId'> /** * One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live * events. `id` is the value `send` returned to the caller, stable across this - * message's enqueue, dequeue, and discard events. Its content and source are - * the exact input values accepted by the agent. + * message's enqueue, dequeue, and discard events. The agent snapshots and + * freezes the accepted content and source before enqueue observers receive it. */ interface AgentMessage extends UserMessageData { /** The id `send` returned for this message. */ @@ -539,6 +539,7 @@ interface Agent { * immediately without opening a turn. If admission closes without a turn, * a context-only boundary appends immediately; context staged beside * steering remains pending with it. + * The agent snapshots and freezes `input` before publishing or queueing it. * @param input - model-facing content and its producer provenance. * @param options - target queue and wakeup decision. * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 80ba889515..3a54be9f9f 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,21 +8,21 @@ 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:140`](../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:313`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:252`](../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:261`](../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:424`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:291`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:281`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:341`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:367`](../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:382`](../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) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | -| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:411`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:270`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:354`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:397`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:314`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:253`](../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:262`](../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:425`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:292`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:282`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:342`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:368`](../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:383`](../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) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:327`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | +| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:412`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:271`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:355`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:398`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `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/acp/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 295d3159f8..86943d4e7a 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -52,6 +52,8 @@ export class ReactLoopAgent implements Agent { /** Whether observers see a running interval; consecutive turns share it. */ private busy = false + /** Whether an idle waking send has deferred driver admission. */ + private wakeScheduled = false /** Whether next-step input belongs to the current admission or open turn. */ acceptsNextStep = false /** Abort owner for the current admission or turn. */ @@ -95,7 +97,7 @@ export class ReactLoopAgent implements Agent { input: UserMessageData, options: SendOptions, ): AgentMessageId { - const { content, source } = input + const { content, source } = deepFreeze(structuredClone(input)) const { target, wakeup } = options const id = AgentMessageId(randomUUID()) if (target === 'next-step' && !wakeup) { @@ -113,13 +115,17 @@ export class ReactLoopAgent implements Agent { content, source, } + deepFreeze(message) if (placement === 'steering') { this.outbox.push(message) } else { this.queued.push({ message, wakeup }) } + // Preserve the routing decision for every send in this synchronous caller + // stack, while installing quiescence ownership before enqueue observers + // can cancel or dispose. + if (placement === 'queued' && wakeup) this.scheduleKick() emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', message, placement) - if (placement === 'queued' && wakeup) this.kick() return id } @@ -200,11 +206,33 @@ export class ReactLoopAgent implements Agent { // but the waiter must not gamble quiescence on that: a future escape // still counts as settled activity. /* v8 ignore next 3 -- the catch arm backstops rejection paths that are all currently contained */ - while (this.abort !== undefined || this.queued.some(item => item.wakeup)) { + while (this.wakeScheduled || this.abort !== undefined || this.queued.some(item => item.wakeup)) { await this.done.catch(() => undefined) } } + /** Defer idle admission while keeping {@link done} as its quiescence owner. */ + private scheduleKick(): void { + if (this.abort !== undefined || this.wakeScheduled) return + this.wakeScheduled = true + const pending = Promise.withResolvers() + const scheduled = pending.promise + queueMicrotask(() => { + this.wakeScheduled = false + this.kick() + const activity = this.done + if (activity === scheduled) { + pending.resolve() + } else { + void activity.then( + () => { pending.resolve() }, + () => { pending.resolve() }, + ) + } + }) + this.done = scheduled + } + /** Claim and admit the next queued prompt, then start its turn. */ private kick(): void { if (this.abort !== undefined || !this.queued.some(item => item.wakeup)) return @@ -212,6 +240,7 @@ export class ReactLoopAgent implements Agent { // assertion expresses that invariant. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const { message } = this.queued.shift()! + const inheritedOutboxLength = this.outbox.length const admission = new AbortController() this.abort = admission @@ -274,7 +303,7 @@ export class ReactLoopAgent implements Agent { this.continueOrIdle() return } - await this.run(trigger, admitted) + await this.run(trigger, admitted, inheritedOutboxLength) }) // Published only after the abort owner and pending done are installed: a // dequeue listener that cancels or disposes must find live cancellation @@ -286,7 +315,11 @@ export class ReactLoopAgent implements Agent { * Run one turn and any request-error retry. `admitted` input enters the log * only after `turn/start` commits; until then it has no owner state to unwind. */ - private async run(trigger: TurnTrigger, admitted: UserMessageData[] = []): Promise { + private async run( + trigger: TurnTrigger, + admitted: UserMessageData[] = [], + inheritedOutboxLength = 0, + ): Promise { // Both entries hold the invariant: kick() clears the admission slot before // awaiting run(), and retry() returns early whenever a slot owner exists. /* v8 ignore next -- unreachable guard: every caller clears or checks the abort slot first */ @@ -316,6 +349,9 @@ export class ReactLoopAgent implements Agent { this.turnOpen = true opened = true this.lastTurn = turn + // Context or steering retained by an earlier rejected admission happened + // before this prompt and must occupy the same order in durable history. + this.drainOutbox(turn, inheritedOutboxLength) for (const input of admitted) { this.session.append('user/message', input, { surfaceOp: 'append' }) } @@ -611,9 +647,9 @@ export class ReactLoopAgent implements Agent { } /** Commit the outbox and report whether it contained steering. */ - private drainOutbox(turn: number): boolean { + private drainOutbox(turn: number, limit = this.outbox.length): boolean { let steered = false - for (const message of this.outbox.splice(0)) { + for (const message of this.outbox.splice(0, limit)) { if ('id' in message) { steered = true emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', message) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index bbac92a244..c725b8d55a 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -928,7 +928,7 @@ describe('turn and step boundary recovery', () => { }) send(agent, 'go') - await driverDone(agent) + await agent.whenIdle() const e = [...agent.session.events] // Balanced: one turn/start, one turn/end carrying disposed (NOT error). diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 99dcbd1d41..ac27233aab 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -348,7 +348,7 @@ describe('stream failure edges', () => { }) describe('post-turn continuation edges', () => { - it('an agent/settled listener that enqueues a waking prompt preempts continueOrIdle', async () => { + it('an agent/settled listener that starts a retry preempts continueOrIdle', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('settled-preempt'), { provider: 'mock', model: 'mock' }) @@ -357,9 +357,9 @@ describe('post-turn continuation edges', () => { if (subject !== agent || injected) return expect(subject.status).toBe('running') injected = true - // kick() installs the next admission synchronously, so the following - // continueOrIdle() sees an abort owner and yields to it. - send(agent, 'follow-up from settled listener') + // retry() installs the next run synchronously, so the following + // continueOrIdle() sees its abort owner and yields to it. + subject.retry() }) send(agent, 'go') @@ -524,6 +524,26 @@ describe('unrenderable failure settlement', () => { }) describe('driver bookkeeping edges', () => { + it('a deferred wake settles when replacement activity rejects', async () => { + const adapter = new MockAdapter([]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('rejected-deferred-wake'), { + provider: 'mock', + model: 'mock', + }) + ctx.on('agent/inbox/enqueue', (subject) => { + if (subject !== agent) return + subject.cancel({ kind: 'user' }) + const mutable = subject as Agent & { done: Promise } + mutable.done = Promise.reject(new Error('replacement rejected')) + }) + + send(agent, 'cancel before wake') + + await expect(agent.whenIdle()).resolves.toBeUndefined() + expect(agent.session.events).toEqual([]) + }) + it('a whenIdle waiter survives a rejected driver promise', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index e215bbaa3b..9667318555 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -1,10 +1,21 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +import SessionStore, { + SessionId, + type SessionEvent, + type TurnEndReason, + type UserMessageData, +} from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { type Agent, type InboxPlacement, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { + type Agent, + type AgentMessage, + type InboxPlacement, + type PromptDecision, + type SessionStartSource, +} from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -69,6 +80,57 @@ describe('agent/prompt-submit', () => { expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'hello' }]) }) + it('snapshots and freezes input before publishing or awaiting admission', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('owned-input'), { provider: 'mock', model: 'mock' }) + const entered = Promise.withResolvers() + const decision = Promise.withResolvers() + const observed: AgentMessage[] = [] + ctx.on('agent/inbox/enqueue', (subject, message) => { + if (subject !== agent) return + expect(Object.isFrozen(message)).toBe(true) + expect(Object.isFrozen(message.content)).toBe(true) + expect(Object.isFrozen(message.content[0])).toBe(true) + expect(Object.isFrozen(message.source)).toBe(true) + expect(() => { + const block = message.content[0] + if (block?.type === 'text') block.text = 'listener mutation' + }).toThrow() + }) + ctx.on('agent/inbox/enqueue', (subject, message) => { + if (subject === agent) observed.push(message) + }) + ctx.on('agent/prompt-submit', async () => { + entered.resolve(undefined) + return decision.promise + }) + const input: UserMessageData = { + content: [{ type: 'text', text: 'accepted text' }], + source: { kind: 'plugin', plugin: 'accepted source' }, + } + + const idle = waitForIdle(ctx, agent) + agent.followup(input) + await entered.promise + const block = input.content[0] + if (block?.type === 'text') block.text = 'caller mutation' + if (input.source.kind === 'plugin') input.source.plugin = 'caller mutation' + decision.resolve({ kind: 'allow' }) + await idle + + expect(observed).toHaveLength(1) + expect(observed[0]).toMatchObject({ + content: [{ type: 'text', text: 'accepted text' }], + source: { kind: 'plugin', plugin: 'accepted source' }, + }) + const userMsg = events(agent).find(event => event.type === 'user/message') + expect(userMsg?.type === 'user/message' && userMsg.data).toEqual({ + content: [{ type: 'text', text: 'accepted text' }], + source: { kind: 'plugin', plugin: 'accepted source' }, + }) + }) + it('allow with content REWRITES the prompt before it is recorded', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) @@ -257,6 +319,54 @@ describe('agent/prompt-submit', () => { expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged steering') }) + it('orders rejected-admission outbox input before a later admitted prompt', async () => { + const adapter = new MockAdapter([textResponse('continued')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('rejected-admission-order'), { + provider: 'mock', + model: 'mock', + }) + ctx.on('agent/prompt-submit', async (_agent, content, _source, _signal, next) => { + const decision = await next() + return content.some(block => block.type === 'text' && block.text === 'blocked prompt') + ? { kind: 'block', reason: 'policy' } + : decision + }) + ctx.on('agent/prompt-submit', async (subject, content, _source, _signal, next) => { + if (content.some(block => block.type === 'text' && block.text === 'blocked prompt')) { + subject.inject({ + content: [{ type: 'text', text: 'earlier state change' }], + source: { kind: 'plugin', plugin: 'test' }, + }) + subject.steer({ + content: [{ type: 'text', text: 'earlier steering' }], + source: { kind: 'user' }, + }) + } + return next() + }) + + const idle = waitForIdle(ctx, agent) + send(agent, 'blocked prompt') + send(agent, 'later prompt') + await idle + + const staged = events(agent).filter(event => + event.type === 'turn/start' || event.type === 'user/message' || event.type === 'steering/message') + expect(staged.map(event => event.type)).toEqual([ + 'turn/start', + 'user/message', + 'steering/message', + 'user/message', + ]) + expect(staged[1]?.type === 'user/message' && staged[1].data.content) + .toEqual([{ type: 'text', text: 'earlier state change' }]) + expect(staged[2]?.type === 'steering/message' && staged[2].data.content) + .toEqual([{ type: 'text', text: 'earlier steering' }]) + expect(staged[3]?.type === 'user/message' && staged[3].data.content) + .toEqual([{ type: 'text', text: 'later prompt' }]) + }) + it('commits context-only injection when admission closes without a turn', async () => { const adapter = new MockAdapter([]) const ctx = await harness(adapter) @@ -296,13 +406,20 @@ describe('agent/prompt-submit', () => { vi.spyOn(agent.session, 'append').mockImplementationOnce(() => { throw new Error('append unavailable') }) - ctx.on('agent/prompt-submit', async () => ({ kind: 'block', reason: 'policy' })) + const entered = Promise.withResolvers() + const decision = Promise.withResolvers() + ctx.on('agent/prompt-submit', async () => { + entered.resolve(undefined) + return decision.promise + }) agent.followup({ content: [{ type: 'text', text: 'blocked prompt' }], source: { kind: 'user' } }) + await entered.promise agent.inject({ content: [{ type: 'text', text: 'retained context' }], source: { kind: 'plugin', plugin: 'test' }, }) + decision.resolve({ kind: 'block', reason: 'policy' }) await agent.whenIdle() expect(events(agent)).toEqual([]) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index ce1d90a461..b4d2e53c68 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -293,8 +293,8 @@ describe('agent loop', () => { expect(flat).toContain('change of plans') }) - it('same-tick steering joins the prompt already in admission', async () => { - const adapter = new MockAdapter([textResponse('combined')]) + it('same-tick idle steering preserves one turn per send', 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' }) @@ -303,21 +303,18 @@ describe('agent loop', () => { agent.steer({ content: [{ type: 'text', text: 'second idle steer' }], source: { kind: 'user' } }) await idle - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) expect(agent.session.events .filter(event => event.type === 'user/message') .map(event => event.data.content)).toEqual([ [{ type: 'text', text: 'first idle steer' }], - ]) - expect(agent.session.events - .filter(event => event.type === 'steering/message') - .map(event => event.data.content)).toEqual([ [{ type: 'text', text: 'second idle steer' }], ]) - expect(adapter.requests).toHaveLength(1) - const request = JSON.stringify(adapter.requests[0]?.messages) - expect(request).toContain('first idle steer') - expect(request).toContain('second idle steer') + expect(agent.session.events.filter(event => event.type === 'steering/message')).toEqual([]) + expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('first 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('keeps steering staged after a failed step until retry', async () => { @@ -428,7 +425,7 @@ describe('agent loop', () => { expect(result.seq).toBeLessThan(contexts[0]!.seq) expect(contexts.flatMap(event => event.type === 'user/message' ? event.data.content : [])) .toEqual([ - { type: 'text', text: 'mutated after inject' }, + { type: 'text', text: 'mid-turn notice' }, { type: 'text', text: 'second notice' }, ]) @@ -441,7 +438,7 @@ describe('agent loop', () => { ? [index] : []) expect(resultIndex).toBeGreaterThanOrEqual(0) - expect(contextIndexes).toHaveLength(1) + expect(contextIndexes).toHaveLength(2) expect(contextIndexes.every(index => index > resultIndex)).toBe(true) }) diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index f80fb48c14..8361e4e6bf 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -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: d71ea99001d9e764265550e0b50f898e2da541ff -README.zh.md: 8fea509eacbf22ae8616fdbb7c8ed4074d955528 +README.md: b9e7914ac2238b677e5c799db58ca54088140352 +README.zh.md: 1a88f0d2e4c4619defae95a4cbe49caec7768599 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index d71ea99001..b9e7914ac2 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -58,7 +58,7 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(input, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix. `input` is the existing `UserMessageData { content, source }`, while `SendOptions` requires only the routing policy `target` and `wakeup`. It returns the accepted message's opaque `AgentMessageId`, which the message's `agent/inbox/enqueue`/`dequeue`/`discard` events carry so a caller can correlate a queued item with its lifecycle; enqueue also carries the resolved `queued | steering` placement so listeners never reconstruct acceptance-time routing from later state. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. +- `agent.send(input, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix. `input` is the existing `UserMessageData { content, source }`, while `SendOptions` requires only the routing policy `target` and `wakeup`. The agent snapshots and freezes `input` before publication or queueing, so later caller or observer mutation cannot change the accepted message. It returns the accepted message's opaque `AgentMessageId`, which the message's `agent/inbox/enqueue`/`dequeue`/`discard` events carry so a caller can correlate a queued item with its lifecycle; enqueue also carries the resolved `queued | steering` placement so listeners never reconstruct acceptance-time routing from later state. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. - `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. - `agent.steer(input)` — the `next-step`/wakeup preset: during prompt admission or an open turn, stage steering for the next safe boundary without dispatching `agent/prompt-submit`; outside that acceptance window, delegate to a woken follow-up. Admission failure leaves staged steering for retry or a later admitted prompt, while cancellation or disposal may discard it. - `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. During prompt admission or an open turn, injection waits in the outbox for the next safe boundary. Outside that acceptance window, it appends immediately without opening a turn; a context-only admission batch takes this fallback if admission closes without a turn, while context staged beside steering remains pending with it. Persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 8fea509eac..1a88f0d2e4 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -58,7 +58,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, 每个插件面向的 handle: -- `agent.send(input, options)`:覆盖(`target` × `wakeup`)矩阵的唯一投递原语。`input` 是既有的 `UserMessageData { content, source }`,而 `SendOptions` 只要求路由策略 `target` 与 `wakeup`。它返回被接受消息的不透明 `AgentMessageId`,由该消息的 `agent/inbox/enqueue`/`dequeue`/`discard` 事件携带,调用方可据此把排队项与其生命周期关联;入队事件还会携带解析出的 `queued | steering` 路由归类,使监听器无需从后续状态重建接收时的路由。`target: 'next-turn'` 排队一条独立 FIFO 项,获准后成为其轮次中唯一的普通提示词。`target: 'next-step'` 且 `wakeup: true` 提交 steering(中途引导),而 `target: 'next-step'` 且 `wakeup: false` 注入持久上下文,不运行模型。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。 +- `agent.send(input, options)`:覆盖(`target` × `wakeup`)矩阵的唯一投递原语。`input` 是既有的 `UserMessageData { content, source }`,而 `SendOptions` 只要求路由策略 `target` 与 `wakeup`。agent 会在发布或入队前为 `input` 创建快照并将其冻结,因此调用方或观察方后续的修改无法改变已接受的消息。它返回被接受消息的不透明 `AgentMessageId`,由该消息的 `agent/inbox/enqueue`/`dequeue`/`discard` 事件携带,调用方可据此把排队项与其生命周期关联;入队事件还会携带解析出的 `queued | steering` 路由归类,使监听器无需从后续状态重建接收时的路由。`target: 'next-turn'` 排队一条独立 FIFO 项,获准后成为其轮次中唯一的普通提示词。`target: 'next-step'` 且 `wakeup: true` 提交 steering(中途引导),而 `target: 'next-step'` 且 `wakeup: false` 注入持久上下文,不运行模型。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。 - `agent.followup(input)`:`send()` 的 `next-turn`/wakeup 预设:排队一个普通后续轮次并唤醒驱动器。 - `agent.steer(input)`:`next-step`/wakeup 预设:提示词接纳期间或轮次打开时,为下一个安全边界暂存 steering,且不分发 `agent/prompt-submit`;该接收窗口之外则委托给会唤醒的后续轮次。接纳失败会保留暂存的 steering,以供重试或之后获准的提示词使用,而取消或 dispose 可能丢弃它。 - `agent.inject(input)`:`next-step`/不唤醒预设:追加面向模型的上下文而不运行模型;下一次请求会看到一条逐字的 user role 消息,其来源由必填的 `input.source` 携带。提示词接纳期间或轮次打开时,注入会在 outbox 中等待下一个安全边界。该接收窗口之外,它会立即追加而不开启轮次;如果接纳结束却未开启轮次,仅含上下文的接纳批次会采用这一回退,而与 steering 一同暂存的上下文则会随其继续待处理。持久化独立地响应 `session/event`。注入不发出 `agent/inbox/*` 事件。 diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 7e0c7740da..96966ac1f6 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -77,8 +77,8 @@ export function AgentMessageId(id: string): AgentMessageId { /** * One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live * events. `id` is the value `send` returned to the caller, stable across this - * message's enqueue, dequeue, and discard events. Its content and source are - * the exact input values accepted by the agent. + * message's enqueue, dequeue, and discard events. The agent snapshots and + * freezes the accepted content and source before enqueue observers receive it. */ export interface AgentMessage extends UserMessageData { /** The id `send` returned for this message. */ @@ -172,6 +172,7 @@ export interface Agent { * immediately without opening a turn. If admission closes without a turn, * a context-only boundary appends immediately; context staged beside * steering remains pending with it. + * The agent snapshots and freezes `input` before publishing or queueing it. * @param input - model-facing content and its producer provenance. * @param options - target queue and wakeup decision. * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.