DSH / Atlas
2026-07-22implementedarchitecture

Unify agent delivery on send(target × wakeup) and coalesce injected context into user/message

将 agent 投递统一到 send(target × wakeup) 并把注入的上下文合并进 user/message

The agent's public driving surface had grown three near-parallel verbs — `send`, `steer`, `inject` — each with its own options type, its own live event story, and its own durable event. `send` and `steer` both queued a frozen inbox record and emitted `agent/queued`; `inject` bypassed the inbox and wrote a separate `context/message` durable event. The three verbs actually vary along only two independent axes: which qu

English

Problem

The agent's public driving surface had grown three near-parallel verbs — send, steer, inject — each with its own options type, its own live event story, and its own durable event. send and steer both queued a frozen inbox record and emitted agent/queued; inject bypassed the inbox and wrote a separate context/message durable event. The three verbs actually vary along only two independent axes: which queue an item joins (a whole new turn versus the active turn) and whether the item makes the model run. Encoding that 2×2 as three hand-written methods hid the symmetry, made "queue a turn without waking the driver" unreachable, and left cancel() with no way to abort a turn while preserving queued work.

Separately, context/message and user/message had converged: the surface projected both as verbatim user-role content, and the only real difference was that injected context carried a non-user source and was "not a prompt." Two event types for one projection meant every consumer branched on event type to answer "is this a human prompt?", and the goal system used the type split as a side channel (round-zero state changes were context/message, admitted rounds were user/message).

Decision

One primitive, three preset aliases. The Agent interface's send(message, target, wakeup) covers the (target × wakeup) matrix. Its complete UserMessage owns identity, role, model-facing content, and producer source; the remaining arguments own only routing policy. followup (next-turn/wakeup), steer (next-step/wakeup), and inject (next-step/no-wakeup) each accept that one message and fix the policy. wakeup reserves a driver when the agent is idle; an already active driver receives no second reservation and can claim the input only if it reaches a later pre-step boundary. next-turn/no-wakeup (queue without waking) is representable with no alias and no current caller.

inject is a non-waking next-step delivery. It always appends the complete message to the next-step inbox and records that insertion in a durable agent/inbox/spliced event. The driver claims it at a later pre-step and records it as model-visible user/message only when the final decision returns it in the entering batch; idle injection remains pending until another delivery wakes the driver. Its required UserMessage.source preserves the source fields supplied by the caller.

context/message is gone. Injected context uses one UserMessage value in the inbox and becomes a user/message event if admitted; context producers supply the appropriate non-user source explicitly, and typed source variants carry any durable producer-specific fields. The surface, derivation, and SurfaceEventType drop context/message; consumers that need "is this a human prompt?" read source.kind === 'user' instead of the event type.

Goal continuation attribution uses positive rounds. Goal lifecycle state commits through the domain-owned goal/change event defined by the later goal-owned durable event decision. A positive round advances only from an admitted continuation user/message; goal persistence does not use injection or inbox state.

send does not return identity. Callers already own the complete message and its opaque MessageId; creation and freezing are owned by the identified immutable message decision, not by routing.

Inbox mutations have one durable projection and three minimal live notifications. Every append, prepend, edit, remove, cancellation, and claim records normalized agent/inbox/spliced coordinates. Insertions emit agent/inbox/inserted { message }; ordinary removals carry durable outcome: 'canceled' and emit agent/inbox/discarded { message }; the loop's atomic claim() records pure deletion splices and then emits agent/inbox/claimed { message, turn }. MessageId is the sole occurrence identity and remains unique across both pending lists. The live payloads deliberately omit placement, outcome, and batch envelopes because the durable splice owns those facts.

Pre-step claims next-step input without making it a separate turn. Steering and injection always enter the same next-step inbox; steering wakes the driver, while injection does not. At a turn boundary the driver atomically claims pending next-step input before one queued prompt, and between steps it claims only next-step input. Claiming records pure deletion splices and emits agent/inbox/claimed { message, turn } once per message. agent/pre-step then rejects the proposed step or returns its complete entering batch. Rejection and listener failure leave the claimed batch removed; input arriving after the claim waits for a later boundary.

One accepted message keeps one representation. Durable user-role input and additional model-facing context both use the identified, frozen UserMessage directly. The loop stores that value beside private routing state rather than copying its identity, content, or source into another public shape. Steering, injection, and tool-produced context each keep their identified messages in the next-step inbox. The identified immutable message decision supersedes this note's former UserMessageData/AgentMessage hierarchy and extends the representation to assistant and tool-result messages.

Idle wakeup follows insertion. A waking send inserts its input, then enters the running driver before returning. The first pre-step may claim that input immediately; later synchronous sends therefore join the running loop and wait for a later boundary. Cancellation belongs to the running turn signal from wakeup onward; no distinct pre-run phase intervenes.

