feat(agent-loop): give each send its own turn
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 队列增长。只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。
|
||||
@@ -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.
|
||||
@@ -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()` 边界会变得确定;只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。
|
||||
Reference in New Issue
Block a user