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.