cancel gains keepInbox. cancel(cause, { keepInbox? }); callers choose the cause explicitly, and keepInbox: true aborts the active turn while preserving queued and steering items (no discard event, and un-started work is not dropped).

Alternatives considered

  • A dedicated MessageSource kind context for injected content. Rejected because plugin already means "not a human," so a fourth kind would add a parallel axis the authority checks would have to learn. Plugin-produced injected context supplies its plugin source explicitly.
  • A typed discriminant field on UserMessage (e.g. origin: 'prompt' | 'context') to replace the event-type split. Rejected in favor of source, which every consumer already carries and which the goal system already keyed on; a second discriminant would duplicate that fact.
  • Keeping agent/queued alongside the inbox events. Rejected as a mirror: agent/inbox/inserted is the live insertion signal, while claimed/discarded notifications describe exits and the durable splice retains placement.
  • Derive inbox placement from agent status. Rejected because running includes pre-step processing and settlement. The producer already supplies the exact target to the durable splice.

Consequences

The delivery surface is now one primitive plus three self-documenting presets, and the (target × wakeup) matrix makes previously-unreachable combinations explicit. One identified message value serves prompts, injected context, and goal rounds, so every "human prompt?" check simplifies to a source test. The Agent contract remains an interface, so alternate implementations and object-literal test fakes implement the same minimal structural surface. Positive goal rounds fold from admitted user/message events, while goal lifecycle state remains outside the delivery surface. An idle injection remains pending without opening a turn or running the model, then becomes user/message when a later waking delivery's pre-step returns it in the entering batch.

wakeup is the "should the model run" signal, so the inbox distinguishes waking queued work from anything available to claim: a lone next-turn/no-wakeup item stays parked at idle and rides along the next waking send, and whenIdle/cancel settle quiescence off the waking signal. Every insertion and exit publishes its matching live notification, while domain-specific durable facts travel in typed message sources rather than a parallel metadata channel. The direct pending-message representation keeps durable splices and live events correlated without maintaining a second steering wrapper or allowing its data to diverge. The later claimed pre-step inbox lifecycle decision keeps live queue mutations addressed by MessageId and separates single-message lifecycle notifications from the durable whole-queue splice projection.

Related

中文

问题

agent(智能体)的对外驱动接口逐渐长出三个近乎平行的动词——sendsteerinject——各自带有独立的选项类型、独立的实时事件叙事,以及独立的持久事件。sendsteer 都会把一条冻结的 inbox 记录入队并发出 agent/queuedinject 则绕过 inbox,写入一条独立的 context/message 持久事件。这三个动词实际上只沿两条独立的轴变化:一个队列项加入哪个队列(一个全新的轮次,还是当前活跃的轮次),以及这个队列项是否让模型运行。把这个 2×2 编码成三个手写方法,掩盖了其中的对称性,让「排入一个轮次但不唤醒驱动器」无法表达,也让 cancel() 无从在保留排队工作的前提下中止一个轮次。

另外,context/messageuser/message 已经趋同:对外接口把二者都原样投影为 user 角色内容,唯一真正的区别是注入的上下文携带非 user source 且「不是提示词」。一个投影对应两种事件类型,意味着每个消费方都要根据事件类型分支来回答「这是不是一条人类提示词?」,而 goal 系统把这种类型区分当作侧信道使用(第 0 个 Round 的状态变更是 context/message,已准入的 Round 是 user/message)。

决策

一个原语,三个预设别名。 Agent 接口的 send(message, target, wakeup) 覆盖(target × wakeup)矩阵。完整的 UserMessage 持有标识、角色、模型可见 content 与生产方 source;其余参数只持有路由策略。followupnext-turn/wakeup)、steernext-step/wakeup)和 injectnext-step/no-wakeup)都接收这一条消息并固定策略。wakeup 会在 agent 空闲时保留一个驱动器;已经活跃的驱动器不会获得第二次保留,只有在抵达后续 pre-step 边界时才能领取该输入。next-turn/no-wakeup(入队但不唤醒)可以表达,只是没有别名,也没有当前调用方。

inject 是不会唤醒的 next-step 投递。 它始终把完整消息追加到 next-step inbox,并在持久 agent/inbox/spliced 事件中记录该插入。驱动器会在后续 pre-step 领取它,并且只有最终决策把它放入进入步骤的批次时,才会将其记录为模型可见的 user/message;空闲注入会保持待处理,直到其他投递唤醒驱动器。必填的 UserMessage.source 会保留调用方提供的源字段。

context/message 已移除。 注入的上下文在 inbox 中使用同一个 UserMessage 值,并在获准时成为 user/message 事件;上下文生产方显式提供合适的非 user 类别 source,类型化 source 变体携带持久化的生产方专用字段。对外接口、派生逻辑和 SurfaceEventType 都不再包含 context/message;需要判断「这是不是一条人类提示词?」的消费方改为读取 source.kind === 'user',而不是事件类型。

