DSH / Atlas
2026-07-31implementedarchitecturearchived 2026-09-30

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, the loop's package-internal ReactLoopInbox atomically claims 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, emits agent/inbox/claimed { message, turn } once per claimed message, and returns the exclusive batch for the loop's waterfall with { 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 records pure deletions without an outcome and emits claimed events from ReactLoopInbox. These live events add no placement, outcome, or batch fields.

Agent.inbox exposes only the structural Inbox interface for reading and mutating pending work; loop-only hasPending and claim operations are absent from that public face. dsh-agent-loop constructs one ReactLoopInbox and uses it for both structural commands and driver operations. The concrete constructor receives SessionProjectionRegistry directly instead of the wider Cordis Context. AgentLoop owns the standard projection registration for its service lifetime, keeping cold Inbox reads available without any live Agent; ReactLoopInbox only reads and mutates that shared state.

The two event surfaces have separate consumers. Observers following one message use agent/inbox/inserted, claimed, and discarded. AgentLoop contributes the standard inbox projection over the durable agent/inbox/spliced stream; UI edits and removals route through an Inbox mutation method so the same projection records every change. When that projection reconstructs durable history, it rejects unsafe or out-of-range coordinates and duplicate MessageId values across both lists, and reports the offending event seq. Whole-queue control consumers use the generic projection change feed and read the complete Inbox value directly.

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 owns addressability, while AgentLoop contributes inbox as the standard session projection over durable splices. The generic projection carrier serves that fold for live updates, history-tail reconnect baselines, and cold process-restart recovery without a live Agent mirror.

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, cancellation, and Inbox availability across Agent unloads and projection removal when AgentLoop unloads. 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, resumed durable projection, rejection of invalid persisted coordinates or cross-list identities, and post-fold queue replacement when the controller registers before the projection registry. Consumer-domain tests use a process-local Inbox stub only when durability is outside the test subject; claiming, durable projection, recovery, validation, and live-notification tests create Agents through the production AgentLoop test harness, so test support never reimplements the 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 已有的标识。它把插入、编辑、领取、取消、重连投影与步骤进入合并成一套协议,但仅追加会话本就拥有持久队列投影。

决策

每个拟议步骤之前,循环包内部的 ReactLoopInbox 会原子领取完整批次:全部 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-prepare、agent/prompt-submit 或 agent/step 扩展点。

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

Agent.inbox 只暴露用于读取和变更待处理工作的结构化 Inbox 接口;仅供循环使用的 hasPending 与领取操作不在该公开接口上。dsh-agent-loop 只构造一个 ReactLoopInbox,同时用于结构化命令与驱动器操作。具体构造函数直接接收 SessionProjectionRegistry,而不是更宽泛的 Cordis Context。AgentLoop 在服务生命周期内持有标准投影注册,让没有 live Agent 时的冷 Inbox 读取仍然可用;ReactLoopInbox 只读取和变更该共享状态。

两类事件接口服务不同消费方。跟踪单条消息的观察方使用 agent/inbox/inserted、claimed 与 discarded。AgentLoop 在持久 agent/inbox/spliced 流上贡献标准 inbox 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。该投影重建持久历史时,会拒绝不安全或越界的坐标,以及跨两份列表重复的 MessageId,并报告出错事件的 seq。整体队列的 control 消费方使用通用投影变更流,直接读取完整的 Inbox 值。

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

已归档的可寻址队列项决策描述了已被取代的单次出现包装层设计。MessageId 负责寻址,而 AgentLoop 把 inbox 作为持久 splice 上的标准会话投影贡献给投影注册表。通用投影传输层会将该折叠结果用于实时更新、历史尾页的重连基线和冷进程重启恢复,无需 live Agent 镜像。

曾考虑的替代方案

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

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

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

验证

agent loop(智能体循环)覆盖固定先 turn/start、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败、取消,以及Agent 卸载后 Inbox 仍可读取,以及 AgentLoop 卸载时移除投影。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点、恢复后的持久投影、对非法持久坐标或跨列表重复标识的拒绝,以及 controller 早于投影注册表注册时仍使用折叠后队列值。只有当持久性不属于测试对象时,消费方领域测试才使用进程内 Inbox 桩;领取、持久投影、恢复、校验与实时通知测试通过生产 AgentLoop 测试 harness 创建 Agent,因此测试支持代码不会重新实现该投影。生成的事件与类型目录只公开新的 waterfall 与载荷。

后果

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