From a425aff586a5721a4ecd39dbc1e5be183794b092 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 17 Jul 2026 16:52:35 +0800 Subject: [PATCH 01/13] docs(rfc): propose one-send-one-turn simplification --- docs/rfc/INDEX.md | 1 + .../2026-07-17-one-send-one-turn.i18n.yaml | 6 ++++ .../2026-07-17-one-send-one-turn.md | 36 +++++++++++++++++++ .../2026-07-17-one-send-one-turn.zh.md | 36 +++++++++++++++++++ 4 files changed, 79 insertions(+) create mode 100644 docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml create mode 100644 docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md create mode 100644 docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index a60579ef4c..c0992414af 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -22,6 +22,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | | [Prune dead public and result surface](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | | [Simplify session-log representation](proposed/simplification/2026-07-12-simplify-session-log-representation.md) | 2026-07-12 | +| [Give each ordinary send its own turn](proposed/simplification/2026-07-17-one-send-one-turn.md) | 2026-07-17 | ### Architecture diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml new file mode 100644 index 0000000000..d485306ae6 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-17-one-send-one-turn.md: 2542b1038be07b5590ff80a02080b8872558f71c +2026-07-17-one-send-one-turn.zh.md: eefe6f2cc43c544dba4a67f95b36a27a852d7439 diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md new file mode 100644 index 0000000000..2542b1038b --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md @@ -0,0 +1,36 @@ +# RFC: Give each ordinary send its own turn + +Status: proposed + +English | [中文](2026-07-17-one-send-one-turn.zh.md) + +## Problem + +`Agent.send()` snapshots one ordinary message and appends it to a FIFO, but the agent loop drains every waiting ordinary message into one turn. Whether adjacent sends share a turn depends on when the driver happens to dequeue: calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks can observe different grouping even though callers used the same API. + +A shared turn also shares prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. A later message can therefore join an earlier message's model request instead of observing the earlier turn's committed result. The batching branches for mixed allowed and blocked prompts add lifecycle states that no caller explicitly requests. + +`steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit batching makes `send()` overlap both explicit operations instead of preserving a single meaning. + +## Proposal + +The inbox will dequeue at most one ordinary message for each turn start. A successful `send()` will remain synchronous: it validates agent state, snapshots and freezes content, appends one FIFO item, and publishes `agent/queued`. If two items are both claimed, the second turn will start only after the first turn ends and its durability checkpoint completes; an item discarded before turn start will not create an empty turn. + +Prompt admission will decide one message. An allowed prompt will become that turn's `user/message`; a blocked prompt will end that turn as `rejected`. The mixed-batch and all-blocked-batch branches will disappear. + +Running `steer()` will continue to append to the active turn's steering FIFO. Idle `steer()` will continue to delegate to `send()` and therefore create an independent ordinary turn. `inject()` will retain its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` will remain whole-agent operations rather than per-message controls. + +## Alternatives considered + +**Keep opportunistic batching for throughput.** Combining queued prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and prevents a later message from reliably observing the preceding turn's durable result. Explicit lifecycle semantics are worth the additional model calls; a future measured batching feature would need an explicit caller-visible contract. + +## Acceptance criteria + +- Two adjacent successful sends remain distinct FIFO items and, when both are claimed, produce two turns separated by the first turn's durability checkpoint. +- Dequeue timing and reentrant sends from queued listeners, session listeners, and model callbacks do not change the one-message turn boundary. +- Prompt veto, cancellation, disposal, and turn-start failure cannot merge messages or leave the agent permanently running. +- Running and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their documented meanings. + +## Risks + +Workloads that intentionally relied on coincidental batching will make more model requests and may take longer to drain. FIFO queues may also grow under sustained producers. The proposal accepts those costs because the public `send()` boundary becomes deterministic; throughput optimization can return only with an explicit measured contract. diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md new file mode 100644 index 0000000000..eefe6f2cc4 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md @@ -0,0 +1,36 @@ +# RFC: 让每次普通 send 独占一个轮次 + +Status: proposed + +[English](2026-07-17-one-send-one-turn.md) | 中文 + +## 问题 + +`Agent.send()` 会为一条普通消息创建快照,并将其追加到 FIFO,但 agent loop(智能体循环)会把所有等待中的普通消息一起取出并放入同一个轮次。相邻 send 是否共享轮次取决于 driver 何时恰好出队:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件 listener 和模型 callback 的调用也可能产生不同分组。 + +共享轮次也会共享 prompt admission、`turn/start`、`turn/end` 和持久性检查点。因此,后一条消息可能加入前一条消息的模型请求,而不能观察前一轮次已经提交的结果。allowed 与 blocked prompt 混合批次的分支引入了调用方从未显式请求的生命周期状态。 + +`steer()` 已经用于表达加入当前 active turn,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 + +## 提案 + +Inbox 在每次轮次开始时最多取出一条普通消息。成功的 `send()` 仍为同步调用:它会校验 agent 状态、创建并冻结内容快照、追加一个 FIFO item,然后发布 `agent/queued`。如果两个 item 都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点完成后开始;在轮次开始前被丢弃的 item 不会创建空轮次。 + +Prompt admission 将只处理一条消息。allowed prompt 会成为该轮次的 `user/message`;blocked prompt 会让该轮次以 `rejected` 结束。mixed-batch 和 all-blocked-batch 分支将被删除。 + +运行中的 `steer()` 仍会追加到 active turn 的 steering FIFO。空闲时的 `steer()` 仍会委托给 `send()`,因此会创建一个独立的普通轮次。`inject()` 保持现有的轮次封闭与 flush 行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 + +## 曾考虑的替代方案 + +**为吞吐量保留机会式批处理。** 当 producer 速度快于 driver 时,合并排队的 prompt 可以减少模型调用,但会让轮次边界取决于调度,并使后一条消息无法可靠观察前一轮次的持久化结果。额外模型调用的代价低于显式生命周期语义的价值;未来若根据测量结果重新引入批处理,必须提供调用方可见的显式契约。 + +## 验收标准 + +- 相邻两次成功 send 始终是两个独立 FIFO item;如果两者都被认领,则形成两个轮次,并由第一个轮次的持久性检查点隔开。 +- 出队时机,以及 queued listener、session listener 和模型 callback 中的重入 send,都不能改变一条消息对应一个轮次的边界。 +- Prompt veto、取消、dispose 和 turn-start failure 不能合并消息,也不能让 agent 永久停留在 running 状态。 +- 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的 status 和 `whenIdle()` 保持文档中的含义。 + +## 风险 + +依赖偶然批处理的工作负载会产生更多模型请求,队列清空时间也可能延长。持续 producer 还可能让 FIFO 队列增长。本提案接受这些成本,因为公共 `send()` 边界会变得确定;只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。 From 97c5ca940d449d5a4b164984dc2ffcc6878ef841 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 17 Jul 2026 16:58:56 +0800 Subject: [PATCH 02/13] review fix: preserve blocked-prompt RFC contract --- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 ++-- .../simplification/2026-07-17-one-send-one-turn.md | 4 ++-- .../2026-07-17-one-send-one-turn.zh.md | 14 +++++++------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml index d485306ae6..36bd0699c4 100644 --- a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.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 -2026-07-17-one-send-one-turn.md: 2542b1038be07b5590ff80a02080b8872558f71c -2026-07-17-one-send-one-turn.zh.md: eefe6f2cc43c544dba4a67f95b36a27a852d7439 +2026-07-17-one-send-one-turn.md: 21ab23a9ab0e7ea11fcaf44252b6e5d8e49c224b +2026-07-17-one-send-one-turn.zh.md: 07e268fcfa8651977c204baf46b499c62c27626a diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md index 2542b1038b..21ab23a9ab 100644 --- a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md +++ b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md @@ -16,7 +16,7 @@ A shared turn also shares prompt admission, `turn/start`, `turn/end`, and the du The inbox will dequeue at most one ordinary message for each turn start. A successful `send()` will remain synchronous: it validates agent state, snapshots and freezes content, appends one FIFO item, and publishes `agent/queued`. If two items are both claimed, the second turn will start only after the first turn ends and its durability checkpoint completes; an item discarded before turn start will not create an empty turn. -Prompt admission will decide one message. An allowed prompt will become that turn's `user/message`; a blocked prompt will end that turn as `rejected`. The mixed-batch and all-blocked-batch branches will disappear. +Prompt admission will decide one message. An allowed prompt will become that turn's `user/message`; a blocked prompt will append one durable `prompt/blocked` and end that one-message turn as `rejected`. The mixed-batch and all-blocked-batch branches will disappear. Running `steer()` will continue to append to the active turn's steering FIFO. Idle `steer()` will continue to delegate to `send()` and therefore create an independent ordinary turn. `inject()` will retain its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` will remain whole-agent operations rather than per-message controls. @@ -28,7 +28,7 @@ Running `steer()` will continue to append to the active turn's steering FIFO. Id - Two adjacent successful sends remain distinct FIFO items and, when both are claimed, produce two turns separated by the first turn's durability checkpoint. - Dequeue timing and reentrant sends from queued listeners, session listeners, and model callbacks do not change the one-message turn boundary. -- Prompt veto, cancellation, disposal, and turn-start failure cannot merge messages or leave the agent permanently running. +- Prompt veto appends one durable `prompt/blocked` for its `rejected` turn; cancellation, disposal, and `turn/start` failure cannot merge messages or leave the agent permanently `running`. - Running and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their documented meanings. ## Risks diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md index eefe6f2cc4..07e268fcfa 100644 --- a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md @@ -8,27 +8,27 @@ Status: proposed `Agent.send()` 会为一条普通消息创建快照,并将其追加到 FIFO,但 agent loop(智能体循环)会把所有等待中的普通消息一起取出并放入同一个轮次。相邻 send 是否共享轮次取决于 driver 何时恰好出队:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件 listener 和模型 callback 的调用也可能产生不同分组。 -共享轮次也会共享 prompt admission、`turn/start`、`turn/end` 和持久性检查点。因此,后一条消息可能加入前一条消息的模型请求,而不能观察前一轮次已经提交的结果。allowed 与 blocked prompt 混合批次的分支引入了调用方从未显式请求的生命周期状态。 +共享轮次也会共享提示词准入、`turn/start`、`turn/end` 和持久性检查点。因此,后一条消息可能加入前一条消息的模型请求,而不能观察前一轮次已经提交的结果。获准与被阻止提示词混合批次的分支引入了调用方从未显式请求的生命周期状态。 -`steer()` 已经用于表达加入当前 active turn,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 +`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 ## 提案 Inbox 在每次轮次开始时最多取出一条普通消息。成功的 `send()` 仍为同步调用:它会校验 agent 状态、创建并冻结内容快照、追加一个 FIFO item,然后发布 `agent/queued`。如果两个 item 都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点完成后开始;在轮次开始前被丢弃的 item 不会创建空轮次。 -Prompt admission 将只处理一条消息。allowed prompt 会成为该轮次的 `user/message`;blocked prompt 会让该轮次以 `rejected` 结束。mixed-batch 和 all-blocked-batch 分支将被删除。 +提示词准入将只处理一条消息。获准提示词会成为该轮次的 `user/message`;被阻止提示词会追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。mixed-batch 和 all-blocked-batch 分支将被删除。 -运行中的 `steer()` 仍会追加到 active turn 的 steering FIFO。空闲时的 `steer()` 仍会委托给 `send()`,因此会创建一个独立的普通轮次。`inject()` 保持现有的轮次封闭与 flush 行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 +运行中的 `steer()` 仍会追加到当前轮次的 steering FIFO。空闲时的 `steer()` 仍会委托给 `send()`,因此会创建一个独立的普通轮次。`inject()` 保持现有的轮次封闭与 flush 行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 ## 曾考虑的替代方案 -**为吞吐量保留机会式批处理。** 当 producer 速度快于 driver 时,合并排队的 prompt 可以减少模型调用,但会让轮次边界取决于调度,并使后一条消息无法可靠观察前一轮次的持久化结果。额外模型调用的代价低于显式生命周期语义的价值;未来若根据测量结果重新引入批处理,必须提供调用方可见的显式契约。 +**为吞吐量保留机会式批处理。** 当 producer 速度快于 driver 时,合并排队的提示词可以减少模型调用,但会让轮次边界取决于调度,并使后一条消息无法可靠观察前一轮次的持久化结果。额外模型调用的代价低于显式生命周期语义的价值;未来若根据测量结果重新引入批处理,必须提供调用方可见的显式契约。 ## 验收标准 - 相邻两次成功 send 始终是两个独立 FIFO item;如果两者都被认领,则形成两个轮次,并由第一个轮次的持久性检查点隔开。 -- 出队时机,以及 queued listener、session listener 和模型 callback 中的重入 send,都不能改变一条消息对应一个轮次的边界。 -- Prompt veto、取消、dispose 和 turn-start failure 不能合并消息,也不能让 agent 永久停留在 running 状态。 +- 出队时机,以及 queued listener、会话 listener 和模型 callback 中的重入 send,都不能改变一条消息对应一个轮次的边界。 +- 提示词否决会为对应的 `rejected` 轮次追加一条持久的 `prompt/blocked`;取消、dispose(资源释放)和 `turn/start` 失败不能合并消息,也不能让 agent 永久停留在 `running` 状态。 - 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的 status 和 `whenIdle()` 保持文档中的含义。 ## 风险 From aa4e62987473c878eaf1a0ee117ebcd8b6327337 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 17 Jul 2026 17:14:52 +0800 Subject: [PATCH 03/13] feat(agent-loop): give each send its own turn --- docs/architecture.md | 10 +- docs/core-data-structures/core.md | 11 +- docs/core-data-structures/session.md | 12 +- docs/defensive-patterns.md | 2 +- docs/persistence-catalog.md | 4 +- docs/rfc/INDEX.md | 2 +- .../feature/2026-06-30-interception-seams.md | 6 +- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 +- .../2026-07-17-one-send-one-turn.md | 38 +++++ .../2026-07-17-one-send-one-turn.zh.md | 38 +++++ .../2026-07-17-one-send-one-turn.md | 36 ----- .../2026-07-17-one-send-one-turn.zh.md | 36 ----- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/inbox.ts | 12 +- packages/core/agent-loop/src/loop.ts | 60 +++----- packages/core/agent-loop/tests/cancel.spec.ts | 9 +- .../tests/contract-regressions.spec.ts | 40 ++++-- .../agent-loop/tests/coverage-edges.spec.ts | 16 ++- packages/core/agent-loop/tests/inbox.spec.ts | 10 +- .../agent-loop/tests/interception.spec.ts | 38 ++--- packages/core/agent-loop/tests/loop.spec.ts | 130 +++++++++++++++++- .../core/agent-loop/tests/properties.spec.ts | 41 ++++-- packages/core/agent/README.md | 2 +- packages/core/agent/src/types.ts | 8 +- packages/core/session/src/types.ts | 10 +- packages/ui/acp/src/index.ts | 4 +- 26 files changed, 372 insertions(+), 209 deletions(-) rename docs/rfc/{proposed => implemented}/simplification/2026-07-17-one-send-one-turn.i18n.yaml (65%) create mode 100644 docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md create mode 100644 docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md delete mode 100644 docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md delete mode 100644 docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md diff --git a/docs/architecture.md b/docs/architecture.md index 848980a45a..b0449be8ce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,7 +55,7 @@ Waterfall events behave like around-middleware: a listener delegates by calling The shipped loop drains work, assembles requests, streams model answers, executes tools, applies continuation policy, and checkpoints state. Every pause is a service call or event available to plugins. -A **session** is one agent's append-only event log. A **turn** drains one queued batch and runs until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. +A **session** is one agent's append-only event log. An ordinary **turn** claims one queued message; an injection turn claims none. A turn ends when the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus its tool executions. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. ### Turn Flow @@ -64,13 +64,13 @@ prepare private session + agent.ctx -> await unpublished setup -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: - wait for queued messages + wait for a queued message emit agent/status(running) TURN: 'turn/start' - each queued message -> agent/prompt-submit + claimed message -> agent/prompt-submit allowed prompt -> 'user/message' plus injected context - every prompt blocked -> 'turn/end'(rejected) + blocked prompt -> 'prompt/blocked' -> 'turn/end'(rejected) STEP loop: drain steering assemble system prompt and tool schemas @@ -95,7 +95,7 @@ forever: checkpoint persistence and notify idle/running status ``` -The loop renders one prompt assembly per step. Plugins contribute ordered sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn instead of shipping a hole. `dsh-system-prompt` owns the harness identity and default deployment persona; an agent-scoped persona may shadow the default. The loop supplies `model` and `cwd`. See the [prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). +Each successful `send()` adds one FIFO item. Queued items run as consecutive ordinary turns under one running interval, each after the prior turn's durability checkpoint. Each step has one prompt assembly. Plugins contribute ordered sections, tool schemas, and `{{name}}` variables; unresolved references fail the turn. `dsh-system-prompt` owns the harness identity and default deployment persona; an agent-scoped persona may shadow the default. The loop supplies `model` and `cwd`. See the [prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 340212d37a..8a78dbc79c 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -262,8 +262,9 @@ interface Agent { readonly ctx: Context /** - * Queue a user message. Starts a turn when idle; otherwise waits for the next - * turn. Content and the resolved source are accepted as one detached, + * Queue one user-message FIFO item. Unless cleared before turn start, the + * item becomes the sole ordinary message in its turn and waits for every + * preceding turn's durability checkpoint. Content and the resolved source are accepted as one detached, * deeply-frozen lossless-JSON record before notification or enqueue, so * caller or `agent/queued` listener in-place mutation cannot change later * log/model input. Throws synchronously when either value is not losslessly @@ -308,8 +309,8 @@ interface Agent { * - drops a turn that is about to start (a `cancel()` landing in the * pre-step window — after a `send()` queued but before the loop flips to * `running`, or after `running` is emitted but before the first step) so - * that queued prompt does not run and cannot be batched into the cancelled - * turn. + * that queued prompt does not run; later accepted items remain independent + * queued turns. * * After `cancel()`, `whenIdle()` resolves on the post-cancel quiescent state. * `cancel()` on an idle agent with nothing queued or running is a safe no-op @@ -364,7 +365,7 @@ interface HookContext { } ``` -`agent/prompt-submit` returns a `PromptDecision` (allow a drained queued message — optionally rewriting its `content` or attaching `additionalContext` — or block it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`): +`agent/prompt-submit` returns a `PromptDecision` (allow the turn's claimed queued message — optionally rewriting its `content` or attaching `additionalContext` — or record `prompt/blocked` and end that zero-step turn as `rejected`): ```ts type-equiv type PromptDecision = diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index d8292c49b3..18d511a0a0 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -19,9 +19,8 @@ interface SessionEventMap { /** * A queued prompt an `agent/prompt-submit` listener VETOED — the durable * record of a blocked prompt and why. Appended in place of the `user/message` - * the prompt would have become, so the block survives replay even in a MIXED - * batch where another queued prompt is allowed (there the turn does not end - * `rejected`, so the boundary reason alone would not preserve it). `content` + * the prompt would have become; that one-message turn runs zero steps and + * ends `rejected`. `content` * is the original prompt the listener rejected; `reason` is the veto text * ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a * blocked prompt produces no LLM message and never reaches `deriveMessages()`. @@ -268,9 +267,8 @@ interface TurnEndReasonMap { disposed: { kind: 'disposed' } 'max-tokens': { kind: 'max-tokens' } /** - * The turn's entire prompt batch was BLOCKED before any step ran — every - * drained queued message was vetoed by an `agent/prompt-submit` listener (a - * hook). The turn still opened (so the boundary stays balanced and the block + * The turn's claimed prompt was BLOCKED before any step ran by an + * `agent/prompt-submit` listener (a hook). The turn still opened (so the boundary stays balanced and the block * is a durable in-turn fact), but ran zero steps. `reason` carries the block * message from the vetoing decision. Distinct from `aborted` (a user-driven * cancel) and `error` (a failure): the prompt was rejected by policy, not @@ -291,7 +289,7 @@ interface TurnEndReasonMap { } ``` -`max-tokens` mirrors the model-call `FinishReason` of the same name: any `max-tokens` step in a turn makes the whole turn end `max-tokens` rather than `completed` (the cut-short fact wins over a later continuation), so a consumer can tell a clean stop from a truncated one — but only over `completed`: the `disposed`/`aborted`/`error` outcomes take precedence. `rejected` is a zero-step turn whose whole prompt batch an `agent/prompt-submit` hook blocked (the ACP bridge maps it to `cancelled`). `interrupted` is the one reason no loop emits — it is synthesized by crash recovery (see [persistence.md](persistence.md)). Both maps are merge-extensible. +`max-tokens` mirrors the model-call `FinishReason` of the same name: any `max-tokens` step in a turn makes the whole turn end `max-tokens` rather than `completed` (the cut-short fact wins over a later continuation), so a consumer can tell a clean stop from a truncated one — but only over `completed`: the `disposed`/`aborted`/`error` outcomes take precedence. `rejected` is a zero-step turn whose claimed prompt an `agent/prompt-submit` hook blocked (the ACP bridge maps it to `cancelled`). `interrupted` is the one reason no loop emits — it is synthesized by crash recovery (see [persistence.md](persistence.md)). Both maps are merge-extensible. ## The turn-enclosure invariant diff --git a/docs/defensive-patterns.md b/docs/defensive-patterns.md index cf30072094..fe74a9d19f 100644 --- a/docs/defensive-patterns.md +++ b/docs/defensive-patterns.md @@ -12,7 +12,7 @@ When an interface documents two valid ways to signal something — an adapter ma ## Async state is not synchronous state -`agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) rather than counting actions you assume map 1:1 to turns (the loop batches queued messages). The guard cuts both ways: if the awaited transition can never occur (EOF with no work submitted → never `running`), the wait hangs — handle the "nothing to wait for" branch explicitly. +`agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-send result: several queued sends run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. The guard cuts both ways: if the awaited transition can never occur (EOF with no work submitted → never `running`), the wait hangs — handle the "nothing to wait for" branch explicitly. ## Dispose must reach quiescence, not just request it diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index a27b19b9a7..64a0625f36 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -169,7 +169,7 @@ Source: [`packages/ui/permission/src/index.ts:33`](../packages/ui/permission/src #### `prompt/blocked` — log-only -Durable record of a prompt veto and its reason. It is log-only: the blocked prompt never enters the model-visible surface, including in a mixed batch. +Durable record of a prompt veto and its reason. It is log-only: the blocked prompt never enters the model-visible surface, and its turn runs zero steps. ```ts persistence-catalog 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } @@ -305,7 +305,7 @@ Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/ #### `turn/start` — log-only -Opens turn `turn`. `trigger` records what started it — a drained message batch or an idle-time injection. The turn is the durability/replay boundary: every event sits between a `turn/start` and its matching `turn/end` (the turn-enclosure invariant). +Opens turn `turn`. `trigger` records what started it — one claimed queued message or an idle-time injection. The turn is the durability/replay boundary: every event sits between a `turn/start` and its matching `turn/end` (the turn-enclosure invariant). ```ts persistence-catalog 'turn/start': { turn: number; trigger: TurnTrigger } diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index c0992414af..b382cbc945 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -22,7 +22,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | | [Prune dead public and result surface](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | | [Simplify session-log representation](proposed/simplification/2026-07-12-simplify-session-log-representation.md) | 2026-07-12 | -| [Give each ordinary send its own turn](proposed/simplification/2026-07-17-one-send-one-turn.md) | 2026-07-17 | ### Architecture @@ -108,6 +107,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 | | [Drop unconsumed skill provider events](implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md) | 2026-07-12 | | [Prune unused web seam fields](implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md) | 2026-07-12 | +| [Give each ordinary send its own turn](implemented/simplification/2026-07-17-one-send-one-turn.md) | 2026-07-17 | ### Architecture diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index ea371ad55a..669e577625 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -14,7 +14,7 @@ The canonical surface separates transformable policy, around-dispatch control, a **Agent events** (`dsh-agent`): - `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`. -- `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (dropping the prompt; the loop appends a durable `prompt/blocked` in its place — see the dispatch note below). +- `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired for the turn's single claimed queued message before the `user/message` append. `allow` optionally rewrites the prompt `content` or attaches `additionalContext`; `block` appends a durable `prompt/blocked` and rejects that zero-step turn. **`agent/turn-continuation`** receives and returns a `ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing context recorded as next-step steering in the same turn — the typed twin of the `/goal` step-end-steer pattern. @@ -30,11 +30,11 @@ Every call follows `tools/pre-execute` → guards → `tools/execute` → dispat Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, malformed-result, non-JSON result, and identity-shape failures resolve as JSON-safe `isError` results rather than escaping the turn. A post-execute listener can therefore inspect a thrown tool, and a final observer sees exactly what the caller receives and the session log can persist. -**`TurnEndReason.rejected`** (`dsh-session`): a turn whose entire prompt batch was blocked by `prompt-submit`. +**`TurnEndReason.rejected`** (`dsh-session`): a zero-step turn whose claimed prompt was blocked by `prompt-submit`. ### Three load-bearing loop decisions -1. **Open the turn before prompt policy.** A fully blocked batch becomes a zero-step `rejected` turn, preserving enclosure and giving ACP a durable terminal event. Every veto also records `prompt/blocked` with the original prompt and reason, so mixed batches retain blocked inputs. Allowed `additionalContext` is injected into the open turn. +1. **Open the turn before prompt policy.** A blocked prompt becomes a zero-step `rejected` turn, preserving enclosure and giving ACP a durable terminal event. The veto records `prompt/blocked` with the original prompt and reason, while allowed `additionalContext` is injected into the open turn. Each ordinary send owns an independent turn under the [one-send-one-turn simplification](../simplification/2026-07-17-one-send-one-turn.md). 2. **Post-tool `additionalContext` is buffered and appended AFTER all `tool/result`s.** `content`/`feedback` shape the result `execute()` returns, but `additionalContext` is a SEPARATE `context/message`, and a single step can carry multiple tool calls. Appending context right after each result would interleave `result(c1) → context → result(c2)` and break tool-call/result adjacency. So `execute()` surfaces `additionalContext` on its `ToolExecutionResult`, and the loop buffers every per-call context for the step and appends them as `context/message`(s) only after every `tool/result` is appended. diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml similarity index 65% rename from docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml rename to docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index 36bd0699c4..bcb9485da5 100644 --- a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.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 -2026-07-17-one-send-one-turn.md: 21ab23a9ab0e7ea11fcaf44252b6e5d8e49c224b -2026-07-17-one-send-one-turn.zh.md: 07e268fcfa8651977c204baf46b499c62c27626a +2026-07-17-one-send-one-turn.md: 34331a04b53f9ccf67db0baf201dec23e1c2a60f +2026-07-17-one-send-one-turn.zh.md: e6c4b97e826393ebca81d715ae3760c6068f84d0 diff --git a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md new file mode 100644 index 0000000000..34331a04b5 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -0,0 +1,38 @@ +# RFC: Give each ordinary send its own turn + +Status: implemented + +English | [中文](2026-07-17-one-send-one-turn.zh.md) + +## Problem + +An ordinary `Agent.send()` payload is one complete caller message. Opportunistically draining every waiting payload into one turn would make adjacent calls share a boundary according to driver timing: calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks could be grouped differently even though callers used the same API. + +A turn owns prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later message join an earlier message's model request instead of observing the earlier turn's committed result, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. + +`steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit batching would make `send()` overlap both explicit operations instead of preserving a single meaning. + +## Decision + +Each successful `send()` synchronously validates agent state, snapshots and freezes content, appends one independent FIFO item, and publishes `agent/queued`. The loop dequeues at most one ordinary item for each turn start. If two items are both claimed, the second turn starts only after the first turn ends and its durability checkpoint settles; broad cancellation, disposal, or a pre-start failure can discard an unstarted item without creating an empty turn. + +Prompt admission decides one message. An allowed prompt becomes that turn's `user/message`; a blocked prompt appends one durable `prompt/blocked` and ends that one-message turn as `rejected`. There are no mixed-batch or all-blocked-batch branches. + +Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` delegates to `send()` and therefore creates an independent ordinary turn. `inject()` retains its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` remain whole-agent operations rather than per-message controls. + +## Alternatives considered + +**Keep opportunistic batching for throughput.** Combining queued prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and prevents a later message from reliably observing the preceding turn's durable result. Explicit lifecycle semantics are worth the additional model calls; any future batching feature needs an explicit caller-visible contract justified by measurements. + +## Verification + +- Unit and property coverage pins same-stack, neighboring-microtask, differently sourced, and reentrant sends as one FIFO-ordered message per turn. +- A deferred first-turn flush proves the next queued turn cannot start before the checkpoint settles and that its request sees the preceding assistant result; a rejected flush still settles before the next turn starts. +- Prompt veto and listener failure, broad cancellation, disposal, and pre-commit `turn/start` failure preserve balanced recorded turns and do not merge or strand surviving queued work. +- Running and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their existing coverage. + +## Consequences + +Ordinary turn boundaries are deterministic, and a claimed FIFO successor observes the preceding turn's committed session result. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. + +Workloads that relied on coincidental batching make more model requests, incur more checkpoints, and may take longer to drain; FIFO queues may grow under sustained producers. Throughput optimization can return only through an explicit measured contract. diff --git a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md new file mode 100644 index 0000000000..e6c4b97e82 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -0,0 +1,38 @@ +# RFC: 让每次普通 send 独占一个轮次 + +Status: implemented + +[English](2026-07-17-one-send-one-turn.md) | 中文 + +## 问题 + +每个普通 `Agent.send()` payload 都是一条完整的调用方消息。如果机会式地把所有等待 payload 放入同一个轮次,相邻调用是否共享边界就会取决于 driver 时机:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件 listener 和模型 callback 的调用也可能产生不同分组。 + +轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条消息加入前一条消息的模型请求,而不能观察前一轮次已经提交的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 + +`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 + +## 决策 + +每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立 FIFO item,然后发布 `agent/queued`。agent loop 在每次轮次开始时最多取出一个普通 item。如果两个 item 都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点完成后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的 item,而不创建空轮次。 + +提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有 mixed-batch 或 all-blocked-batch 分支。 + +运行中的 `steer()` 会追加到当前轮次的 steering FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通轮次。`inject()` 保持现有的轮次封闭与 flush 行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 + +## 曾考虑的替代方案 + +**为吞吐量保留机会式批处理。** 当 producer 速度快于 driver 时,合并排队的提示词可以减少模型调用,但会让轮次边界取决于调度,并使后一条消息无法可靠观察前一轮次的持久化结果。额外模型调用的代价低于显式生命周期语义的价值;未来的任何批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 + +## 验证 + +- 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 send 的行为:每个轮次只有一条消息,并按 FIFO 排序。 +- 延迟第一个轮次的 flush 可以证明下一个排队轮次不能在检查点完成前开始,且其请求能看到前一个 assistant result;被拒绝的 flush 也会在下一个轮次开始前完成。 +- 提示词否决与 listener failure、广义取消、dispose 和提交前 `turn/start` failure 都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 +- 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的 status 和 `whenIdle()` 保持原有覆盖。 + +## 后果 + +普通轮次边界是确定的,被认领的 FIFO 后继项可以观察前一轮次已经提交的会话结果。多个排队 item 仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此 status 和静止状态仍是面向整个 agent 的观察,而不是逐消息结果。 + +依赖偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续 producer 还可能让 FIFO 队列增长。只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。 diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md deleted file mode 100644 index 21ab23a9ab..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md +++ /dev/null @@ -1,36 +0,0 @@ -# RFC: Give each ordinary send its own turn - -Status: proposed - -English | [中文](2026-07-17-one-send-one-turn.zh.md) - -## Problem - -`Agent.send()` snapshots one ordinary message and appends it to a FIFO, but the agent loop drains every waiting ordinary message into one turn. Whether adjacent sends share a turn depends on when the driver happens to dequeue: calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks can observe different grouping even though callers used the same API. - -A shared turn also shares prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. A later message can therefore join an earlier message's model request instead of observing the earlier turn's committed result. The batching branches for mixed allowed and blocked prompts add lifecycle states that no caller explicitly requests. - -`steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit batching makes `send()` overlap both explicit operations instead of preserving a single meaning. - -## Proposal - -The inbox will dequeue at most one ordinary message for each turn start. A successful `send()` will remain synchronous: it validates agent state, snapshots and freezes content, appends one FIFO item, and publishes `agent/queued`. If two items are both claimed, the second turn will start only after the first turn ends and its durability checkpoint completes; an item discarded before turn start will not create an empty turn. - -Prompt admission will decide one message. An allowed prompt will become that turn's `user/message`; a blocked prompt will append one durable `prompt/blocked` and end that one-message turn as `rejected`. The mixed-batch and all-blocked-batch branches will disappear. - -Running `steer()` will continue to append to the active turn's steering FIFO. Idle `steer()` will continue to delegate to `send()` and therefore create an independent ordinary turn. `inject()` will retain its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` will remain whole-agent operations rather than per-message controls. - -## Alternatives considered - -**Keep opportunistic batching for throughput.** Combining queued prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and prevents a later message from reliably observing the preceding turn's durable result. Explicit lifecycle semantics are worth the additional model calls; a future measured batching feature would need an explicit caller-visible contract. - -## Acceptance criteria - -- Two adjacent successful sends remain distinct FIFO items and, when both are claimed, produce two turns separated by the first turn's durability checkpoint. -- Dequeue timing and reentrant sends from queued listeners, session listeners, and model callbacks do not change the one-message turn boundary. -- Prompt veto appends one durable `prompt/blocked` for its `rejected` turn; cancellation, disposal, and `turn/start` failure cannot merge messages or leave the agent permanently `running`. -- Running and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their documented meanings. - -## Risks - -Workloads that intentionally relied on coincidental batching will make more model requests and may take longer to drain. FIFO queues may also grow under sustained producers. The proposal accepts those costs because the public `send()` boundary becomes deterministic; throughput optimization can return only with an explicit measured contract. diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md deleted file mode 100644 index 07e268fcfa..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md +++ /dev/null @@ -1,36 +0,0 @@ -# RFC: 让每次普通 send 独占一个轮次 - -Status: proposed - -[English](2026-07-17-one-send-one-turn.md) | 中文 - -## 问题 - -`Agent.send()` 会为一条普通消息创建快照,并将其追加到 FIFO,但 agent loop(智能体循环)会把所有等待中的普通消息一起取出并放入同一个轮次。相邻 send 是否共享轮次取决于 driver 何时恰好出队:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件 listener 和模型 callback 的调用也可能产生不同分组。 - -共享轮次也会共享提示词准入、`turn/start`、`turn/end` 和持久性检查点。因此,后一条消息可能加入前一条消息的模型请求,而不能观察前一轮次已经提交的结果。获准与被阻止提示词混合批次的分支引入了调用方从未显式请求的生命周期状态。 - -`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 - -## 提案 - -Inbox 在每次轮次开始时最多取出一条普通消息。成功的 `send()` 仍为同步调用:它会校验 agent 状态、创建并冻结内容快照、追加一个 FIFO item,然后发布 `agent/queued`。如果两个 item 都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点完成后开始;在轮次开始前被丢弃的 item 不会创建空轮次。 - -提示词准入将只处理一条消息。获准提示词会成为该轮次的 `user/message`;被阻止提示词会追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。mixed-batch 和 all-blocked-batch 分支将被删除。 - -运行中的 `steer()` 仍会追加到当前轮次的 steering FIFO。空闲时的 `steer()` 仍会委托给 `send()`,因此会创建一个独立的普通轮次。`inject()` 保持现有的轮次封闭与 flush 行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 - -## 曾考虑的替代方案 - -**为吞吐量保留机会式批处理。** 当 producer 速度快于 driver 时,合并排队的提示词可以减少模型调用,但会让轮次边界取决于调度,并使后一条消息无法可靠观察前一轮次的持久化结果。额外模型调用的代价低于显式生命周期语义的价值;未来若根据测量结果重新引入批处理,必须提供调用方可见的显式契约。 - -## 验收标准 - -- 相邻两次成功 send 始终是两个独立 FIFO item;如果两者都被认领,则形成两个轮次,并由第一个轮次的持久性检查点隔开。 -- 出队时机,以及 queued listener、会话 listener 和模型 callback 中的重入 send,都不能改变一条消息对应一个轮次的边界。 -- 提示词否决会为对应的 `rejected` 轮次追加一条持久的 `prompt/blocked`;取消、dispose(资源释放)和 `turn/start` 失败不能合并消息,也不能让 agent 永久停留在 `running` 状态。 -- 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的 status 和 `whenIdle()` 保持文档中的含义。 - -## 风险 - -依赖偶然批处理的工作负载会产生更多模型请求,队列清空时间也可能延长。持续 producer 还可能让 FIFO 队列增长。本提案接受这些成本,因为公共 `send()` 边界会变得确定;只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index a07aa5fa70..88efc953a7 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -44,7 +44,7 @@ Configured agents start automatically. `cwd` applies only to fresh sessions; `re - `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy. -`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()` and running `steer()` materialize content plus resolved source once as detached, deeply frozen lossless JSON, then share that accepted record between `agent/queued` and the inbox; malformed data throws before either boundary. +`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. Each `ReactLoopAgent.send()` materializes content plus resolved source once as a detached, deeply frozen lossless-JSON FIFO item, shares that accepted record between `agent/queued` and the inbox, and gives the item its own ordinary turn after preceding checkpoints; malformed data throws before either boundary. Running `steer()` uses the same acceptance boundary but joins the active turn. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index abb588b919..b26a79a1ef 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -15,7 +15,7 @@ export interface InboxMessage { } /** - * Per-agent inbox: a queued FIFO (drained at turn start) and a steering FIFO + * Per-agent inbox: a queued FIFO (dequeued once per turn start) and a steering FIFO * (drained between steps of a running turn). Purely an in-memory mechanism of * the loop — the public surface is `Agent.send()` / `Agent.steer()`. */ @@ -54,11 +54,11 @@ export class Inbox { } /** - * Drain all queued messages (turn start). - * @returns the drained messages in arrival order; the queued FIFO is left empty. + * Remove the oldest queued message for one turn start. + * @returns the oldest message, or `undefined` when the queued FIFO is empty. */ - drainQueued(): InboxMessage[] { - return this.queuedMessages.splice(0) + dequeueQueued(): InboxMessage | undefined { + return this.queuedMessages.shift() } /** @@ -72,7 +72,7 @@ export class Inbox { /** * Discard all pending messages (queued + steering) without delivering them — * used by `cancel()`, which drops un-started work rather than draining it into - * a turn. Unlike `drainQueued`/`drainSteering`, the messages are thrown away. + * a turn. Unlike `dequeueQueued`/`drainSteering`, the messages are thrown away. */ clear(): void { this.queuedMessages.length = 0 diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 15cc0aa410..ebe528c5e0 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -88,7 +88,7 @@ export interface LoopHandle { } /** - * Drive queued batches as durable turns until disposal. Plugin failures end the + * Drive queued messages as independent durable turns until disposal. Plugin failures end the * current turn without terminating the driver. * @param ctx - the plugin context the loop reaches events (agent/…, session/flush) and services (systemPrompt, llm, tools) through. * @param agent - the agent this invocation drives for its whole lifetime (its inbox, session, and options). @@ -159,12 +159,11 @@ async function runTurn( ): Promise { const { session } = agent - // Drain before opening the turn, but append only after `turn/start`. - const queued = handle.inbox.drainQueued() - const first = queued[0] + // Claim one queued message before opening its turn, but append it only after `turn/start`. + const message = handle.inbox.dequeueQueued() /* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */ - if (!first) throw new Error('runTurn invariant violated: no queued message at turn start') - const trigger: TurnTrigger = { kind: 'message', source: first.source } + if (!message) throw new Error('runTurn invariant violated: no queued message at turn start') + const trigger: TurnTrigger = { kind: 'message', source: message.source } let reason: TurnEndReason = { kind: 'completed' } let step = 0 @@ -202,51 +201,32 @@ async function runTurn( // matter what throws below; the catch + closeTurn guarantee it. A pre-commit // veto leaves no turn/start in the log and therefore owes no turn/end. session.append('turn/start', { turn, trigger }) - // Each drained queued message runs the `agent/prompt-submit` waterfall before - // it becomes a `user/message` — a hook can rewrite the prompt or block it. + // The claimed message runs the `agent/prompt-submit` waterfall before it + // becomes a `user/message` — a hook can rewrite the prompt or block it. // Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed; // turn/end is now owed, so a throwing prompt-submit listener (the waterfall // throws) is caught below and the turn still closes. - let anyAllowed = false - // Seeded with a floor (only observable if the batch were empty, which - // runTurn never allows — it is called with ≥1 queued message); each `block` - // decision carries a required `reason` and overwrites it, so a fully-blocked - // batch always reports the last vetoing reason. - let lastBlockReason = 'prompt blocked by hook' - for (const message of queued) { - const decision = await events.waterfall( - 'agent/prompt-submit', message.content, message.source, - () => Promise.resolve({ kind: 'allow' }), - ) - if (decision.kind === 'block') { - lastBlockReason = decision.reason - // Record the veto durably: `PromptDecision.reason` is the durable record - // of why a prompt was blocked, but a fully-blocked batch's `rejected` - // turn/end only preserves the LAST reason, and a MIXED batch (this prompt - // blocked, another allowed) does not end `rejected` at all — so without - // this append a blocked prompt would vanish from the log whenever any - // sibling prompt is allowed. `prompt/blocked` sits in the open turn in - // place of the `user/message` this prompt would have become. - session.append('prompt/blocked', { content: message.content, source: message.source, reason: decision.reason }) - continue - } - anyAllowed = true + const promptDecision = await events.waterfall( + 'agent/prompt-submit', message.content, message.source, + () => Promise.resolve({ kind: 'allow' }), + ) + if (promptDecision.kind === 'block') { + session.append('prompt/blocked', { content: message.content, source: message.source, reason: promptDecision.reason }) + reason = { kind: 'rejected', reason: promptDecision.reason } + } else { // `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them. - const content = decision.content ?? message.content + const content = promptDecision.content ?? message.content session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' }) // `allow.additionalContext` is a SEPARATE context/message the next request // also sees. The turn is open, so inject() appends it into THIS turn. - if (decision.additionalContext) { - agent.inject(decision.additionalContext.content, { source: decision.additionalContext.source }) + if (promptDecision.additionalContext) { + agent.inject(promptDecision.additionalContext.content, { source: promptDecision.additionalContext.source }) } } while (true) { - // A fully blocked batch closes its zero-step turn as rejected. - if (!anyAllowed) { - reason = { kind: 'rejected', reason: lastBlockReason } - break - } + // A blocked prompt closes its zero-step turn as rejected. + if (promptDecision.kind === 'block') break step += 1 // Steering from the previous round's continuation listeners joins before diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index cad830e827..5ed15f1b0b 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -75,7 +75,8 @@ describe('Agent.cancel()', () => { // send() queues synchronously (status still idle, loop microtask not yet // resumed). Cancel in that pre-step window: the queued turn must not run. - send(agent, 'drop me') + send(agent, 'drop me first') + send(agent, 'drop me second') agent.cancel('pre-step') // Give the loop a chance to wake and process the cancel. @@ -106,7 +107,7 @@ describe('Agent.cancel()', () => { expect(agent.status).toBe('idle') }) - it('cancel() mid-step aborts the in-flight model call; the turn ends aborted', async () => { + it('cancel() mid-step aborts the active turn and drops every queued tail item', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -117,10 +118,14 @@ describe('Agent.cancel()', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') + send(agent, 'queued tail') agent.cancel('mid-step') await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'aborted', reason: 'mid-step' }]) + expect(userTexts(agent)).toEqual(['go']) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(adapter.requests).toHaveLength(1) }) it('cancel() with no reason defaults to "cancelled" when aborting an in-flight step', async () => { diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index ee50261e9d..33e0b7de9c 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -276,15 +276,20 @@ describe('plugin exceptions are contained', () => { expect(agent.status).toBe('idle') }) - it('a rejecting session/flush listener is reported but does not kill the agent', async () => { + it('a rejecting first-turn flush settles before the queued tail starts', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - let rejectedOnce = false - ctx.on('session/flush', async () => { - if (!rejectedOnce) { - rejectedOnce = true + const firstFlush = Promise.withResolvers() + const releaseFirstFlush = Promise.withResolvers() + let flushes = 0 + ctx.on('session/flush', async (session) => { + if (session !== agent.session) return + flushes += 1 + if (flushes === 1) { + firstFlush.resolve(undefined) + await releaseFirstFlush.promise throw new Error('disk full') } }) @@ -292,18 +297,25 @@ describe('plugin exceptions are contained', () => { const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) + const idle = waitForIdle(ctx, agent) send(agent, 'first') - await waitForIdle(ctx, agent) - expect(errors.map(e => e.message)).toEqual(['disk full']) - send(agent, 'second') - await waitForIdle(ctx, agent) + + await firstFlush.promise + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + + releaseFirstFlush.resolve(undefined) + await idle + + expect(errors.map(e => e.message)).toEqual(['disk full']) expect(adapter.requests).toHaveLength(2) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) }) }) describe('disposed status is part of the agent/status contract', () => { - it('disposing the fiber emits agent/status(disposed) and ends the turn with reason disposed', async () => { + it('disposing the fiber ends the active turn and never starts its queued tail', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) @@ -319,11 +331,19 @@ describe('disposed status is part of the agent/status contract', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) + send(agent, 'queued tail') await fiber.dispose() await agent.done expect(statuses).toEqual(['running', 'disposed']) expect(reasons).toEqual([{ kind: 'disposed' }]) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + const messages = agent.session.events + .filter(event => event.type === 'user/message') + .flatMap(event => event.data.content) + .flatMap(block => block.type === 'text' ? [block.text] : []) + expect(messages).toEqual(['go']) + expect(adapter.requests).toHaveLength(1) }) it('a throwing agent/status listener cannot break disposal or leak the registry entry', async () => { diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 5deee8e159..c85c5ef949 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -141,12 +141,22 @@ describe('toError normalization', () => { const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) - send(agent, 'go') + send(agent, 'fails before turn start') + send(agent, 'survives as the next item') await waitForIdle(ctx, agent) expect(errors).toHaveLength(1) expect(errors[0]).toMatchObject({ message: 'naked string error', code: 'UNKNOWN' }) - expect(adapter.requests).toEqual([]) - expect(agent.session.events.some(event => event.type === 'turn/start' || event.type === 'turn/end')).toBe(false) + expect(adapter.requests).toHaveLength(1) + const starts = agent.session.events.filter(event => event.type === 'turn/start') + const ends = agent.session.events.filter(event => event.type === 'turn/end') + const messages = agent.session.events.filter(event => event.type === 'user/message') + expect(starts).toHaveLength(1) + expect(starts[0]?.type === 'turn/start' && starts[0].data.turn).toBe(1) + expect(ends).toHaveLength(1) + expect(messages).toHaveLength(1) + expect(messages[0]?.type === 'user/message' && messages[0].data.content).toEqual([ + { type: 'text', text: 'survives as the next item' }, + ]) }) it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => { diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 4bea62abe2..f4eea9fdd0 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -8,17 +8,17 @@ function resolverPair() { } describe('Inbox', () => { - it('enqueues and drains queued messages in FIFO order', () => { + it('dequeues one queued message at a time in FIFO order', () => { const inbox = new Inbox() inbox.enqueue({ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } }) inbox.enqueue({ content: [{ type: 'text', text: 'second' }], source: { kind: 'user' } }) expect(inbox.hasQueued).toBe(true) - const drained = inbox.drainQueued() - expect(drained).toHaveLength(2) - expect(drained[0]!.content[0]).toMatchObject({ text: 'first' }) - expect(drained[1]!.content[0]).toMatchObject({ text: 'second' }) + expect(inbox.dequeueQueued()?.content[0]).toMatchObject({ text: 'first' }) + expect(inbox.hasQueued).toBe(true) + expect(inbox.dequeueQueued()?.content[0]).toMatchObject({ text: 'second' }) expect(inbox.hasQueued).toBe(false) + expect(inbox.dequeueQueued()).toBeUndefined() }) it('pushes and drains steering messages separately from queued', () => { diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 4b37210993..ad0ea7d199 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -182,9 +182,7 @@ describe('agent/prompt-submit', () => { expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'rejected', reason: 'blocked by policy' }) }) - it('a mixed batch records a prompt/blocked for the vetoed prompt while the allowed one runs', async () => { - // Blocking one prompt in a mixed batch must persist its reason even though - // the allowed prompt keeps the turn from ending rejected. + it('adjacent blocked and allowed prompts keep independent turn outcomes', async () => { const adapter = new MockAdapter([textResponse('ran once')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -197,13 +195,13 @@ describe('agent/prompt-submit', () => { const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) - // both sends land before the loop drains → one batched turn + // Both sends land before the driver wakes, but each remains its own turn. send(agent, 'secret') send(agent, 'safe') await waitForIdle(ctx, agent) const log = events(agent) - // the allowed prompt became a user/message and drove exactly one model call + // The allowed prompt became a user/message and drove exactly one model call. const userMsgs = log.filter(e => e.type === 'user/message') expect(userMsgs).toHaveLength(1) expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }]) @@ -215,12 +213,14 @@ describe('agent/prompt-submit', () => { content: [{ type: 'text', text: 'secret' }], reason: 'policy: no secrets', }) - // the turn did NOT reject — a sibling was allowed — so the boundary reason - // alone would not have preserved the block - expect(reasons.some(r => r.kind === 'rejected')).toBe(false) + expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2) + expect(reasons).toEqual([ + { kind: 'rejected', reason: 'policy: no secrets' }, + { kind: 'completed' }, + ]) }) - it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => { + it('a throwing prompt-submit listener ends its turn balanced while an adjacent message survives', async () => { const adapter = new MockAdapter([textResponse('after')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -233,18 +233,18 @@ describe('agent/prompt-submit', () => { const errors: Error[] = [] ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) + const idle = waitForIdle(ctx, agent) send(agent, 'first') - await waitForIdle(ctx, agent) - expect(errors.map(e => e.message)).toEqual(['prompt hook broke']) - // turn balanced - const log = events(agent) - expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1) - expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1) - - // loop survives: a second prompt runs normally send(agent, 'second') - await waitForIdle(ctx, agent) - expect(adapter.requests.length).toBeGreaterThanOrEqual(1) + await idle + expect(errors.map(e => e.message)).toEqual(['prompt hook broke']) + // The failed prompt owns one balanced error turn; the adjacent prompt owns + // the following normal turn without an intermediate idle transition. + const log = events(agent) + expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2) + expect(log.filter(e => e.type === 'turn/end')).toHaveLength(2) + expect(adapter.requests).toHaveLength(1) + expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('second') }) }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index fb686928b1..368ce0c6f1 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -827,7 +827,104 @@ describe('agent loop', () => { expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed') }) - it('chains queued messages into consecutive turns', async () => { + it('keeps same-tick sends in separate turns and checkpoints before the next starts', async () => { + const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + const firstFlush = Promise.withResolvers() + const releaseFirstFlush = Promise.withResolvers() + let flushes = 0 + ctx.on('session/flush', async (session) => { + if (session !== agent.session) return + flushes += 1 + if (flushes === 1) { + firstFlush.resolve(undefined) + await releaseFirstFlush.promise + } + }) + + const turns: number[] = [] + ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'turn/start') turns.push(event.data.turn) + }) + + const idle = waitForIdle(ctx, agent) + send(agent, 'first message') + send(agent, 'second message') + + await firstFlush.promise + expect(turns).toEqual([1]) + expect(adapter.requests).toHaveLength(1) + + releaseFirstFlush.resolve(undefined) + await idle + + expect(turns).toEqual([1, 2]) + expect(flushes).toBe(2) + expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first answer') + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message') + }) + + it('keeps a reentrant agent/queued send as the next independent turn', async () => { + const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + let nested = false + ctx.on('agent/queued', (subject) => { + if (subject !== agent || nested) return + nested = true + send(agent, 'queued listener message') + }) + + const idle = waitForIdle(ctx, agent) + send(agent, 'outer message') + await idle + + const turns = agent.session.events.filter(event => event.type === 'turn/start') + const messages = agent.session.events + .filter(event => event.type === 'user/message') + .map(event => event.data.content) + expect(turns).toHaveLength(2) + expect(messages).toEqual([ + [{ type: 'text', text: 'outer message' }], + [{ type: 'text', text: 'queued listener message' }], + ]) + }) + + it('preserves independent turn sources across an adjacent microtask send', async () => { + const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + const idle = waitForIdle(ctx, agent) + agent.send([{ type: 'text', text: 'user message' }]) + await Promise.resolve() + agent.send( + [{ type: 'text', text: 'plugin message' }], + { source: { kind: 'plugin', plugin: 'test' } }, + ) + await idle + + const triggers = agent.session.events + .filter(event => event.type === 'turn/start') + .map(event => event.data.trigger) + const sources = agent.session.events + .filter(event => event.type === 'user/message') + .map(event => event.data.source) + expect(triggers).toEqual([ + { kind: 'message', source: { kind: 'user' } }, + { kind: 'message', source: { kind: 'plugin', plugin: 'test' } }, + ]) + expect(sources).toEqual([ + { kind: 'user' }, + { kind: 'plugin', plugin: 'test' }, + ]) + }) + + it('keeps a session-listener send after dequeue in the following turn', async () => { const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -850,6 +947,37 @@ describe('agent loop', () => { expect(turns).toEqual([1, 2]) expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first') + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message') + }) + + it('keeps a model-adapter callback send in the following turn', async () => { + const agentRef: { current?: ReactLoopAgent } = {} + const adapter = new MockAdapter([ + () => { + const agent = agentRef.current + if (agent === undefined) throw new Error('model callback ran before agent setup') + send(agent, 'model callback message') + return textResponse('first') + }, + textResponse('second'), + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agentRef.current = agent + + const idle = waitForIdle(ctx, agent) + send(agent, 'outer message') + await idle + + const messages = agent.session.events + .filter(event => event.type === 'user/message') + .map(event => event.data.content) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + expect(messages).toEqual([ + [{ type: 'text', text: 'outer message' }], + [{ type: 'text', text: 'model callback message' }], + ]) }) it('awaits session/flush at turn end (persistence checkpoint)', async () => { diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index dbc43ad985..82b103d664 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -75,6 +75,21 @@ function turnNumbers(agent: ReactLoopAgent): number[] { .map(e => (e.data as { turn: number }).turn) } +function turnEndNumbers(agent: ReactLoopAgent): number[] { + return agent.session.events + .filter(e => e.type === 'turn/end') + .map(e => (e.data as { turn: number }).turn) +} + +function userMessageCountsByTurn(agent: ReactLoopAgent): number[] { + const counts: number[] = [] + for (const event of agent.session.events) { + if (event.type === 'turn/start') counts.push(0) + if (event.type === 'user/message') counts[counts.length - 1]! += 1 + } + return counts +} + /** Assert a status trace is a legal run: idle/running alternating, ending idle. */ function assertLegalStatusTrace(trace: string[]): void { for (let i = 1; i < trace.length; i++) { @@ -84,7 +99,7 @@ function assertLegalStatusTrace(trace: string[]): void { } describe('agent loop scheduling properties', () => { - it('a synchronous burst loses no message and uses strictly increasing turns', async () => { + it('a synchronous burst gives every message its own strictly increasing turn', async () => { await fc.assert(fc.asyncProperty( fc.array(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 6 }), async (texts) => { @@ -99,8 +114,11 @@ describe('agent loop scheduling properties', () => { // No message lost: every send appears as a user/message, in order. expect(userMessageTexts(agent)).toEqual(texts) - // A synchronous burst batches into exactly one turn. - expect(turnNumbers(agent)).toEqual([1]) + // Every successful send owns an independent turn even before the driver wakes. + expect(turnNumbers(agent)).toEqual(texts.map((_, i) => i + 1)) + expect(turnEndNumbers(agent)).toEqual(texts.map((_, i) => i + 1)) + expect(userMessageCountsByTurn(agent)).toEqual(texts.map(() => 1)) + expect(trace).toEqual(['running', 'idle']) assertLegalStatusTrace(trace) } finally { await ctx.fiber.dispose() @@ -131,9 +149,9 @@ describe('agent loop scheduling properties', () => { ), { numRuns: 20, timeout: 2000 }) }) - it('mixed schedule (send, optionally settle) loses no message and orders turns', async () => { - // Each step is a (text, settle?) pair: settle=true awaits idle before the - // next send (own turn); settle=false sends in the same tick (batches). + it('mixed settled and same-tick sends preserve one turn per message', async () => { + // Each step optionally waits for idle before the next send; that scheduling + // choice must not change the ordinary message-to-turn mapping. const stepArb = fc.record({ text: fc.string({ minLength: 1 }), settle: fc.boolean() }) await fc.assert(fc.asyncProperty( fc.array(stepArb, { minLength: 1, maxLength: 6 }), @@ -152,14 +170,13 @@ describe('agent loop scheduling properties', () => { } await lastIdle - // No message lost or reordered, regardless of batching. + // No message is lost or reordered, regardless of driver timing. expect(userMessageTexts(agent)).toEqual(steps.map(s => s.text)) - // Turn numbers are a strictly increasing 1..N prefix (N = turn count). + // Every send owns exactly one turn, numbered in FIFO order. const turns = turnNumbers(agent) - expect(turns).toEqual(turns.map((_, i) => i + 1)) - // Every message landed in some turn; turns never exceed messages. - expect(turns.length).toBeLessThanOrEqual(steps.length) - expect(turns.length).toBeGreaterThanOrEqual(1) + expect(turns).toEqual(steps.map((_, i) => i + 1)) + expect(turnEndNumbers(agent)).toEqual(turns) + expect(userMessageCountsByTurn(agent)).toEqual(steps.map(() => 1)) } finally { await ctx.fiber.dispose() } diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 5639f30daf..e1e5b27f22 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -39,7 +39,7 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). +- `agent.send(content, options?)` — queue one independent FIFO item. Unless broad cancellation or disposal clears it before turn start, that item becomes the sole ordinary message in its turn; the next item waits for the preceding turn's durability checkpoint. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle - `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 3aad65a70e..62d5b2695e 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -59,7 +59,7 @@ export interface HookContext { /** * Prompt interception result. `allow.content` replaces the prompt and * `additionalContext` becomes a separate context message. `block` records a - * durable `prompt/blocked`; an all-blocked batch ends a zero-step rejected turn. + * durable `prompt/blocked` and ends that prompt's zero-step turn as rejected. */ export type PromptDecision = | { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext } @@ -90,7 +90,7 @@ export interface Agent { readonly ctx: Context /** - * Queue detached, frozen lossless-JSON input; starts a turn when idle. + * Queue one detached, frozen lossless-JSON item; if claimed, it is the sole ordinary message in a FIFO-ordered turn. * Invalid input throws synchronously before notification or enqueue. */ send(content: ContentBlock[], options?: SendOptions): void @@ -111,8 +111,8 @@ export interface Agent { inject(content: ContentBlock[], options?: SendOptions): void /** - * Clear queued and steering work, including work waiting to start, and abort - * the active step. The supplied reason is preserved across pre-step and active + * Clear all queued and steering work, including items waiting to start, and + * abort the active step. The supplied reason is preserved across pre-step and active * cancellation windows, and `whenIdle()` resolves after cancellation reaches * quiescence. Idle cancellation is a no-op and does not arm a later cancel. */ diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index f4f42062fd..131deec325 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -105,8 +105,8 @@ export interface TurnEndReasonMap { /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } /** - * Policy blocked every prompt before the first step. The zero-step turn still - * records a balanced durable boundary and the veto reason. + * Policy blocked the turn's claimed prompt before the first step. The + * zero-step turn still records a balanced durable boundary and veto reason. */ rejected: { kind: 'rejected'; reason: string } /** @@ -209,8 +209,8 @@ export interface ToolsDelta { */ export interface SessionEventMap { /** - * Opens turn `turn`. `trigger` records what started it — a drained message - * batch or an idle-time injection. The turn is the durability/replay + * Opens turn `turn`. `trigger` records what started it — one claimed queued + * message or an idle-time injection. The turn is the durability/replay * boundary: every event sits between a `turn/start` and its matching * `turn/end` (the turn-enclosure invariant). */ @@ -229,7 +229,7 @@ export interface SessionEventMap { 'user/message': { content: ContentBlock[]; source: MessageSource } /** * Durable record of a prompt veto and its reason. It is log-only: the blocked - * prompt never enters the model-visible surface, including in a mixed batch. + * prompt never enters the model-visible surface, and its turn runs zero steps. */ 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } /** diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index c464ffd22b..2907334b69 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -709,8 +709,8 @@ export function apply(ctx: Context, config: AcpConfig): void { // session/cancel maps to the queue-aware agent.cancel(reason): it aborts // a RUNNING step, clears the queued + steering FIFOs, and drops a // turn that is about to start (the pre-step window) — so a queued-but- - // not-yet-started prompt never runs, and a prompt accepted right after - // cannot be batched into the cancelled turn. Scoped to THIS session's + // not-yet-started prompt never runs, while a prompt accepted afterward + // remains a separate queued turn. Scoped to THIS session's // agent — a cancel in one session never touches another's stream or // pending prompt (RFC 011 isolation). We ALSO settle the in-flight prompt // as cancelled directly here: do NOT rely on the resulting turn/end to From 2cf689301c337c53739e4cebcc37929c06e325ff Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 17 Jul 2026 17:27:53 +0800 Subject: [PATCH 04/13] review fix: close one-turn lifecycle gaps --- packages/core/agent-loop/src/loop.ts | 1 + packages/core/agent-loop/tests/cancel.spec.ts | 30 +++++++++ .../agent-loop/tests/interception.spec.ts | 11 ++++ packages/core/agent-loop/tests/loop.spec.ts | 65 +++++++++++++++++-- 4 files changed, 102 insertions(+), 5 deletions(-) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index b26d09e5c1..80b6b27eaf 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -117,6 +117,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH } handle.setStatus('running') + if (handle.isDisposed()) break // A synchronous `running` listener can cancel before `runTurn`; balance the // status only when no replacement prompt was queued by that listener. diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 5ed15f1b0b..dd02afdec7 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -88,6 +88,36 @@ describe('Agent.cancel()', () => { expect(agent.status).toBe('idle') }) + it('disposal from the running notification drops queued work before turn start', async () => { + const adapter = new MockAdapter([textResponse('should not run')]) + const ctx = await harness(adapter) + const handle = await ctx.agents.create({ + agentId: AgentId('a-dispose-running'), + sessionId: SessionId('dispose-running-session'), + agentOptions: { model: 'mock' }, + }) + const agent = handle.agent as ReactLoopAgent + + const running = Promise.withResolvers() + let disposalDone: Promise | undefined + ctx.on('agent/status', (subject, status) => { + if (subject !== agent || status !== 'running') return + disposalDone = handle.dispose() + running.resolve(undefined) + }) + + send(agent, 'drop before claim') + await running.promise + if (disposalDone === undefined) throw new Error('running listener did not start disposal') + await disposalDone + await agent.done + + expect(agent.status).toBe('disposed') + expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(userTexts(agent)).toEqual([]) + expect(adapter.requests).toHaveLength(0) + }) + it('a whenIdle() waiter registered BEFORE a pre-step cancel resolves (F1 hang guard)', async () => { const adapter = new MockAdapter([textResponse('x')]) 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 57b773de8a..8663875c6b 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -239,7 +239,13 @@ describe('agent/prompt-submit', () => { return { kind: 'allow' as const } }) const errors: Error[] = [] + const reasons: TurnEndReason[] = [] + const statuses: string[] = [] ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) + ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) }) + ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason) + }) const idle = waitForIdle(ctx, agent) send(agent, 'first') @@ -251,6 +257,11 @@ describe('agent/prompt-submit', () => { const log = events(agent) expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2) expect(log.filter(e => e.type === 'turn/end')).toHaveLength(2) + expect(reasons).toEqual([ + { kind: 'error', step: 0, message: 'prompt hook broke' }, + { kind: 'completed' }, + ]) + expect(statuses).toEqual(['running', 'idle']) expect(adapter.requests).toHaveLength(1) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('second') }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index aff13ddd5c..0e223563b7 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -349,14 +349,24 @@ describe('agent loop', () => { expect(flat).toContain('change of plans') }) - it('steering while idle behaves like send (starts a turn)', async () => { - const adapter = new MockAdapter([textResponse('ok')]) + it('same-tick idle steering inherits one-send-one-turn FIFO behavior', async () => { + const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.steer([{ type: 'text', text: 'hello' }]) - await waitForIdle(ctx, agent) - expect(agent.session.events.some(e => e.type === 'user/message')).toBe(true) + const idle = waitForIdle(ctx, agent) + agent.steer([{ type: 'text', text: 'first idle steer' }]) + agent.steer([{ type: 'text', text: 'second idle steer' }]) + await idle + + 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' }], + [{ type: 'text', text: 'second idle steer' }], + ]) + expect(adapter.requests).toHaveLength(2) }) it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => { @@ -893,6 +903,51 @@ describe('agent loop', () => { expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message') }) + it('holds a turn-end listener send behind the closing turn checkpoint', async () => { + const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + const firstFlush = Promise.withResolvers() + const releaseFirstFlush = Promise.withResolvers() + let flushes = 0 + ctx.on('session/flush', async (session) => { + if (session !== agent.session) return + flushes += 1 + if (flushes === 1) { + firstFlush.resolve(undefined) + await releaseFirstFlush.promise + } + }) + + const turns: number[] = [] + const statuses: string[] = [] + ctx.on('agent/status', (subject, status) => { + if (subject === agent) statuses.push(status) + }) + ctx.on('session/event', (session, event) => { + if (session !== agent.session) return + if (event.type === 'turn/start') turns.push(event.data.turn) + if (event.type === 'turn/end' && event.data.turn === 1) send(agent, 'turn-end listener message') + }) + + const idle = waitForIdle(ctx, agent) + send(agent, 'first message') + await firstFlush.promise + + expect(turns).toEqual([1]) + expect(adapter.requests).toHaveLength(1) + + releaseFirstFlush.resolve(undefined) + await idle + + expect(turns).toEqual([1, 2]) + expect(statuses).toEqual(['running', 'idle']) + expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first answer') + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('turn-end listener message') + }) + it('keeps a reentrant agent/queued send as the next independent turn', async () => { const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) From 0b720942dfe6e8e018d537b80364dfbd201ed27e Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 17 Jul 2026 17:31:35 +0800 Subject: [PATCH 05/13] review fix: qualify one-turn documentation --- docs/architecture.md | 2 +- docs/core-data-structures/core.md | 7 +++--- .../feature/2026-06-30-interception-seams.md | 2 +- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 ++-- .../2026-07-17-one-send-one-turn.md | 8 +++---- .../2026-07-17-one-send-one-turn.zh.md | 24 +++++++++---------- packages/core/agent-loop/README.md | 2 +- packages/core/agent/README.md | 2 +- 8 files changed, 26 insertions(+), 25 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 063c805272..627776bbe0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -95,7 +95,7 @@ forever: checkpoint persistence and notify idle/running status ``` -Each successful `send()` adds one FIFO item. Queued items run as consecutive ordinary turns under one running interval, each after the prior turn's durability checkpoint. Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). +Each successful `send()` adds one FIFO item. A claimed item is the sole ordinary message in its turn and waits for the prior checkpoint to settle; cancellation, disposal, or a pre-start failure may drop it without a turn. Consecutive claimed items run under one `running` interval. Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 072ad629a6..0d8c874704 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -271,9 +271,10 @@ interface Agent { readonly ctx: Context /** - * Queue one user-message FIFO item. Unless cleared before turn start, the - * item becomes the sole ordinary message in its turn and waits for every - * preceding turn's durability checkpoint. Content and the resolved source are accepted as one detached, + * Queue one user-message FIFO item. If claimed, the item becomes the sole + * ordinary message in its turn after every preceding turn's checkpoint + * settles. Broad cancellation, disposal, or a pre-start failure can drop it + * without a turn. Content and the resolved source are accepted as one detached, * deeply-frozen lossless-JSON record before notification or enqueue, so * caller or `agent/queued` listener in-place mutation cannot change later * log/model input. Throws synchronously when either value is not losslessly diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index 50ff4a3af6..aef2ef4eb5 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -34,7 +34,7 @@ Core dispatch and the tool body sit inside normalization boundaries, so tool, li ### Three load-bearing loop decisions -1. **Open the turn before prompt policy.** A blocked prompt becomes a zero-step `rejected` turn, preserving enclosure and giving ACP a durable terminal event. The veto records `prompt/blocked` with the original prompt and reason, while every allowed `additionalContexts` entry is injected into the open turn. Each ordinary send owns an independent turn under the [one-send-one-turn simplification](../simplification/2026-07-17-one-send-one-turn.md). +1. **Open the turn before prompt policy.** A blocked prompt becomes a zero-step `rejected` turn, preserving enclosure and giving ACP a durable terminal event. The veto records `prompt/blocked` with the original prompt and reason, while every allowed `additionalContexts` entry is injected into the open turn. Each claimed ordinary-send item is the sole message in its turn under the [one-send-one-turn simplification](../simplification/2026-07-17-one-send-one-turn.md); a pre-start drop creates no turn. 2. **Post-tool `additionalContexts` are buffered and appended AFTER all `tool/result`s.** `content`/`feedback` shape the result `execute()` returns, but each context is a SEPARATE `context/message`, and a single step or composite tool can produce many. Appending context immediately would interleave `result(c1) → context → result(c2)` or place nested context before its outer result, breaking tool-call/result adjacency. `ToolRunContext.deferContext()` therefore collects nested-dispatch context through failures, `execute()` surfaces the ordered array on `ToolExecutionResult`, and the loop appends every entry only after every `tool/result` in the step. An accepted outer call preserves deferred contexts before decision contexts; an outer block discards deferred contexts and exposes only contexts explicitly supplied by the blocking decision. diff --git a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index bcb9485da5..1d3181bb0a 100644 --- a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.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 -2026-07-17-one-send-one-turn.md: 34331a04b53f9ccf67db0baf201dec23e1c2a60f -2026-07-17-one-send-one-turn.zh.md: e6c4b97e826393ebca81d715ae3760c6068f84d0 +2026-07-17-one-send-one-turn.md: 232e91b1d13ed07f230ffc2d0a190adafb29c6e2 +2026-07-17-one-send-one-turn.zh.md: b4dc8d37a0dc03a8aae1abfab4c9d473c7afd332 diff --git a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md index 34331a04b5..232e91b1d1 100644 --- a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -8,7 +8,7 @@ English | [中文](2026-07-17-one-send-one-turn.zh.md) An ordinary `Agent.send()` payload is one complete caller message. Opportunistically draining every waiting payload into one turn would make adjacent calls share a boundary according to driver timing: calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks could be grouped differently even though callers used the same API. -A turn owns prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later message join an earlier message's model request instead of observing the earlier turn's committed result, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. +A turn owns prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later message join an earlier message's model request instead of observing the earlier turn's closed result in the same session log, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. `steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit batching would make `send()` overlap both explicit operations instead of preserving a single meaning. @@ -18,11 +18,11 @@ Each successful `send()` synchronously validates agent state, snapshots and free Prompt admission decides one message. An allowed prompt becomes that turn's `user/message`; a blocked prompt appends one durable `prompt/blocked` and ends that one-message turn as `rejected`. There are no mixed-batch or all-blocked-batch branches. -Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` delegates to `send()` and therefore creates an independent ordinary turn. `inject()` retains its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` remain whole-agent operations rather than per-message controls. +Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` delegates to `send()` and therefore creates an independent ordinary queue item. `inject()` retains its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` remain whole-agent operations rather than per-message controls. ## Alternatives considered -**Keep opportunistic batching for throughput.** Combining queued prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and prevents a later message from reliably observing the preceding turn's durable result. Explicit lifecycle semantics are worth the additional model calls; any future batching feature needs an explicit caller-visible contract justified by measurements. +**Keep opportunistic batching for throughput.** Combining queued prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and lets a later message run before the preceding turn closes and its checkpoint settles. Explicit lifecycle semantics are worth the additional model calls; any future batching feature needs an explicit caller-visible contract justified by measurements. ## Verification @@ -33,6 +33,6 @@ Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` del ## Consequences -Ordinary turn boundaries are deterministic, and a claimed FIFO successor observes the preceding turn's committed session result. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. +Ordinary turn boundaries are deterministic, and a claimed FIFO successor observes the preceding turn's closed session result after its checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. Workloads that relied on coincidental batching make more model requests, incur more checkpoints, and may take longer to drain; FIFO queues may grow under sustained producers. Throughput optimization can return only through an explicit measured contract. diff --git a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index e6c4b97e82..b4dc8d37a0 100644 --- a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -6,33 +6,33 @@ Status: implemented ## 问题 -每个普通 `Agent.send()` payload 都是一条完整的调用方消息。如果机会式地把所有等待 payload 放入同一个轮次,相邻调用是否共享边界就会取决于 driver 时机:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件 listener 和模型 callback 的调用也可能产生不同分组。 +每次普通 `Agent.send()` 接受的载荷都是一条完整的调用方消息。如果机会式地把所有待处理载荷放入同一个轮次,相邻调用是否共享边界就会取决于驱动器的运行时机:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件监听器和模型回调的调用也可能产生不同分组。 -轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条消息加入前一条消息的模型请求,而不能观察前一轮次已经提交的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 +轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条消息加入前一条消息的模型请求,无法观察前一轮次关闭后写入同一会话日志的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 `steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 ## 决策 -每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立 FIFO item,然后发布 `agent/queued`。agent loop 在每次轮次开始时最多取出一个普通 item。如果两个 item 都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点完成后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的 item,而不创建空轮次。 +每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop(智能体循环)在每个轮次开始时最多取出一个普通队列项。如果两个队列项最终都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 -提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有 mixed-batch 或 all-blocked-batch 分支。 +提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有混合批次或全阻止批次分支。 -运行中的 `steer()` 会追加到当前轮次的 steering FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通轮次。`inject()` 保持现有的轮次封闭与 flush 行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 +运行中的 `steer()` 会把消息追加到当前轮次的 steering(中途引导) FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个智能体的操作,不变成逐消息控制。 ## 曾考虑的替代方案 -**为吞吐量保留机会式批处理。** 当 producer 速度快于 driver 时,合并排队的提示词可以减少模型调用,但会让轮次边界取决于调度,并使后一条消息无法可靠观察前一轮次的持久化结果。额外模型调用的代价低于显式生命周期语义的价值;未来的任何批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 +**为吞吐量保留机会式批处理。** 当消息进入队列的速度超过驱动器的处理速度时,合并排队的提示词可以减少模型调用,但会让轮次边界取决于调度,并让后一条消息在前一轮次关闭且其检查点处理结束之前就运行。额外模型调用的代价低于显式生命周期语义的价值;未来的任何批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 ## 验证 -- 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 send 的行为:每个轮次只有一条消息,并按 FIFO 排序。 -- 延迟第一个轮次的 flush 可以证明下一个排队轮次不能在检查点完成前开始,且其请求能看到前一个 assistant result;被拒绝的 flush 也会在下一个轮次开始前完成。 -- 提示词否决与 listener failure、广义取消、dispose 和提交前 `turn/start` failure 都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 -- 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的 status 和 `whenIdle()` 保持原有覆盖。 +- 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 `send()` 的行为:每个轮次只有一条消息,并按 FIFO 排序。 +- 延迟第一个轮次的持久化刷新可以证明下一个排队轮次不能在检查点处理结束前开始,且其请求能看到前一条助手结果;刷新即使失败,下一轮次也要等它结束后才会开始。 +- 提示词否决、监听器失败、广义取消、资源释放和 `turn/start` 提交前失败都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 +- 运行中与空闲时的 `steer()`、`inject()`、面向整个智能体的状态和 `whenIdle()` 保持原有覆盖。 ## 后果 -普通轮次边界是确定的,被认领的 FIFO 后继项可以观察前一轮次已经提交的会话结果。多个排队 item 仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此 status 和静止状态仍是面向整个 agent 的观察,而不是逐消息结果。 +普通轮次边界是确定的,被认领的 FIFO 后继项会在前一轮次关闭且其检查点处理结束后观察会话中的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个智能体的观察,而不是逐消息结果。 -依赖偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续 producer 还可能让 FIFO 队列增长。只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。 +依赖偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 88efc953a7..e899da75fc 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -44,7 +44,7 @@ Configured agents start automatically. `cwd` applies only to fresh sessions; `re - `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy. -`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. Each `ReactLoopAgent.send()` materializes content plus resolved source once as a detached, deeply frozen lossless-JSON FIFO item, shares that accepted record between `agent/queued` and the inbox, and gives the item its own ordinary turn after preceding checkpoints; malformed data throws before either boundary. Running `steer()` uses the same acceptance boundary but joins the active turn. +`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. Each `ReactLoopAgent.send()` materializes content plus resolved source once as a detached, deeply frozen lossless-JSON FIFO item and shares that accepted record between `agent/queued` and the inbox. If claimed, the item becomes the sole ordinary message in a turn after preceding checkpoints settle; cancellation, disposal, or a pre-start failure can drop it first. Malformed data throws before either acceptance boundary. Running `steer()` uses the same boundary but joins the active turn. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 8e86700835..e56eb8cf94 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -41,7 +41,7 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(content, options?)` — queue one independent FIFO item. Unless broad cancellation or disposal clears it before turn start, that item becomes the sole ordinary message in its turn; the next item waits for the preceding turn's durability checkpoint. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). +- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn after the preceding checkpoint settles; broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle - `agent.inject(content, options?)` — inject in-session context (`context/message` event); the next request sees it. `options.envelope` defaults to the canonical `` framing and may be `'raw'` when the caller owns a complete familiar frame; `options.meta` persists opaque JSON state without rendering it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. From 915c727e14ceeb0b3bcbc696ca779d399c555fce Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 17 Jul 2026 17:38:39 +0800 Subject: [PATCH 06/13] review fix: distinguish checkpoint settlement --- docs/core-data-structures/persistence.md | 2 +- docs/persistence-catalog.md | 2 +- .../2026-07-17-one-send-one-turn.i18n.yaml | 2 +- .../2026-07-17-one-send-one-turn.zh.md | 12 ++++++------ packages/core/agent-loop/tests/properties.spec.ts | 4 ++-- packages/core/session/src/types.ts | 4 ++-- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 7bf102924b..3ae22d05d1 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -6,7 +6,7 @@ The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06 ## The flush checkpoint -`session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) and drain at the awaited `session/flush` checkpoint the loop fires at every turn end. Flush is `ctx.parallel` (awaited): a turn's events are durably committed before the next turn starts, and the turn boundary is the commit boundary. A rejecting flush is reported via `agent/error` and the logger — never as a session event (it would land past the commit boundary), so the backend keeps its buffered events for the next flush. +`session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) and drain at the awaited `session/flush` checkpoint the loop fires at every turn end. The next turn waits for that checkpoint to settle. A successful flush durably commits the closed turn as one unit; a rejecting flush is reported via `agent/error` and the logger — never as a session event (it would land past the closed turn) — and does not prevent the next turn, while the backend keeps its buffered events for the next flush. ## Crash recovery preserves an interrupted turn diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 6c48285d99..9678d25594 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -293,7 +293,7 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/ #### `turn/end` — log-only -Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awaited `session/flush` checkpoint at every turn end, so the turn boundary is also the durable-commit boundary. +Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awaited `session/flush` checkpoint at every turn end; the next turn waits for settlement. Success commits the closed turn; rejection is reported live and does not prevent later work. ```ts persistence-catalog 'turn/end': { turn: number; reason: TurnEndReason } diff --git a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index 1d3181bb0a..2d926e45c3 100644 --- a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-17-one-send-one-turn.md: 232e91b1d13ed07f230ffc2d0a190adafb29c6e2 -2026-07-17-one-send-one-turn.zh.md: b4dc8d37a0dc03a8aae1abfab4c9d473c7afd332 +2026-07-17-one-send-one-turn.zh.md: c533e0fa35a73056b2bb5ae6ca8a53e5757c55cf diff --git a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index b4dc8d37a0..c533e0fa35 100644 --- a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -8,17 +8,17 @@ Status: implemented 每次普通 `Agent.send()` 接受的载荷都是一条完整的调用方消息。如果机会式地把所有待处理载荷放入同一个轮次,相邻调用是否共享边界就会取决于驱动器的运行时机:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件监听器和模型回调的调用也可能产生不同分组。 -轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条消息加入前一条消息的模型请求,无法观察前一轮次关闭后写入同一会话日志的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 +轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条消息加入前一条消息的模型请求,无法观察同一会话日志中前一个已关闭轮次的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 `steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 ## 决策 -每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop(智能体循环)在每个轮次开始时最多取出一个普通队列项。如果两个队列项最终都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 +每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop 在每个轮次开始时最多取出一个普通队列项。如果两个队列项最终都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有混合批次或全阻止批次分支。 -运行中的 `steer()` 会把消息追加到当前轮次的 steering(中途引导) FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个智能体的操作,不变成逐消息控制。 +运行中的 `steer()` 会把消息追加到当前轮次的 steering(中途引导) FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 ## 曾考虑的替代方案 @@ -28,11 +28,11 @@ Status: implemented - 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 `send()` 的行为:每个轮次只有一条消息,并按 FIFO 排序。 - 延迟第一个轮次的持久化刷新可以证明下一个排队轮次不能在检查点处理结束前开始,且其请求能看到前一条助手结果;刷新即使失败,下一轮次也要等它结束后才会开始。 -- 提示词否决、监听器失败、广义取消、资源释放和 `turn/start` 提交前失败都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 -- 运行中与空闲时的 `steer()`、`inject()`、面向整个智能体的状态和 `whenIdle()` 保持原有覆盖。 +- 提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 提交前失败都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 +- 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的状态和 `whenIdle()` 保持原有覆盖。 ## 后果 -普通轮次边界是确定的,被认领的 FIFO 后继项会在前一轮次关闭且其检查点处理结束后观察会话中的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个智能体的观察,而不是逐消息结果。 +普通轮次边界是确定的,被认领的 FIFO 后继项会在前一轮次关闭且其检查点处理结束后观察会话中的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 依赖偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。 diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 82b103d664..1dd1a0c127 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -114,7 +114,7 @@ describe('agent loop scheduling properties', () => { // No message lost: every send appears as a user/message, in order. expect(userMessageTexts(agent)).toEqual(texts) - // Every successful send owns an independent turn even before the driver wakes. + // This failure-free fixture claims every item into an independent turn. expect(turnNumbers(agent)).toEqual(texts.map((_, i) => i + 1)) expect(turnEndNumbers(agent)).toEqual(texts.map((_, i) => i + 1)) expect(userMessageCountsByTurn(agent)).toEqual(texts.map(() => 1)) @@ -172,7 +172,7 @@ describe('agent loop scheduling properties', () => { // No message is lost or reordered, regardless of driver timing. expect(userMessageTexts(agent)).toEqual(steps.map(s => s.text)) - // Every send owns exactly one turn, numbered in FIFO order. + // Every item is claimed and therefore owns one FIFO-ordered turn. const turns = turnNumbers(agent) expect(turns).toEqual(steps.map((_, i) => i + 1)) expect(turnEndNumbers(agent)).toEqual(turns) diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index f9de8962ac..dfc8a57fcd 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -221,8 +221,8 @@ export interface SessionEventMap { 'turn/start': { turn: number; trigger: TurnTrigger } /** * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop - * fires the awaited `session/flush` checkpoint at every turn end, so the turn - * boundary is also the durable-commit boundary. + * fires the awaited `session/flush` checkpoint at every turn end; the next turn waits for settlement. + * Success commits the closed turn; rejection is reported live and does not prevent later work. */ 'turn/end': { turn: number; reason: TurnEndReason } /** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */ From a6e96c47f37e12e1cb1a3a36514ba6aa060fe09e Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 11:53:49 +0800 Subject: [PATCH 07/13] review fix: pin ordinary-send batching removal --- ...-18-agent-lifecycle-and-ownership-seams.md | 4 +-- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 +-- .../2026-07-17-one-send-one-turn.md | 3 +- .../2026-07-17-one-send-one-turn.zh.md | 3 +- docs/architecture.md | 6 ++-- docs/cordis-catalog/events.md | 30 ++++++++--------- docs/core-data-structures/core.md | 14 +++++--- docs/core-data-structures/persistence.md | 2 +- docs/core-data-structures/session.md | 5 +-- docs/event-producer-consumer.md | 30 ++++++++--------- docs/persistence-catalog.md | 33 ++++++++++--------- packages/core/agent-loop/src/loop.ts | 4 +-- packages/core/agent/README.md | 2 +- packages/core/agent/src/types.ts | 14 +++++--- packages/core/session/src/types.ts | 5 +-- .../stdio-demo/tests/built-bin.e2e.ts | 17 ++++++++-- packages/ui/stdio/src/index.ts | 6 ++-- website/zh-CN/api/harness/events.md | 30 ++++++++--------- 18 files changed, 118 insertions(+), 94 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index 125ef0946c..ef5bb5847a 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -12,7 +12,7 @@ Three seams: the queue-aware cancel, the `AgentHandle` disposer, and the bash ow ### 1. Queue-aware `Agent.cancel(reason?)` -A new `cancel()` verb on the `Agent` interface — the single public stop primitive. (It originally shipped alongside a narrower step-only `abort()`; that verb was later removed as unused, leaving `cancel()` the only public way to stop work.) It clears the inbox's queued + steering FIFOs, aborts the in-flight step if any, and drives a **turn-scoped cancellation marker** the driver loop checks at every turn-decision point — so a prompt that is queued-but-not-yet-started never runs, a cancel landing in the pre-step / continuation window drops the about-to-run turn (ending it `aborted`), and a later prompt cannot be batched into the cancelled turn. `whenIdle()` reaches post-cancel quiescence. ACP `session/cancel` maps to `cancel()`. The marker is armed ONLY when there is something to cancel, so an idle no-op cancel cannot strand the next prompt. +A new `cancel()` verb on the `Agent` interface — the single public stop primitive. (It originally shipped alongside a narrower step-only `abort()`; that verb was later removed as unused, leaving `cancel()` the only public way to stop work.) It clears the inbox's queued + steering FIFOs, aborts the in-flight step if any, and drives a **turn-scoped cancellation marker** the driver loop checks at every turn-decision point — so a prompt that is queued-but-not-yet-started never runs, a cancel landing in the pre-step / continuation window drops the about-to-run turn (ending it `aborted`), and a later accepted prompt remains an independent queued turn. `whenIdle()` reaches post-cancel quiescence. ACP `session/cancel` maps to `cancel()`. The marker is armed ONLY when there is something to cancel, so an idle no-op cancel cannot strand the next prompt. ### 2. `AgentHandle` async disposer @@ -29,7 +29,7 @@ Background-task ownership moved from a `tool-bash` plugin-local `Map-session-`; `sessionId` resumes or creates; `resumeSessionId` requires history. Active failures emit `agent-loop/config-start-failed(sessionId, error)`, so front doors reject work; teardown stays silent. @@ -106,7 +106,7 @@ forever: checkpoint persistence and notify idle/running status ``` -Each successful `send()` adds one FIFO item whose claimed turn contains no other ordinary message; consecutive claimed items wait for the prior ordinary turn's checkpoint to settle, but can share one `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). Cancellation, disposal, or a pre-start failure may drop an item without a turn. Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). +Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). Tool-time context—including async `agent.inject()` notices and post-tool `additionalContexts`—settles, then follows recorded results. Steering drains before `agent/post-step`, which observes durable output, results, context, and steering before signal closure. Leftovers become queued input. Terminal `agent/turn-stop` runs after continuation and steering folding, stays authoritative through turn close and flush, and discards later steering but preserves queued prompts. diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 5d6c627751..87f9e1fdd8 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -33,7 +33,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:147`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:151`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -53,7 +53,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:156`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:160`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -75,7 +75,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:311`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -98,7 +98,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:264`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:268`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -121,7 +121,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:204`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -142,7 +142,7 @@ Allow, rewrite, or block one drained 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:214`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -163,7 +163,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -186,7 +186,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:226`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -211,7 +211,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.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:278`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:282`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -237,7 +237,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:241`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:245`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -259,7 +259,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:188`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:192`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -279,7 +279,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:165`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:169`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -301,7 +301,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:252`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:256`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -322,7 +322,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:288`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:292`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -343,7 +343,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:298`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 675182422e..ba1b2e6156 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -362,7 +362,9 @@ interface Agent { readonly ctx: Context /** - * Queue one detached, frozen lossless-JSON item; if claimed, it is the sole ordinary message in a FIFO-ordered turn. + * Queue one detached, frozen lossless-JSON item. If claimed, it is the sole + * ordinary message in its FIFO-ordered turn; the next claimed item waits for + * that turn's checkpoint. * Invalid input throws synchronously before notification or enqueue. */ send(content: ContentBlock[], options?: SendOptions): void @@ -385,9 +387,10 @@ interface Agent { /** * Clear all queued and steering work, including items waiting to start, and - * abort the active step. The supplied reason is preserved across pre-step and active - * cancellation windows, and `whenIdle()` resolves after cancellation reaches - * quiescence. Idle cancellation is a no-op and does not arm a later cancel. + * abort the active step. The supplied reason is preserved across pre-step + * and active cancellation windows, and `whenIdle()` resolves after + * cancellation reaches quiescence. Idle cancellation is a no-op and does not + * arm a later cancel. */ cancel(reason?: string): void @@ -429,7 +432,8 @@ interface HookContext { /** * Prompt interception result. `allow.content` replaces the prompt and each * `additionalContexts` entry becomes a separate context message. `block` - * records a durable `prompt/blocked` and ends that prompt's zero-step turn as rejected. + * records a durable `prompt/blocked` and ends the claimed prompt's zero-step + * turn as rejected. */ type PromptDecision = | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 9d4dc2ee6f..18ef172ef8 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -6,7 +6,7 @@ The seam is a textbook [capability seam](../../.agents/notes/implemented/archite ## The flush checkpoint -`session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) and drain at the awaited `session/flush` checkpoint the loop fires at every turn end. The next turn waits for that checkpoint to settle. A successful flush durably commits the closed turn as one unit; a rejecting flush is reported via `agent/error` and the logger — never as a session event (it would land past the closed turn) — and does not prevent the next turn, while the backend keeps its buffered events for the next flush. +`session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) until `session/flush`. The loop awaits an ordinary turn's checkpoint before claiming the next queue item; synchronous idle `inject()` schedules its checkpoint without blocking `send()`, and disposal still drains it. A successful flush durably commits the closed turn as one unit; a rejecting flush is reported through `agent/error` and the logger — never as a session event past the closed turn — while the backend keeps its buffered events for the next flush. ## Crash recovery preserves an interrupted turn diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index d73f3a21fb..89a8972b98 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -34,8 +34,9 @@ interface SessionEventMap { 'turn/start': { turn: number; trigger: TurnTrigger } /** * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop - * fires the awaited `session/flush` checkpoint at every turn end; the next turn waits for settlement. - * Success commits the closed turn; rejection is reported live and does not prevent later work. + * awaits `session/flush` after an ordinary turn ends before claiming the next + * queued item. Success commits the turn; rejection is reported live and does + * not prevent later work. */ 'turn/end': { turn: number; reason: TurnEndReason } /** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4f6e6daff0..5912accb71 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:362`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:147`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:156`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:204`](../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:214`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:175`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:241`](../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:188`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../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) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:151`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:160`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:315`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:268`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:208`](../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:218`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:179`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:282`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:245`](../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:192`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:169`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:256`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:292`](../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) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index baf8f61057..82859e2eb0 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -79,7 +79,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:293`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:325`](../packages/core/session/src/types.ts) ## Events @@ -151,7 +151,7 @@ Source: [`packages/ui/user-approval/src/index.ts:68`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:220`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -167,7 +167,7 @@ Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:227`](../packages/core/session/src/types.ts) ### `bash/*` @@ -258,7 +258,7 @@ Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:212`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts) ### `hook/*` @@ -336,7 +336,7 @@ Source: [`packages/ui/permission/src/index.ts:33`](../packages/ui/permission/src Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:205`](../packages/core/session/src/types.ts) ### `request/*` @@ -350,7 +350,7 @@ Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts) ### `steering/*` @@ -363,7 +363,7 @@ Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) ### `step/*` @@ -374,7 +374,7 @@ Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:198`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -383,7 +383,7 @@ Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:195`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:196`](../packages/core/session/src/types.ts) ### `todo/*` @@ -396,7 +396,7 @@ Source: [`packages/core/session/src/types.ts:195`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/types.ts) ### `tool/*` @@ -413,7 +413,7 @@ Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:233`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -457,7 +457,7 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:243`](../packages/core/session/src/types.ts) ### `turn/*` @@ -466,15 +466,16 @@ Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/ ```ts persistence-catalog /** * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop - * fires the awaited `session/flush` checkpoint at every turn end; the next turn waits for settlement. - * Success commits the closed turn; rejection is reported live and does not prevent later work. + * awaits `session/flush` after an ordinary turn ends before claiming the next + * queued item. Success commits the turn; rejection is reported live and does + * not prevent later work. */ 'turn/end': { turn: number; reason: TurnEndReason } ``` Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:194`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -503,4 +504,4 @@ Source: [`packages/core/session/src/types.ts:187`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:199`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:200`](../packages/core/session/src/types.ts) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index d7b3fb2ee7..0024623e8d 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -99,8 +99,8 @@ export interface LoopHandle { /** * Drive queued messages as independent durable turns until disposal. Plugin - * failures end the current turn without terminating the driver. The caller establishes the - * `ctx.agents.withInitiator()` boundary before entry; package-private + * failures end the current turn without terminating the driver. The caller + * establishes the `ctx.agents.withInitiator()` boundary before entry; package-private * orchestration recovers that exact Agent and captures its Session locally. * @param ctx - the plugin context the loop reaches its initiating Agent, * events (agent/…, session/flush), and services (systemPrompt, llm, tools) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index f11023a248..fc6382ab2d 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -54,7 +54,7 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn after the preceding checkpoint settles; broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). +- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the rationale. - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle - `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. `options.envelope` defaults to the canonical `` framing and may be `'raw'` when the caller owns a complete familiar frame; `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 25ed34d694..f9bf8f2825 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -59,7 +59,8 @@ export interface HookContext { /** * Prompt interception result. `allow.content` replaces the prompt and each * `additionalContexts` entry becomes a separate context message. `block` - * records a durable `prompt/blocked` and ends that prompt's zero-step turn as rejected. + * records a durable `prompt/blocked` and ends the claimed prompt's zero-step + * turn as rejected. */ export type PromptDecision = | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } @@ -97,7 +98,9 @@ export interface Agent { readonly ctx: Context /** - * Queue one detached, frozen lossless-JSON item; if claimed, it is the sole ordinary message in a FIFO-ordered turn. + * Queue one detached, frozen lossless-JSON item. If claimed, it is the sole + * ordinary message in its FIFO-ordered turn; the next claimed item waits for + * that turn's checkpoint. * Invalid input throws synchronously before notification or enqueue. */ send(content: ContentBlock[], options?: SendOptions): void @@ -120,9 +123,10 @@ export interface Agent { /** * Clear all queued and steering work, including items waiting to start, and - * abort the active step. The supplied reason is preserved across pre-step and active - * cancellation windows, and `whenIdle()` resolves after cancellation reaches - * quiescence. Idle cancellation is a no-op and does not arm a later cancel. + * abort the active step. The supplied reason is preserved across pre-step + * and active cancellation windows, and `whenIdle()` resolves after + * cancellation reaches quiescence. Idle cancellation is a no-op and does not + * arm a later cancel. */ cancel(reason?: string): void diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 11d0fb129b..df73f3fb50 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -187,8 +187,9 @@ export interface SessionEventMap { 'turn/start': { turn: number; trigger: TurnTrigger } /** * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop - * fires the awaited `session/flush` checkpoint at every turn end; the next turn waits for settlement. - * Success commits the closed turn; rejection is reported live and does not prevent later work. + * awaits `session/flush` after an ordinary turn ends before claiming the next + * queued item. Success commits the turn; rejection is reported live and does + * not prevent later work. */ 'turn/end': { turn: number; reason: TurnEndReason } /** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */ diff --git a/packages/examples/stdio-demo/tests/built-bin.e2e.ts b/packages/examples/stdio-demo/tests/built-bin.e2e.ts index d3e0a32d09..c2bb459cc9 100644 --- a/packages/examples/stdio-demo/tests/built-bin.e2e.ts +++ b/packages/examples/stdio-demo/tests/built-bin.e2e.ts @@ -104,8 +104,8 @@ async function makeConsumer( return dir } -/** Run the built bin in `cwd` against `configArg` with one stdin line; resolve with stdout/stderr + exit code. */ -function runBuiltBin(cwd: string, configArg: string, line: string): Promise<{ stdout: string; code: number; stderr: string }> { +/** Run the built bin in `cwd` against `configArg` with piped stdin; resolve with stdout/stderr + exit code. */ +function runBuiltBin(cwd: string, configArg: string, input: string): Promise<{ stdout: string; code: number; stderr: string }> { return new Promise((resolve, reject) => { // --expose-internals: the cordis Loader resolves bare plugin specifiers via // its internal module loader (active only under this flag); demo:echo passes @@ -128,7 +128,7 @@ function runBuiltBin(cwd: string, configArg: string, line: string): Promise<{ st }, 25_000) child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) }) child.on('error', (err) => { clearTimeout(timer); reject(err) }) - child.stdin.write(`${line}\n`) + child.stdin.write(`${input}\n`) child.stdin.end() }) } @@ -167,6 +167,17 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-demo BUILT bin (node lib/bin.j expect(code).toBe(0) }, 30_000) + it('runs two synchronously piped lines as two ordinary turns', async () => { + consumer = await makeConsumer('TWO-TURNS ready.') + const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'first\nsecond') + expect(stderr).not.toContain('UNHANDLED') + expect(stdout).toContain('[main turn 1]') + expect(stdout).toContain('You said: "first"') + expect(stdout).toContain('[main turn 2]') + expect(stdout).toContain('You said: "second"') + expect(code).toBe(0) + }, 30_000) + it('boots when optional spill plugins are loaded from a built consumer install', async () => { consumer = await makeConsumer( 'SPILL-OK ready.', diff --git a/packages/ui/stdio/src/index.ts b/packages/ui/stdio/src/index.ts index 6f73d948bf..720284518a 100644 --- a/packages/ui/stdio/src/index.ts +++ b/packages/ui/stdio/src/index.ts @@ -164,9 +164,9 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt // immediately — no turn will ever start, so there is nothing to wait // for. (Gating on an observed 'running' here would hang forever.) // - If work WAS submitted, exit the next time the agent settles to idle - // AFTER having run. Two subtleties this handles: the loop batches - // several queued messages into ONE turn (one idle), so we don't count - // sends; and agent.send() does NOT synchronously flip status to + // AFTER having run. Later lines may steer the active turn, and consecutive + // queued turns can share one running interval, so we don't count inputs; + // agent.send() also does NOT synchronously flip status to // 'running', so requiring an observed 'running' first (`sawRunning`) // avoids exiting in the gap before the turn starts and dropping work. let stdinClosed = false diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 9cad9215fb..0f5f1132ba 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -28,7 +28,7 @@ A fully configured agent and live session were published. Setup is composition-o - `agent` — the newly registered agent with its live session and completed setup. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L147) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L151) ### agent/disposed @@ -50,7 +50,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef - `agent` — the exact agent removed from the registry. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L156) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L160) ### agent/error @@ -77,7 +77,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w - `step` — the step at which the failure surfaced. - `error` — the failure, verbatim. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L311) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L315) ### agent/post-step @@ -105,7 +105,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in - `step` — the open step number. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L264) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L268) ### agent/pre-step @@ -133,7 +133,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending - `step` — the pending step number. - `signal` — the turn abort signal. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L204) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L208) ### agent/prompt-submit @@ -158,7 +158,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca - `content` — the drained message's blocks, as queued. - `source` — the message's resolved source. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L214) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L218) ### agent/queued @@ -183,7 +183,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already - `content` — the accepted content blocks retained by the inbox. - `info` — the accepted source plus whether it entered as steering. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L175) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L179) ### agent/request @@ -211,7 +211,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha - `step` — the step whose request this is. - `config` — the config the loop would use (frozen); return a replacement to switch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L226) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L230) ### agent/request-error @@ -243,7 +243,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens - `retryAttempt` — zero-based number of prior recovery retries. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L278) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L282) ### agent/session-prefix @@ -273,7 +273,7 @@ Compose request-only messages placed before derived history. The frozen result i - `prefix` — the frozen seed; return an extended replacement. - `signal` — aborts composition when the step is torn down. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L241) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L245) ### agent/session-start @@ -298,7 +298,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to - `agent` — the agent whose session lifecycle began. - `source` — why the session started (fresh startup, resume, …). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L188) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L192) ### agent/status @@ -321,7 +321,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no - `agent` — the agent whose status flipped. - `status` — the status just entered (the transition's destination). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L165) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L169) ### agent/step-result @@ -348,7 +348,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va - `step` — the step that produced the message. - `message` — the assistant message as assembled from the stream. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L252) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L256) ### agent/turn-continuation @@ -373,7 +373,7 @@ Override whether the turn continues. The default continues after tool calls or s - `turn` — the turn being continued or stopped. - `defaultDecision` — what the loop would do absent an override. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L288) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L292) ### agent/turn-stop @@ -397,7 +397,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a - `agent` — the agent whose composed continuation outcome may be stopped. - `turn` — the turn at its terminal-stop checkpoint. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L298) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L302) ## agent-loop/* From d59149a22183f329665157c49ff26fe11aef7ff8 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 12:51:44 +0800 Subject: [PATCH 08/13] review fix: restore idle after between-turn cancellation --- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 +- .../2026-07-17-one-send-one-turn.md | 14 ++--- .../2026-07-17-one-send-one-turn.zh.md | 16 +++--- docs/architecture.md | 2 +- docs/cordis-catalog/events.md | 8 +-- docs/core-data-structures/session.md | 2 +- docs/i18n/style-samples.md | 4 +- docs/persistence-catalog.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/core/agent-loop/src/agent.ts | 2 +- packages/core/agent-loop/src/loop.ts | 5 +- packages/core/agent-loop/tests/cancel.spec.ts | 54 +++++++++++++++++++ packages/core/agent/src/types.ts | 6 +-- packages/core/session/src/types.ts | 2 +- packages/ui/acp/src/index.ts | 3 +- packages/ui/acp/tests/dispose.spec.ts | 3 +- packages/ui/acp/tests/multi-session.spec.ts | 2 +- website/zh-CN/api/harness/events.md | 12 ++--- 18 files changed, 102 insertions(+), 43 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index ab47d77028..9760e026a2 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.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 -2026-07-17-one-send-one-turn.md: 41c2eff49e45e649fd773f02656b799bae8dbaf1 -2026-07-17-one-send-one-turn.zh.md: 69b091aca5c8aeb71b8312b8b4f888fd3742c610 +2026-07-17-one-send-one-turn.md: 9534574e767d319427b6750b87ac391ed593164d +2026-07-17-one-send-one-turn.zh.md: e3cd3c9f6c8951b1f1124a139286ba5e6f3b7fbd diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md index 41c2eff49e..9534574e76 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -8,13 +8,13 @@ English | [中文](2026-07-17-one-send-one-turn.zh.md) An ordinary `Agent.send()` payload is one complete caller message. Opportunistically draining every waiting payload into one turn would make adjacent calls share a boundary according to driver timing: calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks could be grouped differently even though callers used the same API. -A turn owns prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later message join an earlier message's model request instead of observing the earlier turn's closed result in the same session log, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. +An ordinary turn owns prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later ordinary message join an earlier message's model request instead of observing the earlier ordinary turn's closed result in the same session log, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. -`steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit batching would make `send()` overlap both explicit operations instead of preserving a single meaning. +`steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit ordinary-send batching would make `send()` overlap both explicit operations instead of preserving a single meaning. ## Decision -Each successful `send()` synchronously validates agent state, snapshots and freezes content, appends one independent FIFO item, and publishes `agent/queued`. The loop dequeues at most one ordinary item for each turn start. If two items are both claimed, the second turn starts only after the first turn ends and its durability checkpoint settles; broad cancellation, disposal, or a pre-start failure can discard an unstarted item without creating an empty turn. +Each successful `send()` synchronously validates agent state, snapshots and freezes content, appends one independent FIFO item, and publishes `agent/queued`. The loop dequeues at most one ordinary item for each turn start. If two ordinary items are both claimed, the second ordinary turn starts only after the first ordinary turn ends and its durability checkpoint settles; broad cancellation, disposal, or a pre-start failure can discard an unstarted item without creating an empty turn. Prompt admission decides one message. An allowed prompt becomes that turn's `user/message`; a blocked prompt appends one durable `prompt/blocked` and ends that one-message turn as `rejected`. There are no mixed-batch or all-blocked-batch branches. @@ -22,18 +22,18 @@ Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` del ## Alternatives considered -**Keep opportunistic batching for throughput.** Combining queued prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and lets a later message run before the preceding turn closes and its checkpoint settles. Explicit lifecycle semantics are worth the additional model calls; any future batching feature needs an explicit caller-visible contract justified by measurements. +**Keep opportunistic ordinary-send batching for throughput.** Combining queued ordinary prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and lets a later ordinary message run before the preceding ordinary turn closes and its checkpoint settles. Explicit lifecycle semantics are worth the additional model calls; any future ordinary-send batching feature needs an explicit caller-visible contract justified by measurements. ## Verification - Unit and property coverage pins same-stack, neighboring-microtask, differently sourced, and reentrant sends as one FIFO-ordered message per turn. - A real-composition test pipes two lines through the built stdio binary and observes two model requests and two turn boundaries. -- A deferred first-turn flush proves the next queued turn cannot start before the checkpoint settles and that its request sees the preceding assistant result; a rejected flush still settles before the next turn starts. +- A deferred first ordinary-turn flush proves the next queued ordinary turn cannot start before the checkpoint settles and that its request sees the preceding assistant result; a rejected flush still settles before the next ordinary turn starts. - Prompt veto and listener failure, broad cancellation, disposal, and pre-commit `turn/start` failure preserve balanced recorded turns and do not merge or strand surviving queued work. - Running and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their existing coverage. ## Consequences -Ordinary turn boundaries are deterministic, and a claimed FIFO successor observes the preceding turn's closed session result after its checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. +Ordinary turn boundaries are deterministic, and a claimed FIFO successor observes the preceding claimed ordinary turn's closed session result after that turn's checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. -Workloads that relied on coincidental batching make more model requests, incur more checkpoints, and may take longer to drain; FIFO queues may grow under sustained producers. Throughput optimization can return only through an explicit measured contract. +Workloads that relied on coincidental ordinary-send batching make more model requests, incur more checkpoints, and may take longer to drain; FIFO queues may grow under sustained producers. Ordinary-send batching can return only through an explicit measured contract. diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index 69b091aca5..e3cd3c9f6c 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -8,32 +8,32 @@ Status: implemented 每次普通 `Agent.send()` 接受的载荷都是一条完整的调用方消息。如果机会式地把所有待处理载荷放入同一个轮次,相邻调用是否共享边界就会取决于驱动器的运行时机:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件监听器和模型回调的调用也可能产生不同分组。 -轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条消息加入前一条消息的模型请求,无法观察同一会话日志中前一个已关闭轮次的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 +普通轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条普通消息加入前一条普通消息的模型请求,无法观察同一会话日志中前一个已关闭普通轮次的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 -`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 +`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。普通 `send()` 的隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 ## 决策 -每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop 在每个轮次开始时最多取出一个普通队列项。如果两个队列项最终都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 +每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop(智能体循环)在每个轮次开始时最多取出一个普通队列项。如果两个普通队列项最终都被认领,第二个普通轮次只能在第一个普通轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有混合批次或全阻止批次分支。 -运行中的 `steer()` 会把消息追加到当前轮次的 steering(中途引导) FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 +运行中的 `steer()` 会把消息追加到当前轮次的 steering(中途引导)FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 ## 曾考虑的替代方案 -**为吞吐量保留机会式批处理。** 当消息进入队列的速度超过驱动器的处理速度时,合并排队的提示词可以减少模型调用,但会让轮次边界取决于调度,并让后一条消息在前一轮次关闭且其检查点处理结束之前就运行。额外模型调用的代价低于显式生命周期语义的价值;未来的任何批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 +**为吞吐量保留普通 `send()` 的机会式批处理。** 当消息进入队列的速度超过驱动器的处理速度时,合并排队的普通提示词可以减少模型调用,但会让轮次边界取决于调度,并让后一条普通消息在前一个普通轮次关闭且其检查点处理结束之前就运行。额外模型调用的代价低于显式生命周期语义的价值;未来的任何普通 `send()` 批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 ## 验证 - 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 `send()` 的行为:每个轮次只有一条消息,并按 FIFO 排序。 - 真实组合测试会通过 stdio 构建产物同时写入两行,并观察两个模型请求和两个轮次边界。 -- 延迟第一个轮次的持久化刷新可以证明下一个排队轮次不能在检查点处理结束前开始,且其请求能看到前一条助手结果;刷新即使失败,下一轮次也要等它结束后才会开始。 +- 延迟第一个普通轮次的持久化刷新可以证明下一个排队的普通轮次不能在检查点处理结束前开始,且其请求能看到前一条助手结果;刷新即使失败,下一个普通轮次也要等它结束后才会开始。 - 提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 提交前失败都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 - 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的状态和 `whenIdle()` 保持原有覆盖。 ## 后果 -普通轮次边界是确定的,被认领的 FIFO 后继项会在前一轮次关闭且其检查点处理结束后观察会话中的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 +普通轮次边界是确定的;前一个已认领普通消息的轮次完成检查点处理后,被认领的 FIFO 后继项会观察该轮次在会话中已关闭的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 -依赖偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。 +依赖普通 `send()` 偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入普通 `send()` 批处理。 diff --git a/docs/architecture.md b/docs/architecture.md index 2bbd9e8388..d920117fb1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,7 +56,7 @@ Waterfall events behave like around-middleware: a listener delegates by calling The shipped loop drains prompt-to-checkpoint work through plugin-visible services and events. -A **session** is an append-only log. Each ordinary **turn** claims one queued `send()` item; injection claims none. A claimed `send()` successor awaits the prior turn's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model and plugins stop it. A **step** is one model request plus tools. Below ([sequence companion](agent-lifecycle.md)), quotes mark durable events; other names are extension points. +A **session** is an append-only log. Each ordinary **turn** claims one queued `send()` item; injection claims none. A claimed `send()` successor awaits the preceding claimed ordinary turn's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model and plugins stop it. A **step** is one model request plus tools. Below ([sequence companion](agent-lifecycle.md)), quotes mark durable events; other names are extension points. Startup resolves identity. No id mints `-session-`; `sessionId` resumes or creates; `resumeSessionId` requires history. Active failures emit `agent-loop/config-start-failed(sessionId, error)`, so front doors reject work; teardown stays silent. diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 87f9e1fdd8..6d73230a4b 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -125,14 +125,14 @@ Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/t ### `agent/prompt-submit` — waterfall -Allow, rewrite, or block one drained prompt before it becomes a user message. Call `next()` for the unchanged default. +Allow, rewrite, or block one claimed prompt before it becomes a user message. Call `next()` for the unchanged default. ```ts cordis-catalog /** - * Allow, rewrite, or block one drained prompt before it becomes a user + * Allow, rewrite, or block one claimed prompt before it becomes a user * message. Call `next()` for the unchanged default. - * @param agent - the agent draining its inbox. - * @param content - the drained message's blocks, as queued. + * @param agent - the agent whose turn claimed the message. + * @param content - the claimed message's blocks, as queued. * @param source - the message's resolved source. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 89a8972b98..8e2eaac1a3 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -43,7 +43,7 @@ interface SessionEventMap { 'step/start': { turn: number; step: number } /** Closes step `step` of turn `turn`. */ 'step/end': { turn: number; step: number } - /** A user-visible prompt (queued message drained at turn start). */ + /** A user-visible prompt (the queued message claimed for this turn). */ 'user/message': { content: ContentBlock[]; source: MessageSource } /** * Durable record of a prompt veto and its reason. It is log-only: the blocked diff --git a/docs/i18n/style-samples.md b/docs/i18n/style-samples.md index 2d9de63bc6..dd970b7c12 100644 --- a/docs/i18n/style-samples.md +++ b/docs/i18n/style-samples.md @@ -28,9 +28,9 @@ **dispose(资源释放)必须等待所有任务完全停稳,不能仅下发终止指令就返回**:如果清理过程只发出终止或中断信号,却不等任务停止就返回,就会留下孤儿进程。清理应采用异步方式,等待所有子任务彻底退出(先发出终止信号,再等待退出);发出信号前应先关闭监听器与通知注册表,使延迟到达的完成事件不再触发通知。测试要证明 dispose 的确等到清理完成:执行完 `await fiber.dispose()` 后进程 PID 立即消失,不能只检查进程最终会自行消亡。 -> **Async state is not synchronous state** — `agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) rather than counting actions you assume map 1:1 to turns. +> **Async state is not synchronous state** — `agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-send result: several queued sends run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. -**异步状态不等同于同步瞬时状态**:调用 `agent.send()` 不会在返回前同步更新状态;后台任务的完成时间与轮次边界存在竞态;`reader.close()` 既会在读到文件末尾时触发,也会在资源释放时触发。切勿把刚刚发起的状态变更当成已经生效,据此控制流程;生命周期逻辑应以实际触发的事件和已完成的 promise(`agent/status`、`task.done`)为准,并观察完整的状态变化(先 `running`,再 `idle`),不要根据操作次数推断操作与轮次一一对应。 +**异步状态不等同于同步瞬时状态**:调用 `agent.send()` 不会在返回前同步更新状态;后台任务的完成时间与轮次边界存在竞态;`reader.close()` 既会在读到文件末尾时触发,也会在资源释放时触发。切勿把刚刚发起的状态变更当成已经生效,据此控制流程;生命周期逻辑应以实际触发的事件和已完成的 promise(`agent/status`、`task.done`)为准,并观察完整的状态变化(先 `running`,再 `idle`),不要把状态当作逐次 `send()` 的结果:多次排队的 `send()` 会作为连续轮次运行,但可能共用一个 `running` 区间;取消或资源释放还可能丢弃尚未启动的队列项。 ## ③ 测试政策清单 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 82859e2eb0..666a77e112 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -498,7 +498,7 @@ Source: [`packages/core/session/src/types.ts:187`](../packages/core/session/src/ #### `user/message` — surface ```ts persistence-catalog -/** A user-visible prompt (queued message drained at turn start). */ +/** A user-visible prompt (the queued message claimed for this turn). */ 'user/message': { content: ContentBlock[]; source: MessageSource } ``` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index c0c29331ae..d1ea28850f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -652,8 +652,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/prompt-submit', mode: 'waterfall', signature: '\'agent/prompt-submit\'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise', - jsDoc: '/**\n * Allow, rewrite, or block one drained prompt before it becomes a user\n * message. Call `next()` for the unchanged default.\n * @param agent - the agent draining its inbox.\n * @param content - the drained message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', - summary: 'Allow, rewrite, or block one drained prompt before it becomes a user message.', + jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message. Call `next()` for the unchanged default.\n * @param agent - the agent whose turn claimed the message.\n * @param content - the claimed message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + summary: 'Allow, rewrite, or block one claimed prompt before it becomes a user message.', }, { name: 'agent/queued', diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 61b661c082..4161cad92b 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -398,7 +398,7 @@ export class ReactLoopAgent implements Agent { cancelReason: () => this.cancelReason, clearCancel: () => { this.cancelRequested = false }, withToolBatch: run => this.withToolBatch(run), - // Pre-step cancellation re-parks without emitting a status transition. + // Already-idle pre-start cancellation still must settle queued-work waiters. settleIdle: () => { this.settleIdleWaiters() }, })) } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 0024623e8d..66d4a65d69 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -91,7 +91,7 @@ export interface LoopHandle { cancelReason(): string /** Clear the cancel marker (called once per iteration after the turn returns). */ clearCancel(): void - /** Settle idle waiters when pre-running cancellation skips a turn, without emitting `agent/status`. */ + /** Settle idle waiters when pre-running cancellation finds the status already idle. */ settleIdle(): void /** Run an active tool-call batch, accepting post-tool context into the FIFO drained before settlement. */ readonly withToolBatch: (run: (acceptContext: (context: HookContext) => void) => Promise) => Promise @@ -126,6 +126,9 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { if (handle.isCancelled()) { handle.clearCancel() if (!handle.inbox.hasQueued) { + // setStatus settles running→idle; the explicit settle covers the + // already-idle pre-start path where that transition is deduplicated. + handle.setStatus('idle') handle.settleIdle() continue } diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 634421ed09..c582b4c943 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -140,6 +140,60 @@ describe('Agent.cancel()', () => { expect(agent.status).toBe('idle') }) + it('cancel() between consecutive turns restores idle and leaves idle steer usable', async () => { + const adapter = new MockAdapter([textResponse('first reply'), textResponse('steer reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('between-turn-cancel'), { provider: 'mock', model: 'mock' }) + + let rejectFirstFlush = true + ctx.on('session/flush', (session) => { + if (session !== agent.session || !rejectFirstFlush) return + rejectFirstFlush = false + throw new Error('first flush failed') + }) + + const cancelled = Promise.withResolvers() + ctx.on('agent/error', (subject, _turn, _step, error) => { + if (subject !== agent || error.message !== 'first flush failed') return + // The first hop runs before runLoop resumes from runTurn; the second lands + // before its resolved waitForQueued continuation checks cancellation. + queueMicrotask(() => { + queueMicrotask(() => { + agent.cancel('between turns') + cancelled.resolve(undefined) + }) + }) + }) + + const statuses: string[] = [] + ctx.on('agent/status', (subject, status) => { + if (subject === agent) statuses.push(status) + }) + + send(agent, 'first') + send(agent, 'queued tail') + await cancelled.promise + + expect(agent.status).toBe('idle') + expect(statuses).toEqual(['running', 'idle']) + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(userTexts(agent)).toEqual(['first']) + + let idleResolved = false + void agent.whenIdle().then(() => { idleResolved = true }) + await Promise.resolve() + expect(idleResolved).toBe(true) + + const idle = waitForIdle(ctx, agent) + agent.steer([{ type: 'text', text: 'idle steer' }]) + await idle + + expect(statuses).toEqual(['running', 'idle', 'running', 'idle']) + expect(adapter.requests).toHaveLength(2) + expect(userTexts(agent)).toEqual(['first', 'idle steer']) + }) + it('cancel() mid-step aborts the active turn and drops every queued tail item', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index f9bf8f2825..37481dbc1d 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -207,10 +207,10 @@ declare module 'cordis' { */ 'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void /** - * Allow, rewrite, or block one drained prompt before it becomes a user + * Allow, rewrite, or block one claimed prompt before it becomes a user * message. Call `next()` for the unchanged default. - * @param agent - the agent draining its inbox. - * @param content - the drained message's blocks, as queued. + * @param agent - the agent whose turn claimed the message. + * @param content - the claimed message's blocks, as queued. * @param source - the message's resolved source. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index df73f3fb50..3733877258 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -196,7 +196,7 @@ export interface SessionEventMap { 'step/start': { turn: number; step: number } /** Closes step `step` of turn `turn`. */ 'step/end': { turn: number; step: number } - /** A user-visible prompt (queued message drained at turn start). */ + /** A user-visible prompt (the queued message claimed for this turn). */ 'user/message': { content: ContentBlock[]; source: MessageSource } /** * Durable record of a prompt veto and its reason. It is log-only: the blocked diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index efcc4bb18d..ca7510048b 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -830,7 +830,8 @@ export function apply(ctx: Context, config: AcpConfig): void { // not-yet-started prompt never runs, while a prompt accepted afterward // remains a separate queued turn. Scoped to THIS session's // agent — a cancel in one session never touches another's stream or - // pending prompt (RFC 011 isolation). We ALSO settle the in-flight prompt + // pending prompt (multi-session isolation). + // We ALSO settle the in-flight prompt // as cancelled directly here: do NOT rely on the resulting turn/end to // settle it, because cancel() may drop the turn before any turn/end is // emitted, and removing this direct settle would move the RPC's diff --git a/packages/ui/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts index 5cbdb6859b..8baf076d58 100644 --- a/packages/ui/acp/tests/dispose.spec.ts +++ b/packages/ui/acp/tests/dispose.spec.ts @@ -223,7 +223,8 @@ describe('acp bridge — disposal & HMR safety', () => { it('per-session AgentHandle dispose leaves sibling agents untouched', async () => { // The factory returns a per-agent AgentHandle whose dispose() tears down - // EXACTLY that agent + its session — RFC 011 isolation. Create two agents + // EXACTLY that agent + its session — the registry's per-handle isolation + // contract. Create two agents // directly through the registry factory (the same path the ACP bridge uses), // dispose one handle, and assert the other survives, registered and // queryable, with its session still in the store. diff --git a/packages/ui/acp/tests/multi-session.spec.ts b/packages/ui/acp/tests/multi-session.spec.ts index 0881fe4199..efeb00f9ad 100644 --- a/packages/ui/acp/tests/multi-session.spec.ts +++ b/packages/ui/acp/tests/multi-session.spec.ts @@ -14,7 +14,7 @@ function messageTextFor(updates: { sessionId?: string; update: CapturedUpdate }[ .join('') } -describe('acp bridge — RFC 011 multi-session isolation', () => { +describe('acp bridge — multi-session isolation', () => { let storageDir: string let harness: BridgeHarness | undefined diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 0f5f1132ba..01313a79df 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -141,10 +141,10 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending ```ts website-api /** - * Allow, rewrite, or block one drained prompt before it becomes a user + * Allow, rewrite, or block one claimed prompt before it becomes a user * message. Call `next()` for the unchanged default. - * @param agent - the agent draining its inbox. - * @param content - the drained message's blocks, as queued. + * @param agent - the agent whose turn claimed the message. + * @param content - the claimed message's blocks, as queued. * @param source - the message's resolved source. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall @@ -152,10 +152,10 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending 'agent/prompt-submit'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise ``` -Allow, rewrite, or block one drained prompt before it becomes a user message. Call `next()` for the unchanged default. +Allow, rewrite, or block one claimed prompt before it becomes a user message. Call `next()` for the unchanged default. -- `agent` — the agent draining its inbox. -- `content` — the drained message's blocks, as queued. +- `agent` — the agent whose turn claimed the message. +- `content` — the claimed message's blocks, as queued. - `source` — the message's resolved source. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. [Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L218) From da70e4704856542ab7b2fab0f51dde07e35a1743 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 13:15:11 +0800 Subject: [PATCH 09/13] review fix: preserve idle waiter quiescence --- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 +- .../2026-07-17-one-send-one-turn.md | 6 +- .../2026-07-17-one-send-one-turn.zh.md | 14 ++-- packages/core/agent-loop/src/agent.ts | 2 +- packages/core/agent-loop/src/loop.ts | 19 ++++- packages/core/agent-loop/tests/cancel.spec.ts | 76 +++++++++++++++++++ .../agent-loop/tests/interception.spec.ts | 2 +- .../core/agent-loop/tests/properties.spec.ts | 4 +- 8 files changed, 107 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index 9760e026a2..f66fa4f240 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.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 -2026-07-17-one-send-one-turn.md: 9534574e767d319427b6750b87ac391ed593164d -2026-07-17-one-send-one-turn.zh.md: e3cd3c9f6c8951b1f1124a139286ba5e6f3b7fbd +2026-07-17-one-send-one-turn.md: 852a2f24d33d88933568fe1d1d937e10003202df +2026-07-17-one-send-one-turn.zh.md: f40086ee202bf74e4207081a402737d86e9dfa87 diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md index 9534574e76..852a2f24d3 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -8,13 +8,13 @@ English | [中文](2026-07-17-one-send-one-turn.zh.md) An ordinary `Agent.send()` payload is one complete caller message. Opportunistically draining every waiting payload into one turn would make adjacent calls share a boundary according to driver timing: calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks could be grouped differently even though callers used the same API. -An ordinary turn owns prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later ordinary message join an earlier message's model request instead of observing the earlier ordinary turn's closed result in the same session log, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. +An ordinary turn contains prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later ordinary message join an earlier message's model request instead of observing the earlier ordinary turn's closed result in the same session log, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. `steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit ordinary-send batching would make `send()` overlap both explicit operations instead of preserving a single meaning. ## Decision -Each successful `send()` synchronously validates agent state, snapshots and freezes content, appends one independent FIFO item, and publishes `agent/queued`. The loop dequeues at most one ordinary item for each turn start. If two ordinary items are both claimed, the second ordinary turn starts only after the first ordinary turn ends and its durability checkpoint settles; broad cancellation, disposal, or a pre-start failure can discard an unstarted item without creating an empty turn. +Each successful `send()` synchronously validates agent state, snapshots and freezes content, appends one independent FIFO item, and publishes `agent/queued`. The loop dequeues at most one ordinary item for each turn start. If two ordinary items both reach turn processing, the second ordinary turn starts only after the first ordinary turn ends and its durability checkpoint settles; broad cancellation, disposal, or a pre-start failure can discard an unstarted item without creating an empty turn. Prompt admission decides one message. An allowed prompt becomes that turn's `user/message`; a blocked prompt appends one durable `prompt/blocked` and ends that one-message turn as `rejected`. There are no mixed-batch or all-blocked-batch branches. @@ -34,6 +34,6 @@ Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` del ## Consequences -Ordinary turn boundaries are deterministic, and a claimed FIFO successor observes the preceding claimed ordinary turn's closed session result after that turn's checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. +Ordinary turn boundaries are deterministic, and a FIFO successor that reaches turn processing observes the preceding completed ordinary turn's closed session result after that turn's checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. Workloads that relied on coincidental ordinary-send batching make more model requests, incur more checkpoints, and may take longer to drain; FIFO queues may grow under sustained producers. Ordinary-send batching can return only through an explicit measured contract. diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index e3cd3c9f6c..f40086ee20 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -8,13 +8,13 @@ Status: implemented 每次普通 `Agent.send()` 接受的载荷都是一条完整的调用方消息。如果机会式地把所有待处理载荷放入同一个轮次,相邻调用是否共享边界就会取决于驱动器的运行时机:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件监听器和模型回调的调用也可能产生不同分组。 -普通轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条普通消息加入前一条普通消息的模型请求,无法观察同一会话日志中前一个已关闭普通轮次的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 +普通轮次包含提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条普通消息加入前一条普通消息的模型请求,无法观察同一会话日志中前一个已关闭普通轮次的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 -`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。普通 `send()` 的隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 +`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。普通 send 的隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 ## 决策 -每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop(智能体循环)在每个轮次开始时最多取出一个普通队列项。如果两个普通队列项最终都被认领,第二个普通轮次只能在第一个普通轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 +每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop(智能体循环)在每个轮次开始时最多取出一个普通队列项。如果两个普通队列项最终都进入轮次处理,第二个普通轮次只能在第一个普通轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有混合批次或全阻止批次分支。 @@ -22,11 +22,11 @@ Status: implemented ## 曾考虑的替代方案 -**为吞吐量保留普通 `send()` 的机会式批处理。** 当消息进入队列的速度超过驱动器的处理速度时,合并排队的普通提示词可以减少模型调用,但会让轮次边界取决于调度,并让后一条普通消息在前一个普通轮次关闭且其检查点处理结束之前就运行。额外模型调用的代价低于显式生命周期语义的价值;未来的任何普通 `send()` 批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 +**为吞吐量保留普通 send 的机会式批处理。** 当消息进入队列的速度超过驱动器的处理速度时,合并排队的普通提示词可以减少模型调用,但会让轮次边界取决于调度,并让后一条普通消息在前一个普通轮次关闭且其检查点处理结束之前就运行。额外模型调用的代价低于显式生命周期语义的价值;未来的任何普通 send 批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 ## 验证 -- 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 `send()` 的行为:每个轮次只有一条消息,并按 FIFO 排序。 +- 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 send 的行为:每个轮次只有一条消息,并按 FIFO 排序。 - 真实组合测试会通过 stdio 构建产物同时写入两行,并观察两个模型请求和两个轮次边界。 - 延迟第一个普通轮次的持久化刷新可以证明下一个排队的普通轮次不能在检查点处理结束前开始,且其请求能看到前一条助手结果;刷新即使失败,下一个普通轮次也要等它结束后才会开始。 - 提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 提交前失败都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 @@ -34,6 +34,6 @@ Status: implemented ## 后果 -普通轮次边界是确定的;前一个已认领普通消息的轮次完成检查点处理后,被认领的 FIFO 后继项会观察该轮次在会话中已关闭的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 +普通轮次边界是确定的;FIFO 后继项进入轮次处理时,会观察前一个已完成普通轮次在会话中已关闭的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 -依赖普通 `send()` 偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入普通 `send()` 批处理。 +依赖普通 send 偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入普通 send 批处理。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 4161cad92b..ba991311ae 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -398,7 +398,7 @@ export class ReactLoopAgent implements Agent { cancelReason: () => this.cancelReason, clearCancel: () => { this.cancelRequested = false }, withToolBatch: run => this.withToolBatch(run), - // Already-idle pre-start cancellation still must settle queued-work waiters. + // Pre-start cancellation settles queued-work waiters before publishing idle. settleIdle: () => { this.settleIdleWaiters() }, })) } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 66d4a65d69..5e8168d895 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -91,7 +91,7 @@ export interface LoopHandle { cancelReason(): string /** Clear the cancel marker (called once per iteration after the turn returns). */ clearCancel(): void - /** Settle idle waiters when pre-running cancellation finds the status already idle. */ + /** Settle idle waiters before pre-running cancellation publishes idle. */ settleIdle(): void /** Run an active tool-call batch, accepting post-tool context into the FIFO drained before settlement. */ readonly withToolBatch: (run: (acceptContext: (context: HookContext) => void) => Promise) => Promise @@ -118,6 +118,17 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { const events = agentEvents(ctx, agent) while (!handle.isDisposed()) { + // An idle listener can enqueue and cancel replacement work before the next + // wait is installed. Consume that empty marker before parking the driver. + if (handle.isCancelled()) { + handle.clearCancel() + if (!handle.inbox.hasQueued) { + handle.settleIdle() + handle.setStatus('idle') + continue + } + } + await handle.inbox.waitForQueued(handle.disposed) if (handle.isDisposed()) break @@ -126,10 +137,10 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { if (handle.isCancelled()) { handle.clearCancel() if (!handle.inbox.hasQueued) { - // setStatus settles running→idle; the explicit settle covers the - // already-idle pre-start path where that transition is deduplicated. - handle.setStatus('idle') + // Settle before publishing idle: the already-idle path has no status + // transition, while an idle listener can register waiters for new work. handle.settleIdle() + handle.setStatus('idle') continue } } diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index c582b4c943..c6d84dfbf7 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -194,6 +194,82 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toEqual(['first', 'idle steer']) }) + it('an idle-listener replacement keeps whenIdle pending until the replacement turn finishes', async () => { + const adapter = new MockAdapter([textResponse('first reply'), textResponse('replacement reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('between-turn-idle-listener'), { provider: 'mock', model: 'mock' }) + + let rejectFirstFlush = true + ctx.on('session/flush', (session) => { + if (session !== agent.session || !rejectFirstFlush) return + rejectFirstFlush = false + throw new Error('first flush failed') + }) + + ctx.on('agent/error', (subject, _turn, _step, error) => { + if (subject !== agent || error.message !== 'first flush failed') return + queueMicrotask(() => { + queueMicrotask(() => { agent.cancel('between turns') }) + }) + }) + + const replacementRegistered = Promise.withResolvers() + let replacementObservation: Promise<{ status: string; requests: number; turns: number }> | undefined + ctx.on('agent/status', (subject, status) => { + if (subject !== agent || status !== 'idle' || replacementObservation !== undefined) return + send(agent, 'replacement') + replacementObservation = agent.whenIdle().then(() => ({ + status: agent.status, + requests: adapter.requests.length, + turns: agent.session.events.filter(event => event.type === 'turn/start').length, + })) + replacementRegistered.resolve(undefined) + }) + + send(agent, 'first') + send(agent, 'cancelled tail') + await replacementRegistered.promise + if (replacementObservation === undefined) throw new Error('idle listener did not register replacement work') + + await expect(replacementObservation).resolves.toEqual({ status: 'idle', requests: 2, turns: 2 }) + expect(userTexts(agent)).toEqual(['first', 'replacement']) + }) + + it('idle-listener cancellation settles its waiter without cancelling later work', async () => { + const adapter = new MockAdapter([textResponse('first reply'), textResponse('later reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('idle-listener-cancel'), { provider: 'mock', model: 'mock' }) + + const replacementRegistered = Promise.withResolvers() + let replacementObservation: Promise<{ status: string; requests: number; turns: number }> | undefined + ctx.on('agent/status', (subject, status) => { + if (subject !== agent || status !== 'idle' || replacementObservation !== undefined) return + send(agent, 'cancelled replacement') + replacementObservation = agent.whenIdle().then(() => ({ + status: agent.status, + requests: adapter.requests.length, + turns: agent.session.events.filter(event => event.type === 'turn/start').length, + })) + agent.cancel('idle listener') + replacementRegistered.resolve(undefined) + }) + + send(agent, 'first') + await replacementRegistered.promise + if (replacementObservation === undefined) throw new Error('idle listener did not register replacement work') + + await expect(Promise.race([ + replacementObservation, + new Promise((_resolve, reject) => setTimeout(() => { reject(new Error('whenIdle hung after idle-listener cancel')) }, 1000)), + ])).resolves.toEqual({ status: 'idle', requests: 1, turns: 1 }) + + const idle = waitForIdle(ctx, agent) + send(agent, 'later') + await idle + expect(adapter.requests).toHaveLength(2) + expect(userTexts(agent)).toEqual(['first', 'later']) + }) + it('cancel() mid-step aborts the active turn and drops every queued tail item', async () => { const adapter = new MockAdapter(['hang']) 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 7142abd9d6..40981a7301 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -241,7 +241,7 @@ describe('agent/prompt-submit', () => { send(agent, 'second') await idle expect(errors.map(e => e.message)).toEqual(['prompt hook broke']) - // The failed prompt owns one balanced error turn; the adjacent prompt owns + // The failed prompt forms one balanced error turn; the adjacent prompt forms // the following normal turn without an intermediate idle transition. const log = events(agent) expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 9412ffa452..85efda0e4e 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -120,7 +120,7 @@ describe('agent loop scheduling properties', () => { // No message lost: every send appears as a user/message, in order. expect(userMessageTexts(agent)).toEqual(texts) - // This failure-free fixture claims every item into an independent turn. + // This failure-free fixture maps every item to an independent turn. expect(turnNumbers(agent)).toEqual(texts.map((_, i) => i + 1)) expect(turnEndNumbers(agent)).toEqual(texts.map((_, i) => i + 1)) expect(userMessageCountsByTurn(agent)).toEqual(texts.map(() => 1)) @@ -178,7 +178,7 @@ describe('agent loop scheduling properties', () => { // No message is lost or reordered, regardless of driver timing. expect(userMessageTexts(agent)).toEqual(steps.map(s => s.text)) - // Every item is claimed and therefore owns one FIFO-ordered turn. + // Every item forms one FIFO-ordered turn containing only that message. const turns = turnNumbers(agent) expect(turns).toEqual(steps.map((_, i) => i + 1)) expect(turnEndNumbers(agent)).toEqual(turns) From 7d7d69e22512b10322659c247ea6a2069f974eca Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 13:20:51 +0800 Subject: [PATCH 10/13] review fix: cover post-cancel replacement --- packages/core/agent-loop/src/loop.ts | 2 +- packages/core/agent-loop/tests/cancel.spec.ts | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 5e8168d895..d2a27394dc 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -133,7 +133,7 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { if (handle.isDisposed()) break // Cancellation between wake and `running` skips only the cancelled work; - // a replacement prompt still runs and owns the eventual idle transition. + // a replacement prompt still runs before the eventual idle transition. if (handle.isCancelled()) { handle.clearCancel() if (!handle.inbox.hasQueued) { diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index c6d84dfbf7..39289779be 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -270,6 +270,31 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toEqual(['first', 'later']) }) + it('replacement work queued after idle-listener cancellation still runs', async () => { + const adapter = new MockAdapter([textResponse('first reply'), textResponse('replacement reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('idle-listener-post-cancel-send'), { provider: 'mock', model: 'mock' }) + + const replacementRegistered = Promise.withResolvers() + let replacementIdle: Promise | undefined + ctx.on('agent/status', (subject, status) => { + if (subject !== agent || status !== 'idle' || replacementIdle !== undefined) return + send(agent, 'cancelled replacement') + agent.cancel('idle listener') + send(agent, 'surviving replacement') + replacementIdle = agent.whenIdle() + replacementRegistered.resolve(undefined) + }) + + send(agent, 'first') + await replacementRegistered.promise + if (replacementIdle === undefined) throw new Error('idle listener did not register replacement work') + await replacementIdle + + expect(adapter.requests).toHaveLength(2) + expect(userTexts(agent)).toEqual(['first', 'surviving replacement']) + }) + it('cancel() mid-step aborts the active turn and drops every queued tail item', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) From c16e1078f58a5576b3b95126fade6b1eee11bafd Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 14:42:55 +0800 Subject: [PATCH 11/13] docs(agent): clarify late steering timing --- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 +-- .../2026-07-17-one-send-one-turn.md | 8 ++--- .../2026-07-17-one-send-one-turn.zh.md | 8 ++--- docs/cordis-catalog/events.md | 30 +++++++++---------- docs/core-data-structures/core.md | 11 ++++--- docs/event-producer-consumer.md | 30 +++++++++---------- packages/core/agent-loop/README.md | 2 +- packages/core/agent/README.md | 4 ++- packages/core/agent/src/types.ts | 15 ++++++---- website/zh-CN/api/harness/events.md | 30 +++++++++---------- 10 files changed, 75 insertions(+), 67 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index f66fa4f240..46ee3998c3 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.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 -2026-07-17-one-send-one-turn.md: 852a2f24d33d88933568fe1d1d937e10003202df -2026-07-17-one-send-one-turn.zh.md: f40086ee202bf74e4207081a402737d86e9dfa87 +2026-07-17-one-send-one-turn.md: 7eec39e1e5b4678c9d454260f326929f4beff6c9 +2026-07-17-one-send-one-turn.zh.md: f28c93929045b3fcb0f2df8ff3c33adfb8a308ae diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md index 852a2f24d3..7eec39e1e5 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -10,7 +10,7 @@ An ordinary `Agent.send()` payload is one complete caller message. Opportunistic An ordinary turn contains prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later ordinary message join an earlier message's model request instead of observing the earlier ordinary turn's closed result in the same session log, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. -`steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit ordinary-send batching would make `send()` overlap both explicit operations instead of preserving a single meaning. +`steer()` already selects steering semantics while the agent driver is running, while `inject()` records model-facing context without acting as an ordinary message. Implicit ordinary-send batching would make `send()` overlap both explicit operations instead of preserving a single meaning. ## Decision @@ -18,7 +18,7 @@ Each successful `send()` synchronously validates agent state, snapshots and free Prompt admission decides one message. An allowed prompt becomes that turn's `user/message`; a blocked prompt appends one durable `prompt/blocked` and ends that one-message turn as `rejected`. There are no mixed-batch or all-blocked-batch branches. -Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` delegates to `send()` and therefore creates an independent ordinary queue item. `inject()` retains its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` remain whole-agent operations rather than per-message controls. +Running `steer()` adds to the steering FIFO. An open turn records it at the next steering checkpoint before a request or continuation decision. Steering can make continuation default to another step, but continuation or terminal policy can still stop before that step begins. After turn close and its durability checkpoint, remaining steering becomes later queued input. Terminal `agent/turn-stop`, cancellation, or disposal may discard it. Idle `steer()` delegates to `send()` and therefore creates an independent ordinary queue item. `inject()` retains its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` remain whole-agent operations rather than per-message controls. ## Alternatives considered @@ -30,10 +30,10 @@ Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` del - A real-composition test pipes two lines through the built stdio binary and observes two model requests and two turn boundaries. - A deferred first ordinary-turn flush proves the next queued ordinary turn cannot start before the checkpoint settles and that its request sees the preceding assistant result; a rejected flush still settles before the next ordinary turn starts. - Prompt veto and listener failure, broad cancellation, disposal, and pre-commit `turn/start` failure preserve balanced recorded turns and do not merge or strand surviving queued work. -- Running and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their existing coverage. +- Open-turn, post-turn-close, and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their existing coverage. ## Consequences -Ordinary turn boundaries are deterministic, and a FIFO successor that reaches turn processing observes the preceding completed ordinary turn's closed session result after that turn's checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. +Ordinary turn boundaries are deterministic, and a FIFO successor that reaches turn processing observes the preceding completed ordinary turn's closed session result after that turn's checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, which can also cover turn close and its checkpoint, so `running` does not prove a turn is open. Broad cancellation can discard the entire unstarted tail, and status and quiescence remain agent-wide observations rather than per-message results. Workloads that relied on coincidental ordinary-send batching make more model requests, incur more checkpoints, and may take longer to drain; FIFO queues may grow under sustained producers. Ordinary-send batching can return only through an explicit measured contract. diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index f40086ee20..f28c939290 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -10,7 +10,7 @@ Status: implemented 普通轮次包含提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条普通消息加入前一条普通消息的模型请求,无法观察同一会话日志中前一个已关闭普通轮次的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 -`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。普通 send 的隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 +`steer()` 已经用于在 agent(智能体)驱动器运行时选择 steering(中途引导)语义,`inject()` 则记录面向模型的上下文而不充当普通消息。普通 send 的隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 ## 决策 @@ -18,7 +18,7 @@ Status: implemented 提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有混合批次或全阻止批次分支。 -运行中的 `steer()` 会把消息追加到当前轮次的 steering(中途引导)FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 +运行中的 `steer()` 会把消息加入 steering(中途引导)FIFO。打开的轮次会在下一个 steering 检查点、请求或 continuation 决策之前记录该消息。Steering 可以让默认 continuation 决策进入下一步骤,但 continuation 或终止策略仍可在该步骤开始前停止轮次。轮次关闭且其持久性检查点处理结束后,剩余的 steering 会成为后续排队输入。终止性的 `agent/turn-stop`、取消或 dispose(资源释放)可能丢弃该消息。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 ## 曾考虑的替代方案 @@ -30,10 +30,10 @@ Status: implemented - 真实组合测试会通过 stdio 构建产物同时写入两行,并观察两个模型请求和两个轮次边界。 - 延迟第一个普通轮次的持久化刷新可以证明下一个排队的普通轮次不能在检查点处理结束前开始,且其请求能看到前一条助手结果;刷新即使失败,下一个普通轮次也要等它结束后才会开始。 - 提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 提交前失败都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 -- 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的状态和 `whenIdle()` 保持原有覆盖。 +- 轮次打开时、轮次关闭后与空闲时的 `steer()`、`inject()`、面向整个 agent 的状态和 `whenIdle()` 保持原有覆盖。 ## 后果 -普通轮次边界是确定的;FIFO 后继项进入轮次处理时,会观察前一个已完成普通轮次在会话中已关闭的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 +普通轮次边界是确定的;FIFO 后继项进入轮次处理时,会观察前一个已完成普通轮次在会话中已关闭的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,该区间也可以覆盖轮次关闭及其检查点,因此 `running` 不表示轮次必然仍然打开。广义取消可以丢弃整个未启动队尾,状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 依赖普通 send 偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入普通 send 批处理。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 6d73230a4b..555f3d629a 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -33,7 +33,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:151`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -53,7 +53,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:160`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -75,7 +75,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:315`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:318`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -98,7 +98,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:268`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:271`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -121,7 +121,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:208`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:211`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -142,7 +142,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:218`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:221`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -163,7 +163,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:182`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -186,7 +186,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:230`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:233`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -211,7 +211,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.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:282`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -237,7 +237,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:245`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -259,7 +259,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:192`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:195`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -279,7 +279,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:169`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:172`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -301,7 +301,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:256`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -322,7 +322,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:292`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -343,7 +343,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:302`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:305`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index ba1b2e6156..68255bbd41 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -370,9 +370,12 @@ interface Agent { send(content: ContentBlock[], options?: SendOptions): void /** - * Steer a running turn: content is injected between steps of the current - * turn. Uses the same owned-value and synchronous-validation boundary as - * {@link send}; when idle, behaves exactly like that method. + * Submit steering while the agent is `running`. An open turn records it at + * the next steering checkpoint before a request or continuation decision; + * policy may stop before another step. After turn close and its checkpoint, + * any remainder is queued for a later turn; terminal `agent/turn-stop`, + * cancellation, or disposal may discard it. Uses the same synchronous + * snapshot-and-validation boundary as {@link send}; when idle, delegates to it. */ steer(content: ContentBlock[], options?: SendOptions): void @@ -400,7 +403,7 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `SessionId` is branded. `AgentOptions` is merge-extensible and currently includes `provider?` and `model?`; dispatch requires both after `agent/request`. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. +`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `SessionId` is branded. `running` describes the driver-wide drain interval, which can span turn close, its durability checkpoint, and consecutive queued turns; it does not prove a turn is still open. `AgentOptions` is merge-extensible and currently includes `provider?` and `model?`; dispatch requires both after `agent/request`. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 5912accb71..94d16c584c 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:362`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:151`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:160`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:315`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:268`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:208`](../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:218`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:179`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:282`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:245`](../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:192`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:169`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:256`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:292`](../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) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:154`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:163`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:318`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:271`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:211`](../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:221`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:182`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:233`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:248`](../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:195`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:172`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:295`](../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) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:305`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 91df4913ff..acb49b1ba5 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -48,7 +48,7 @@ Configured agents start automatically. A model call requires both `provider` and The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. -Each concrete `send()` materializes content plus resolved source once as a detached, deeply frozen lossless-JSON FIFO item. If claimed, it is the sole ordinary message in its turn; a successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, or a pre-start failure may drop it without a turn. Running `steer()` joins the active turn. Open-turn `inject()` uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls; successful batches place it after all results, and interrupted batches drain it before turn close. Malformed data throws before enqueue or append. +Each concrete `send()` materializes content plus resolved source once as a detached, deeply frozen lossless-JSON FIFO item. If claimed, it is the sole ordinary message in its turn; a successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, or a pre-start failure may drop it without a turn. Running `steer()` enters the steering FIFO: an open turn records it at the next steering checkpoint before a request or continuation decision, but policy can still stop before another step; steering left after turn close and its checkpoint becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. Open-turn `inject()` uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls; successful batches place it after all results, and interrupted batches drain it before turn close. Malformed data throws before enqueue or append. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index fc6382ab2d..cedb6efed9 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -55,12 +55,14 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: - `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the rationale. -- `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle +- `agent.steer(content, options?)` — submit steering while the agent is `running`. An open turn records it at the next steering checkpoint before a request or continuation decision; policy can still stop before another step. After turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. The method uses the same synchronous snapshot-and-validation boundary as `send` and delegates to `send` when idle - `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. `options.envelope` defaults to the canonical `` framing and may be `'raw'` when the caller owns a complete familiar frame; `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` +`running` describes a driver-wide drain interval, not proof that a turn is still open; it can cover turn close, the durability checkpoint, and consecutive queued turns. + ### Extension points - Agent creation: `AgentLoop.create()` is the concrete config-path implementation (in `dsh-agent-loop`), while programmatic consumers create/resume owned agents through `ctx.agents.create()` / `ctx.agents.resume()`. Replace the loop by implementing `Agent` and registering via `ctx.agents.register()`. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 37481dbc1d..a2ddf9280d 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -40,9 +40,9 @@ export interface InjectOptions extends SendOptions { /** * An agent's lifecycle state, emitted on every transition as `agent/status`: - * `idle` (parked, waiting for queued work), `running` (a turn is in progress), - * `disposed` (terminal — no transition leaves it, and `send`/`steer`/`inject` - * throw). + * `idle` (parked, waiting for queued work), `running` (the driver is draining + * work and may be closing or checkpointing a turn), `disposed` (terminal — no + * transition leaves it, and `send`/`steer`/`inject` throw). */ export type AgentStatus = 'idle' | 'running' | 'disposed' @@ -106,9 +106,12 @@ export interface Agent { send(content: ContentBlock[], options?: SendOptions): void /** - * Steer a running turn: content is injected between steps of the current - * turn. Uses the same owned-value and synchronous-validation boundary as - * {@link send}; when idle, behaves exactly like that method. + * Submit steering while the agent is `running`. An open turn records it at + * the next steering checkpoint before a request or continuation decision; + * policy may stop before another step. After turn close and its checkpoint, + * any remainder is queued for a later turn; terminal `agent/turn-stop`, + * cancellation, or disposal may discard it. Uses the same synchronous + * snapshot-and-validation boundary as {@link send}; when idle, delegates to it. */ steer(content: ContentBlock[], options?: SendOptions): void diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 01313a79df..b6ee4a3183 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -28,7 +28,7 @@ A fully configured agent and live session were published. Setup is composition-o - `agent` — the newly registered agent with its live session and completed setup. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L151) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L154) ### agent/disposed @@ -50,7 +50,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef - `agent` — the exact agent removed from the registry. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L160) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L163) ### agent/error @@ -77,7 +77,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w - `step` — the step at which the failure surfaced. - `error` — the failure, verbatim. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L315) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L318) ### agent/post-step @@ -105,7 +105,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in - `step` — the open step number. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L268) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L271) ### agent/pre-step @@ -133,7 +133,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending - `step` — the pending step number. - `signal` — the turn abort signal. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L208) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L211) ### agent/prompt-submit @@ -158,7 +158,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca - `content` — the claimed message's blocks, as queued. - `source` — the message's resolved source. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L218) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L221) ### agent/queued @@ -183,7 +183,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already - `content` — the accepted content blocks retained by the inbox. - `info` — the accepted source plus whether it entered as steering. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L179) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L182) ### agent/request @@ -211,7 +211,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha - `step` — the step whose request this is. - `config` — the config the loop would use (frozen); return a replacement to switch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L230) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L233) ### agent/request-error @@ -243,7 +243,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens - `retryAttempt` — zero-based number of prior recovery retries. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L282) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L285) ### agent/session-prefix @@ -273,7 +273,7 @@ Compose request-only messages placed before derived history. The frozen result i - `prefix` — the frozen seed; return an extended replacement. - `signal` — aborts composition when the step is torn down. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L245) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L248) ### agent/session-start @@ -298,7 +298,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to - `agent` — the agent whose session lifecycle began. - `source` — why the session started (fresh startup, resume, …). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L192) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L195) ### agent/status @@ -321,7 +321,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no - `agent` — the agent whose status flipped. - `status` — the status just entered (the transition's destination). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L169) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L172) ### agent/step-result @@ -348,7 +348,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va - `step` — the step that produced the message. - `message` — the assistant message as assembled from the stream. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L256) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L259) ### agent/turn-continuation @@ -373,7 +373,7 @@ Override whether the turn continues. The default continues after tool calls or s - `turn` — the turn being continued or stopped. - `defaultDecision` — what the loop would do absent an override. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L292) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L295) ### agent/turn-stop @@ -397,7 +397,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a - `agent` — the agent whose composed continuation outcome may be stopped. - `turn` — the turn at its terminal-stop checkpoint. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L302) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L305) ## agent-loop/* From 7ffac135a6e9fa7ae1f96a2a26c82eea6c3e4e0f Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 14:51:38 +0800 Subject: [PATCH 12/13] review fix: disambiguate steering checkpoint timing --- .../simplification/2026-07-17-one-send-one-turn.i18n.yaml | 2 +- .../simplification/2026-07-17-one-send-one-turn.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index 46ee3998c3..da3da47302 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-17-one-send-one-turn.md: 7eec39e1e5b4678c9d454260f326929f4beff6c9 -2026-07-17-one-send-one-turn.zh.md: f28c93929045b3fcb0f2df8ff3c33adfb8a308ae +2026-07-17-one-send-one-turn.zh.md: dde1530a4e6276769ad0ea00ba7af6aa5714520d diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index f28c939290..dde1530a4e 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -18,7 +18,7 @@ Status: implemented 提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有混合批次或全阻止批次分支。 -运行中的 `steer()` 会把消息加入 steering(中途引导)FIFO。打开的轮次会在下一个 steering 检查点、请求或 continuation 决策之前记录该消息。Steering 可以让默认 continuation 决策进入下一步骤,但 continuation 或终止策略仍可在该步骤开始前停止轮次。轮次关闭且其持久性检查点处理结束后,剩余的 steering 会成为后续排队输入。终止性的 `agent/turn-stop`、取消或 dispose(资源释放)可能丢弃该消息。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 +运行中的 `steer()` 会把消息加入 steering(中途引导)FIFO。打开的轮次会在下一个 steering 检查点记录该消息;该检查点发生在请求或 continuation 决策之前。Steering 可以让默认 continuation 决策进入下一步骤,但 continuation 或终止策略仍可在该步骤开始前停止轮次。轮次关闭且其持久性检查点处理结束后,剩余的 steering 会成为后续排队输入。终止性的 `agent/turn-stop`、取消或 dispose(资源释放)可能丢弃该消息。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 ## 曾考虑的替代方案 From f899b857a613ba7e789f51cd62e2ee5db3c0370f Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 15:48:42 +0800 Subject: [PATCH 13/13] review fix: explain one-send turns plainly --- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 +-- .../2026-07-17-one-send-one-turn.md | 34 ++++++++++-------- .../2026-07-17-one-send-one-turn.zh.md | 36 +++++++++++-------- 3 files changed, 43 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index da3da47302..e441d0bfb1 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.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 -2026-07-17-one-send-one-turn.md: 7eec39e1e5b4678c9d454260f326929f4beff6c9 -2026-07-17-one-send-one-turn.zh.md: dde1530a4e6276769ad0ea00ba7af6aa5714520d +2026-07-17-one-send-one-turn.md: 86c056b53700d0e0c02e04a99cf044fb311f5840 +2026-07-17-one-send-one-turn.zh.md: 3ef9973480481d11d1183760c9fc1f3c247629f4 diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md index 7eec39e1e5..86c056b537 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -6,34 +6,40 @@ English | [中文](2026-07-17-one-send-one-turn.zh.md) ## Problem -An ordinary `Agent.send()` payload is one complete caller message. Opportunistically draining every waiting payload into one turn would make adjacent calls share a boundary according to driver timing: calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks could be grouped differently even though callers used the same API. +Suppose a caller submits message A and then message B with two `Agent.send()` calls. Implicit batching can put A and B in one turn simply because both are waiting when the driver reads its queue. The caller made two calls, but the loop silently turns them into one unit of work. -An ordinary turn contains prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later ordinary message join an earlier message's model request instead of observing the earlier ordinary turn's closed result in the same session log, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. +That grouping depends on timing rather than caller intent. Calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks could be grouped differently even though every caller used the same API. -`steer()` already selects steering semantics while the agent driver is running, while `inject()` records model-facing context without acting as an ordinary message. Implicit ordinary-send batching would make `send()` overlap both explicit operations instead of preserving a single meaning. +This grouping changes behavior, not just the number of model calls. One ordinary turn owns prompt admission, `turn/start`, `turn/end`, and a durability checkpoint. If message B shares message A's turn, B can enter A's model request instead of first seeing A's closed result in the session log. Allowing one message while blocking another also requires a mixed state that no caller requested. ## Decision -Each successful `send()` synchronously validates agent state, snapshots and freezes content, appends one independent FIFO item, and publishes `agent/queued`. The loop dequeues at most one ordinary item for each turn start. If two ordinary items both reach turn processing, the second ordinary turn starts only after the first ordinary turn ends and its durability checkpoint settles; broad cancellation, disposal, or a pre-start failure can discard an unstarted item without creating an empty turn. +The rule is simple: each successful `send()` creates one independent FIFO queue item. If that item runs, it is the only ordinary message in its turn. An item can be dropped before it starts, so the precise guarantee is at most one turn rather than exactly one; two sends are never silently combined. -Prompt admission decides one message. An allowed prompt becomes that turn's `user/message`; a blocked prompt appends one durable `prompt/blocked` and ends that one-message turn as `rejected`. There are no mixed-batch or all-blocked-batch branches. +Before enqueueing an item, `send()` checks the agent state and makes a detached, deeply frozen snapshot of the content and resolved source. After enqueueing it, `send()` publishes `agent/queued`. -Running `steer()` adds to the steering FIFO. An open turn records it at the next steering checkpoint before a request or continuation decision. Steering can make continuation default to another step, but continuation or terminal policy can still stop before that step begins. After turn close and its durability checkpoint, remaining steering becomes later queued input. Terminal `agent/turn-stop`, cancellation, or disposal may discard it. Idle `steer()` delegates to `send()` and therefore creates an independent ordinary queue item. `inject()` retains its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` remain whole-agent operations rather than per-message controls. +If messages A and B are both processed, B's turn starts only after A records `turn/end` and A's durability checkpoint settles. B's request therefore sees whatever closed result A left in the same session log. A checkpoint error is reported, but settlement only releases this ordering barrier; it does not make a failed write durable. Broad `cancel()`, disposal, or a failure before `turn/start` can instead discard an unstarted item without opening an empty turn. + +Prompt admission decides one message at a time. An allowed prompt becomes that turn's `user/message`; a blocked prompt records one durable `prompt/blocked` and closes its one-message turn as `rejected`. Mixed-batch and all-blocked-batch branches do not exist. + +The no-batching rule applies only to ordinary `send()`. Running `steer()` puts input in a separate steering FIFO. While a turn remains open, the loop records that input at the next steering checkpoint, which comes before either a model request or the decision whether to continue. Steering makes another step the default, but continuation or terminal policy can still stop before the step starts. Steering left after the turn closes and its durability checkpoint settles becomes later queued input; terminal `agent/turn-stop`, cancellation, or disposal can discard it. When the agent is idle, `steer()` delegates to `send()`, so it creates an independent ordinary queue item. + +`inject()` continues to add model-facing context without submitting an ordinary message; its existing turn-enclosure and flush behavior stays unchanged. `cancel()` remains a whole-agent operation that can clear all unstarted ordinary and steering input and abort the current step. `status` and `whenIdle()` also describe the whole agent, not one message. Several one-message turns can share one `running` interval, including turn close and its checkpoint, so `running` does not prove that a turn is open. ## Alternatives considered -**Keep opportunistic ordinary-send batching for throughput.** Combining queued ordinary prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and lets a later ordinary message run before the preceding ordinary turn closes and its checkpoint settles. Explicit lifecycle semantics are worth the additional model calls; any future ordinary-send batching feature needs an explicit caller-visible contract justified by measurements. +**Keep automatic ordinary-send batching to reduce model calls.** This can improve throughput when producers outpace the driver, but it makes turn boundaries depend on scheduling and lets a later message run before the preceding turn closes and reaches its checkpoint. The decision keeps the predictable boundary and accepts the extra calls. Any future batching feature needs an explicit caller-visible contract backed by measurements. ## Verification -- Unit and property coverage pins same-stack, neighboring-microtask, differently sourced, and reentrant sends as one FIFO-ordered message per turn. -- A real-composition test pipes two lines through the built stdio binary and observes two model requests and two turn boundaries. -- A deferred first ordinary-turn flush proves the next queued ordinary turn cannot start before the checkpoint settles and that its request sees the preceding assistant result; a rejected flush still settles before the next ordinary turn starts. -- Prompt veto and listener failure, broad cancellation, disposal, and pre-commit `turn/start` failure preserve balanced recorded turns and do not merge or strand surviving queued work. -- Open-turn, post-turn-close, and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their existing coverage. +- Unit and property tests submit sends from the same stack, neighboring microtasks, different producers, and reentrant callbacks; every message gets its own FIFO-ordered turn. +- A built-stdio test submits two lines and observes two model requests and two turn boundaries. +- Delayed and rejected first-turn checkpoints keep the next turn waiting and prove that its request sees the preceding assistant result. +- Failure-path tests cover prompt veto, listener failure, broad cancellation, disposal, and failure before `turn/start`; recorded turns stay balanced, messages do not merge, and surviving queued work still drains. +- Separate tests cover open-turn, post-turn-close, and idle `steer()`, plus `inject()`, whole-agent status, and `whenIdle()`. ## Consequences -Ordinary turn boundaries are deterministic, and a FIFO successor that reaches turn processing observes the preceding completed ordinary turn's closed session result after that turn's checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, which can also cover turn close and its checkpoint, so `running` does not prove a turn is open. Broad cancellation can discard the entire unstarted tail, and status and quiescence remain agent-wide observations rather than per-message results. +Ordinary turn boundaries are predictable: messages A and B stay separate, and B runs only after A has closed and reached its checkpoint. Callers still do not receive a per-send completion or cancellation handle; broad cancellation can discard the entire unstarted tail, while status and quiescence remain agent-wide observations. -Workloads that relied on coincidental ordinary-send batching make more model requests, incur more checkpoints, and may take longer to drain; FIFO queues may grow under sustained producers. Ordinary-send batching can return only through an explicit measured contract. +The trade-off is more model requests and more checkpoints. A busy queue can take longer to drain and can grow under sustained producers. Ordinary-send batching returns only through an explicit, measured contract. diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index dde1530a4e..3ef9973480 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 移除普通 send 的隐式批处理 +# Agent Note: 删除普通 send 的隐式批处理 Status: implemented @@ -6,34 +6,40 @@ Status: implemented ## 问题 -每次普通 `Agent.send()` 接受的载荷都是一条完整的调用方消息。如果机会式地把所有待处理载荷放入同一个轮次,相邻调用是否共享边界就会取决于驱动器的运行时机:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件监听器和模型回调的调用也可能产生不同分组。 +假设调用方连续两次调用 `Agent.send()`,先提交消息 A,再提交消息 B。隐式批处理可能只因为驱动器读取队列时两条消息都在等待,就把 A、B 放进同一个轮次。调用方明明调用了两次,agent loop(智能体循环)却悄悄把它们变成一个工作单元。 -普通轮次包含提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条普通消息加入前一条普通消息的模型请求,无法观察同一会话日志中前一个已关闭普通轮次的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 +这种分组取决于运行时机,而不是调用方的意图。因此,即使所有调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件监听器和模型回调的调用也可能产生不同分组。 -`steer()` 已经用于在 agent(智能体)驱动器运行时选择 steering(中途引导)语义,`inject()` 则记录面向模型的上下文而不充当普通消息。普通 send 的隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 +这种分组改变的不只是模型调用次数。一个普通轮次包含提示词准入、`turn/start`、`turn/end` 和持久性检查点。如果消息 B 与消息 A 共用轮次,B 可能直接进入 A 的模型请求,而不是先看到 A 在会话日志中已经关闭的结果。若系统允许一条消息、阻止另一条消息,还需要引入调用方没有请求的混合状态。 ## 决策 -每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop(智能体循环)在每个轮次开始时最多取出一个普通队列项。如果两个普通队列项最终都进入轮次处理,第二个普通轮次只能在第一个普通轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 +规则很简单:一次成功的 `send()` 创建一个独立的 FIFO 队列项。该队列项如果运行,就是所在轮次中唯一的普通消息。队列项可能在启动前被丢弃,因此精确保证是最多一个轮次,而不是必定一个轮次;两次 send 绝不会被悄悄合并。 -提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有混合批次或全阻止批次分支。 +队列项入队之前,`send()` 会检查 agent 状态,并为内容和解析后的来源创建一份脱离调用方对象、经过深度冻结的快照。队列项入队之后,`send()` 发布 `agent/queued`。 -运行中的 `steer()` 会把消息加入 steering(中途引导)FIFO。打开的轮次会在下一个 steering 检查点记录该消息;该检查点发生在请求或 continuation 决策之前。Steering 可以让默认 continuation 决策进入下一步骤,但 continuation 或终止策略仍可在该步骤开始前停止轮次。轮次关闭且其持久性检查点处理结束后,剩余的 steering 会成为后续排队输入。终止性的 `agent/turn-stop`、取消或 dispose(资源释放)可能丢弃该消息。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 +如果消息 A、B 都进入处理,B 的轮次只能在 A 记录 `turn/end` 且 A 的持久性检查点处理结束后开始。因此,B 的请求能看到 A 在同一会话日志中留下的已关闭结果。检查点错误会照常报告,但处理结束只表示解除这道顺序屏障,不表示失败的写入已经持久化。广义 `cancel()`、dispose(资源释放)或 `turn/start` 之前的失败也可能丢弃尚未启动的队列项,而不打开一个空轮次。 + +提示词准入每次只决定一条消息。获准提示词成为该轮次的 `user/message`;被阻止的提示词记录一条持久的 `prompt/blocked`,并让自己的单消息轮次以 `rejected` 关闭。实现中不存在混合批次或全阻止批次分支。 + +上述不合批规则只适用于普通 `send()`。agent 运行时,`steer()` 会把输入放入独立的 steering(中途引导)FIFO。只要当前轮次仍然打开,agent loop 就会在下一个 steering 检查点记录该输入;该检查点位于模型请求或继续轮次的决策之前。收到 steering 会把再执行一步作为默认选择,但继续轮次的策略或终止策略仍可在该步骤开始前停止。轮次关闭且其持久性检查点处理结束后,剩余的 steering 会成为后续排队输入;终止性的 `agent/turn-stop`、取消或 dispose 可以将其丢弃。agent 空闲时,`steer()` 委托给 `send()`,因此会创建一个独立的普通队列项。 + +`inject()` 继续添加面向模型的上下文,而不提交普通消息;其现有的轮次封闭与持久化刷新行为保持不变。`cancel()` 仍是面向整个 agent 的操作,可以清空所有尚未启动的普通输入和 steering,并中止当前步骤。`status` 和 `whenIdle()` 描述的也是整个 agent,而不是某一条消息。多个单消息轮次可以共用一个 `running` 区间,该区间还可能覆盖轮次关闭及其检查点,因此 `running` 不表示轮次一定处于打开状态。 ## 曾考虑的替代方案 -**为吞吐量保留普通 send 的机会式批处理。** 当消息进入队列的速度超过驱动器的处理速度时,合并排队的普通提示词可以减少模型调用,但会让轮次边界取决于调度,并让后一条普通消息在前一个普通轮次关闭且其检查点处理结束之前就运行。额外模型调用的代价低于显式生命周期语义的价值;未来的任何普通 send 批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 +**保留普通 send 的自动批处理,以减少模型调用。** 当消息进入队列的速度超过驱动器的处理速度时,这种做法可以提高吞吐量,但会让轮次边界取决于调度,并让后一条消息在前一轮关闭且到达检查点之前运行。本决策保留可预测的边界,并接受额外调用。未来若要加入批处理功能,必须提供调用方可见的显式契约,并有测量结果作为依据。 ## 验证 -- 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 send 的行为:每个轮次只有一条消息,并按 FIFO 排序。 -- 真实组合测试会通过 stdio 构建产物同时写入两行,并观察两个模型请求和两个轮次边界。 -- 延迟第一个普通轮次的持久化刷新可以证明下一个排队的普通轮次不能在检查点处理结束前开始,且其请求能看到前一条助手结果;刷新即使失败,下一个普通轮次也要等它结束后才会开始。 -- 提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 提交前失败都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 -- 轮次打开时、轮次关闭后与空闲时的 `steer()`、`inject()`、面向整个 agent 的状态和 `whenIdle()` 保持原有覆盖。 +- 单元测试和性质测试从同一调用栈、相邻微任务、不同生产方和重入回调提交 send;每条消息都会得到一个按 FIFO 排序的独立轮次。 +- stdio 构建产物测试提交两行输入,并观察到两个模型请求和两个轮次边界。 +- 延迟和拒绝第一个轮次的检查点,都能让下一个轮次保持等待,并证明其请求可以看到前一条助手结果。 +- 失败路径测试覆盖提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 之前的失败;已记录的轮次保持边界平衡,消息不会合并,仍需处理的排队工作也能继续清空。 +- 其他测试分别覆盖轮次打开时、轮次关闭后和空闲时的 `steer()`,以及 `inject()`、面向整个 agent 的状态和 `whenIdle()`。 ## 后果 -普通轮次边界是确定的;FIFO 后继项进入轮次处理时,会观察前一个已完成普通轮次在会话中已关闭的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,该区间也可以覆盖轮次关闭及其检查点,因此 `running` 不表示轮次必然仍然打开。广义取消可以丢弃整个未启动队尾,状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 +普通轮次的边界可预测:消息 A、B 始终分开,B 只能在 A 关闭并到达检查点后运行。调用方仍然拿不到逐次 send 的完成或取消句柄;广义取消可以丢弃整个尚未启动的队尾,状态和静止性也仍是面向整个 agent 的观察。 -依赖普通 send 偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入普通 send 批处理。 +代价是模型请求和检查点都会增加。繁忙队列可能需要更长时间才能清空;如果生产方持续提交消息,队列也可能增长。只有建立显式且经过测量的契约后,才能重新引入普通 send 批处理。