Goal 继续执行归属使用正数 Round。 Goal 生命周期状态通过后续的Goal 自有持久事件决策所定义的领域自有 goal/change 事件提交。正数 Round 只从已准入的继续执行 user/message 推进;goal 持久化不使用注入或 inbox 状态。

send 不返回标识。 调用方已经持有完整消息及其不透明的 MessageId;消息的创建与冻结由带标识的不可变消息值决策负责,而不是由路由负责。

Inbox 变更只有一份持久投影和三种最小实时通知。 每次 append、prepend、编辑、删除、取消与领取都会记录规范化的 agent/inbox/spliced 坐标。插入会发出 agent/inbox/inserted { message };普通删除携带持久 outcome: 'canceled',并发出 agent/inbox/discarded { message };循环的原子 claim() 会记录纯删除 splice,随后发出 agent/inbox/claimed { message, turn }MessageId 是唯一的单次出现标识,并在两个待处理列表间保持唯一。实时载荷刻意不携带 placement、outcome 或批次封套,因为这些事实由持久 splice 持有。

pre-step 会领取 next-step 输入,但不会为它单独创建轮次。 steering(中途引导)和注入始终进入同一个 next-step inbox;steering 会唤醒驱动器,注入则不会。在轮次边界,驱动器会原子领取待处理的 next-step 输入,再领取一条排队提示词;在步骤之间则只领取 next-step 输入。领取会记录纯删除 splice,并针对每条消息发出一次 agent/inbox/claimed { message, turn }。随后 agent/pre-step 会拒绝拟议步骤,或返回进入步骤的完整批次。拒绝与监听器失败都会让已领取批次保持已删除;领取后才到达的输入会等待后续边界。

一条已接受消息只保留一种表示。 持久的用户角色输入和附加的模型可见上下文都直接使用带标识且冻结的 UserMessage。循环把该值与私有路由状态存放在一起,不会将其标识、内容或来源复制到另一种公开形状中。steering、注入和工具产生的上下文都会在 next-step inbox 中保留各自带标识的消息。带标识的不可变消息值决策取代了本记录此前的 UserMessageData/AgentMessage 层级,并将这一表示扩展到 assistant 消息和工具结果消息。

空闲唤醒在插入之后发生。 会唤醒的发送会先插入输入,再于返回前进入 running 驱动器。首次 pre-step 可能立即领取该输入;因此,后续同步发送会加入正在运行的循环,并等待更晚的边界。自唤醒开始,取消就归属于 running 轮次信号,中间不会插入独立的预运行 phase。

cancel 新增 keepInbox。 cancel(cause, { keepInbox? });调用方显式选择 cause,且 keepInbox: true 会中止活跃轮次,同时保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。

考虑过的替代方案

  • 为注入内容设立专门的 MessageSource 类别 context 不予采纳,因为 plugin 已经表示「不是人类」,因此第四种类别会增加一条平行的轴,让授权检查不得不去学习它。由插件产生的注入上下文会显式提供其插件来源。
  • UserMessage 上设一个类型化的判别字段(例如 origin: 'prompt' | 'context')来取代事件类型的区分。不予采纳,转而采用 source——每个消费方都已经携带它,goal 系统也已经以它为键;第二个判别字段会重复这一事实。
  • 在 inbox 事件之外保留 agent/queued 作为镜像而被否决:agent/inbox/inserted 已经是实时插入信号,claimed/discarded 通知描述退出,而持久 splice 保留 placement。
  • 根据 agent 状态推导 inbox 放置方式。 不予采纳,因为 running 同时涵盖 pre-step 处理与结算。生产方已经把精确目标写入持久 splice。

后果

投递接口现在是一个原语加三个自解释的预设,(target × wakeup)矩阵把此前无法表达的组合显式化。同一个带标识消息值同时服务提示词、注入的上下文和 Goal Round,因此每一处「是否人类提示词?」检查都简化为一次 source 判断。Agent 约定仍是接口,因此其他实现和对象字面量形式的测试替身只需实现同一个最小结构接口。正数 Goal Round 从已准入的 user/message 事件折叠,而 goal 生命周期状态位于投递接口之外。空闲注入会保持待处理,不打开轮次也不运行模型;后续会唤醒的投递在 pre-step 将其放入进入步骤的批次时,它才成为 user/message

wakeup 是「模型是否应当运行」的信号,因此 inbox 会区分能唤醒的排队工作与任何可领取的项:一个孤立的 next-turn/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 whenIdle/cancel 依据唤醒信号来结算完全停稳。每次插入与退出都会发布对应的实时通知,特定于领域的持久事实则通过类型化消息 source 传递,而非通过平行的元数据通道。直接使用待处理消息的表示方式,使持久 splice 与实时事件保持可关联,既无需维护第二个 steering 包装层,也避免数据发生分歧。后续的已领取 pre-step inbox 生命周期决策保留通过 MessageId 寻址的实时队列变更,并把单消息生命周期通知与持久的整体队列 splice 投影分离。

相关