DSH / Atlas
2026-07-31implementedarchitecture

Claim inbox input before one pre-step decision

在单一 pre-step 决策前领取 inbox 输入

The loop previously split one step boundary across prompt preparation, prompt admission, and a serial step hook. Claimed input could be retained or discarded by an admission result, and live queue events carried shapes that duplicated durable inbox state. Plugins had to choose whether to mutate the inbox, rewrite a submitted batch, or append directly to session history, while observers could not rely on one exact ord

English

Problem

The loop previously split one step boundary across prompt preparation, prompt admission, and a serial step hook. Claimed input could be retained or discarded by an admission result, and live queue events carried shapes that duplicated durable inbox state. Plugins had to choose whether to mutate the inbox, rewrite a submitted batch, or append directly to session history, while observers could not rely on one exact ordering.

Occurrence-local inbox wrappers also duplicated the identity already carried by every UserMessage. They made insertion, editing, claiming, cancellation, reconnect projection, and step entry one combined protocol even though the append-only session already owned the durable queue projection.

Decision

Before every proposed step, Inbox.claim(target) atomically removes the complete batch: all next-step messages and, at a turn boundary, one next-turn message. At the initial boundary the loop first commits turn/start, so the claim and its single agent/pre-step decision have durable turn ownership. Claiming records normalized agent/inbox/spliced pure deletions with no outcome. The loop then emits agent/inbox/claimed { message, turn } once per claimed message and awaits the waterfall with that exclusive batch and { turn, step, signal }.

PreStepDecision is { kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }. Reject opens no step, leaves the claimed batch removed, and closes the turn as blocked without any step events. Empty entry, cancellation, and failure before step/start likewise close a balanced no-step turn. Enter supplies the complete batch appended as user/message events after step/start. A listener wrapping next() preserves downstream changes unless it intentionally replaces them, so all message rewrites settle once in the final return value. There is no agent/prompt-prepare, agent/prompt-submit, or agent/step extension point.

The durable inbox remains two UserMessage[] lists addressed by MessageId. append, prepend, and splice take a target, while replace(messageId, newMessage) and remove(messageId) locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits agent/inbox/inserted { message }; an ordinary removal records outcome: 'canceled' and emits agent/inbox/discarded { message }. Claiming is the loop's internal step-boundary operation on the inbox and records pure deletions without notifications or an outcome, so the loop can publish claimed events itself. These live events add no placement, outcome, or batch fields.

The two event surfaces have separate consumers. Observers following one message use agent/inbox/inserted, claimed, and discarded. Whole-queue consumers, including the Web queue projection and reconnect baseline, use the durable agent/inbox/spliced stream; UI edits and removals route through Inbox.splice() or another Inbox mutation method so the same projection records every change.

Plugins that need current-step atomic rewriting return messages from agent/pre-step. Plugins that only need later context may mutate agent.inbox directly. Workspace context uses both paths: asynchronous filesystem projections stage one replaceable next-step item, while the next entering pre-step folds that item or a newly composed baseline into its final batch and removes the pending copy. Rejection keeps the item queued.

The archived addressable queue occurrence decision describes the superseded occurrence-wrapper design. MessageId now owns addressability, while the retained Host queue mirror derives its snapshots from the durable splice projection.

Alternatives considered

Keep separate prepare and admit hooks. This lets preparation mutate the inbox before claiming and admission rewrite afterward, but it creates two ordering surfaces for one boundary and makes cancellation ownership ambiguous.

Let rejection requeue the claimed batch. This preserves retry-like behavior but turns a veto into hidden queue mutation, duplicates later work unless every race is fenced, and prevents claim from being an atomic ownership transfer.

Put placement and outcome on every live event. Durable splices already own those facts. Repeating them on live notifications creates a second contract that can drift and is unnecessary for consumers holding the exact message identity.

Verification

Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, and cancellation. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, and resumed durable projection. Generated event and type catalogs expose only the new waterfall and payloads.

Consequences

The loop has one awaited decision before each step and one ownership transfer for its input. Claimed messages never return to the inbox implicitly; later insertions remain independent. Live events are symmetrical with other inbox notifications without mirroring durable metadata, and plugins can choose exact-current-step rewriting or ordinary later inbox delivery explicitly.

中文

问题

循环此前把一个步骤边界拆成提示词准备、提示词准入与串行步骤钩子。准入结果可以保留或丢弃已领取输入,实时队列事件还携带了与持久 inbox 状态重复的数据结构。插件不得不在修改 inbox、改写已提交批次与直接追加会话历史之间选择,而观察方无法依赖一套明确顺序。

单次出现专属的 inbox 包装层也重复了每个 UserMessage 已有的标识。它把插入、编辑、领取、取消、重连投影与步骤进入合并成一套协议,但仅追加会话本就拥有持久队列投影。

决策

每个拟议步骤之前,Inbox.claim(target) 会原子移除完整批次:全部 next-step 消息,以及轮次边界上的一条 next-turn 消息。在首次边界,循环会先提交 turn/start,使领取及其唯一一次 agent/pre-step 决策拥有持久轮次归属。领取会记录规范化、不带 outcome 的纯删除 agent/inbox/spliced。随后,循环针对每条已领取消息发出一次 agent/inbox/claimed { message, turn },并用该独占批次与 { turn, step, signal } 等待 waterfall(瀑布式事件)。

PreStepDecision{ kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }。reject 不会打开步骤,会让已领取批次保持已删除,并将轮次关闭为 blocked,且不产生任何步骤事件。空的 enter、取消以及 step/start 前的失败同样会关闭一个边界平衡的无步骤轮次。enter 提供在 step/start 后以 user/message 追加的完整批次。包装 next() 的监听器会保留下游变更,除非有意替换,因此全部消息改写只在最终返回值中一次性结算。系统不再存在 agent/prompt-prepareagent/prompt-submitagent/step 扩展点。

持久 inbox 仍是两份通过 MessageId 寻址的 UserMessage[] 列表。appendprependsplice 接受 target;replace(messageId, newMessage)remove(messageId) 则在提交规范化 splice 前,通过 MessageId 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 agent/inbox/inserted { message };普通删除记录 outcome: 'canceled' 并发出 agent/inbox/discarded { message }。领取是循环在 inbox 上的内部步骤边界操作,记录不带通知或 outcome 的纯删除,因此循环可以自行发布 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。

两类事件接口服务不同消费方。跟踪单条消息的观察方使用 agent/inbox/insertedclaimeddiscarded。包括 Web 队列投影和重连基线在内的整体队列消费方使用持久 agent/inbox/spliced 流;UI 编辑与移除通过 Inbox.splice() 或其他 Inbox 变更方法处理,从而让同一投影记录所有变化。

必须对当前步骤进行原子改写的插件从 agent/pre-step 返回消息。只需要稍后上下文的插件可以直接修改 agent.inbox。Workspace context 同时使用两条路径:异步文件系统投影会暂存一条可替换的 next-step 消息,而下一次进入步骤的 pre-step 会把该消息或新组合的基线折入最终批次,并移除仍待处理的副本。reject 会让该条目继续排队。

已归档的可寻址队列项决策描述了已被取代的单次出现包装层设计。现在由 MessageId 负责寻址,而保留的 Host 队列镜像根据持久 splice 投影派生快照。

曾考虑的替代方案

保留分离的 prepare 与 admit 钩子。 这样准备阶段可以在领取前修改 inbox,准入阶段可以在领取后改写,但同一边界会出现两个顺序表面,取消归属也会变得模糊。

reject 时把已领取批次重新入队。 这看似保留重试行为,却会让否决隐式修改队列;若不为每个竞态加围栏,还会复制后续工作,并使 claim 无法成为原子所有权转移。

在每个实时事件上携带 placement 与 outcome。 持久 splice 已经拥有这些事实。实时通知重复它们会建立可能漂移的第二份约定,而持有确切消息标识的消费方并不需要这些字段。

验证

agent loop(智能体循环)覆盖固定先 turn/start、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败与取消。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点以及恢复后的持久投影。生成的事件与类型目录只公开新的 waterfall 与载荷。

后果

循环在每个步骤前只有一个需等待的决策,对输入也只有一次所有权转移。已领取消息不会隐式返回 inbox;后续插入保持独立。实时事件与其他 inbox 通知保持对称,但不镜像持久元数据;插件可以显式选择精确的当前步骤改写,或普通的后续 inbox 投递。