refactor(agent): name delivery methods by intent

This commit is contained in:
Tianyi Cui
2026-07-24 12:27:20 +08:00
parent 7b7f793ee5
commit 086e454931
39 changed files with 514 additions and 484 deletions

View File

@@ -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-22-unified-send-and-coalesced-user-messages.md: 9eb355128b217a0ea8dc09daf4e83334f6aeaa10
2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 679c9100aa49777b7b601725bdfc077f5f2dab0e
2026-07-22-unified-send-and-coalesced-user-messages.md: 9f6b29b4e1d90d75dbb88ab770c7393cdb859627
2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 4469d211da23a24f61017770b29bc983a2abb5f1

View File

@@ -1,4 +1,4 @@
# Agent Note: Unify agent delivery on send(target × wakeup) and coalesce injected context into user/message
# Agent Note: Unify agent delivery and coalesce injected context into user/message
Status: implemented
@@ -12,17 +12,17 @@ Separately, `context/message` and `user/message` had converged: the surface proj
## Decision
**One primitive, three preset aliases.** `Agent` is now an abstract class whose single abstract `send(content, { target, wakeup, source, contexts, meta })` covers the (`target` × `wakeup`) matrix. `followup` (`next-turn`/wakeup), `steer` (`next-step`/wakeup), and `inject` (`next-step`/no-wakeup) are concrete delegates on the base class, so concrete drivers implement `send` once and inherit the ergonomic presets. `wakeup` means "make the model run": wake a parked driver for a `next-turn` item, or force a continuation for a running `next-step` item. `send` defaults to `{ target: 'next-turn', wakeup: true }`, so every prior bare `agent.send(content)` call keeps its exact behavior. `next-turn`/no-wakeup (queue without waking) is now representable with no alias and no current caller.
**One private mechanism, four public intents.** The concrete loop resolves `send`, `queue`, `steer`, and `inject` into one private (`target` × `wakeup`) delivery mechanism. `send` is `next-turn`/wakeup, `queue` is `next-turn`/no-wakeup, `steer` is `next-step`/wakeup, and `inject` is `next-step`/no-wakeup. The public structural interface exposes no routing fields or abstract base; the [intent-named delivery decision](2026-07-24-intent-named-agent-delivery.md) owns that superseding interface choice. Internally, `wakeup` means “make the model run”: wake a parked driver for an ordinary item or force a continuation for running steering.
**inject keeps its mechanism.** The `next-step`/no-wakeup path is exactly the old `inject`: durable model-facing context appended at the current log position (deferred behind an executing tool batch), or a one-shot `injection` turn when idle. It bypasses the FIFOs entirely and defaults its source to `{ kind: 'plugin', plugin: '' }`, never `{ kind: 'user' }`.
**inject keeps its mechanism.** `inject` appends durable model-facing context at the current log position (deferred behind an executing tool batch), or opens a one-shot `injection` turn when idle. It bypasses the FIFOs entirely, accepts no attached contexts, and defaults its source to `{ kind: 'plugin', plugin: '' }`, never `{ kind: 'user' }`.
**context/message is gone.** Injected context is now a `user/message` whose `source` is a non-`user` kind (plugin or goal). `PromptMessageData` gained the optional `meta` that `context/message` carried. 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. This keeps goal-authority's human-authority check exactly as strict as before — an injected message defaults to a plugin source and can never satisfy `source.kind === 'user'`.
**Goal replay disambiguates by round, not type.** A goal state change is a round-zero goal-sourced `user/message` carrying `goal/change` metadata; a positive round is an admitted continuation prompt. `decodeGoalEvent` now takes a `user/message` and still fails loud on goal metadata under a non-goal source or a goal source lacking metadata.
**`send` returns an id.** `send` (and the aliases) return an opaque branded `AgentMessageId` for the accepted message; `send`'s previous return was `void`.
**Delivery returns an id.** Each delivery method returns an opaque branded `AgentMessageId` for the accepted input. FIFO methods carry it through their inbox lifecycle events; injection bypasses those events.
**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items) each carry an `AgentMessage` — the accepted message including its returned `id`, `target`/`wakeup`, source, and contexts — so a caller can correlate a queued item with its lifecycle. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including the loop-authored continuation-reason steer (`agent/turn-continuation` returning `{ action: 'continue', reason }`), so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative.
**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items) each carry an `AgentMessage` — the accepted message including its returned `id`, steering/wakeup facts, source, and contexts — so a caller can correlate a queued item with its lifecycle. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including the loop-authored continuation-reason steer (`agent/turn-continuation` returning `{ action: 'continue', reason }`), so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative.
**cancel gains keepInbox.** `cancel(cause?, { keepInbox? })`; when true it aborts the active turn but preserves queued and steering items (no discard event, and un-started work is not dropped).
@@ -30,16 +30,17 @@ Separately, `context/message` and `user/message` had converged: the surface proj
- **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. Injected context defaults to a plugin source instead.
- **A typed discriminant field on `PromptMessageData`** (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/enqueue` is the same enqueue-time signal with the added `target`/`wakeup` facts, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe.
- **Keeping `agent/queued` alongside the inbox events.** Rejected as a mirror: `agent/inbox/enqueue` is the same enqueue-time signal with the accepted routing facts, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe.
## Consequences
The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The cost: `Agent` became an abstract class, so object-literal test fakes must supply `followup` and cannot spread a class-typed value without re-casting (prototype methods are non-enumerable); the goal fold's channel split moved from event type to `source.round`; and every consumer that filtered `context/message` now filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged — an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`.
The concrete driver has one delivery mechanism, while the public structural interface names four intents and hides its (`target` × `wakeup`) matrix. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every “human prompt?” check simplify to a `source` test. The goal fold's channel split moves from event type to `source.round`, and every consumer that filtered `context/message` filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged: an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`.
`wakeup` is the "should the model run" signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): 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 (a lone quiet item takes `whenIdle`'s fast path, so no waiter is ever left hanging). `SendOptions.meta` on a queued or steering send is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally not on the live `AgentMessage` event, which carries only routing facts. Every FIFO exit publishes exactly one lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` for it — both at the in-turn stop point and on the post-turn drain of late steering — and a loop-authored continuation reason is snapshotted and frozen like a public send. `gen-cordis-api` collects exported classes (public members, body-stripped) so the now-class `Agent` and its transitive shapes still appear in the model-facing API catalog.
Internally, `wakeup` is the “should the model run” signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `queue()` item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal. `SendOptions.meta` on a queued or steering message is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally absent from the live `AgentMessage`, which carries only routing facts. Every FIFO exit publishes exactly one lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` for it, both at the in-turn stop point and on the post-turn drain of late steering, and a loop-authored continuation reason is snapshotted and frozen like public steering.
## Related
- [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md) — the one-claimed-message-per-turn rule this builds on.
- [remove-agent-steering-mirror](../simplification/2026-07-04-remove-agent-steering-mirror.md) — the precedent for collapsing a mirrored live event.
- [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md) — the cancel-cause signal `keepInbox` extends.
- [intent-named-agent-delivery](2026-07-24-intent-named-agent-delivery.md) — the public interface and private routing placement.

View File

@@ -1,4 +1,4 @@
# Agent Note: 将 agent 投递统一到 send(target × wakeup) 并把注入的上下文合并进 user/message
# Agent Note: 统一 agent 投递并把注入的上下文合并进 user/message
Status: implemented
@@ -12,17 +12,17 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`
## 决策
**一个原语,三个预设别名。** `Agent` 现在是一个抽象类,其唯一的抽象方法 `send(content, { target, wakeup, source, contexts, meta })` 覆盖 (`target` × `wakeup`) 矩阵。`followup`(`next-turn`/wakeup)、`steer`(`next-step`/wakeup)和 `inject`(`next-step`/no-wakeup)是基类上的具体委托方法,因此具体驱动器只需实现一次 `send`,就能继承这些好用的预设。`wakeup` 意为“让模型运行”:为一个 `next-turn` 队列项唤醒处于停泊状态的驱动器,或为一个运行中的 `next-step` 队列项强制继续执行。`send` 默认使用 `{ target: 'next-turn', wakeup: true }`,因此此前每一次裸调用 `agent.send(content)` 都保持完全相同的行为。`next-turn`/no-wakeup(入队但不唤醒)现在可以表达,只是没有别名,也没有当前调用方。
**一个私有机制,四种公开意图。** 具体循环把 `send`、`queue`、`steer` 和 `inject` 解析到同一个私有的(`target` × `wakeup`)投递机制中。`send` 是 `next-turn`/wakeup,`queue` 是 `next-turn`/no-wakeup,`steer` 是 `next-step`/wakeup,`inject` 是 `next-step`/no-wakeup。公开的结构化接口不暴露路由字段或抽象基类;取代旧接口的选择由[按意图命名的投递决策](2026-07-24-intent-named-agent-delivery.md)负责说明。内部的 `wakeup` 表示「让模型运行」:为一条普通消息唤醒处于停泊状态的驱动器,或强制运行中的 steering 继续执行。
**inject 保留其机制。** `next-step`/no-wakeup 路径正是旧的 `inject`:在当前日志位置追加的持久、面向模型的上下文(在执行中的工具批处理之后延迟处理),或在空闲时的一次性 `injection` 轮次。它完全绕过 FIFO 队列,并把来源默认设为 `{ kind: 'plugin', plugin: '' }`,绝不是 `{ kind: 'user' }`。
**inject 保留其机制。** `inject` 在当前日志位置追加持久、面向模型的上下文(在执行中的工具批处理之后延迟处理),或在空闲时开启一个一次性的 `injection` 轮次。它完全绕过 FIFO,不接受附加上下文,并把来源默认设为 `{ kind: 'plugin', plugin: '' }`,绝不是 `{ kind: 'user' }`。
**context/message 已移除。** 注入的上下文现在是一条 `user/message`,其 `source` 为非 `user` 类别(plugin 或 goal)。`PromptMessageData` 新增了 `context/message` 原本携带的可选 `meta`。对外接口、派生逻辑和 `SurfaceEventType` 都不再包含 `context/message`;需要判断“这是不是一条人类提示词?”的消费方改为读取 `source.kind === 'user'`,而不是事件类型。这让 goal-authority 的人类授权检查与此前一样严格——注入的消息默认使用 plugin 来源,永远无法满足 `source.kind === 'user'`。
**goal 回放靠轮次而非类型来区分。** 一次 goal 状态变更是一条第 0 轮、来源为 goal 的 `user/message`,携带 `goal/change` 元数据;正数轮次则是一条已准入的继续执行提示词。`decodeGoalEvent` 现在接收一条 `user/message`,并仍会在非 goal 来源携带 goal 元数据、或 goal 来源缺少元数据时立即报错。
**`send` 返回一个 id。** `send`(以及其别名)为被接受的消息返回一个不透明的 branded `AgentMessageId`;`send` 此前的返回值是 `void`。
**投递返回一个 id。** 每种投递方法都为被接受的输入返回一个不透明的 branded `AgentMessageId`。FIFO 方法通过其 inbox 生命周期事件携带这个 id;注入绕过这些事件。
**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都携带一条 `AgentMessage`——即被接受的消息,包含其返回的 `id`、`target`/`wakeup`、来源和上下文——因此调用方可以把一个排队项与其生命周期关联起来。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括由 loop 生成的携带继续原因的 steer(`agent/turn-continuation` 返回 `{ action: 'continue', reason }`),因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。
**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都携带一条 `AgentMessage`——即被接受的消息,包含其返回的 `id`、steering/wakeup 事实、来源和上下文——因此调用方可以把一个排队项与其生命周期关联起来。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括由 loop 生成的携带继续原因的 steer(`agent/turn-continuation` 返回 `{ action: 'continue', reason }`),因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。
**cancel 新增 keepInbox。** `cancel(cause?, { keepInbox? })`;当其为 true 时,它中止活跃轮次,但保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。
@@ -30,16 +30,17 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`
- **为注入内容设立专门的 `MessageSource` 类别 `context`。** 不予采纳,因为 `plugin` 已经表示“不是人类”,因此第四种类别会增加一条平行的轴,让授权检查不得不去学习它。注入的上下文改为默认使用 plugin 来源。
- **在 `PromptMessageData` 上设一个类型化的判别字段**(例如 `origin: 'prompt' | 'context'`)来取代事件类型的区分。不予采纳,转而采用 `source`——每个消费方都已经携带它,goal 系统也已经以它为键;第二个判别字段会重复这一事实。
- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/enqueue` 是同一个入队时刻的信号,只是多带了 `target`/`wakeup` 的事实,而 dequeue/discard 事件补全了单个事件无法描述的 FIFO 生命周期。
- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/enqueue` 是同一个入队时刻的信号,只是多带了已接受的路由事实,而 dequeue/discard 事件补全了单个事件无法描述的 FIFO 生命周期。
## 后果
投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。代价是:`Agent` 变成了抽象类,因此对象字面量形式的测试替身必须提供 `followup`,且无法在不重新做类型转换的情况下展开一个类类型的值(原型方法不可枚举);goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变——空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。
具体驱动器只有一个投递机制,而公开的结构化接口以四种意图为方法命名,并隐藏其(`target` × `wakeup`)矩阵。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处「是否人类提示词?」检查都简化为一次 `source` 判断。goal 折叠的通道区分从事件类型改到 `source.round`,此前过滤 `context/message` 的每个消费方都改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变:空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。
`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会有等待者被永久挂起)。排队 send 或 steering send 上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 事件上,后者只携带路由事实。每一次 FIFO 退出都恰好发布一个生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`——既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时——而由 loop 生成的继续原因会像一次对外 send 那样被快照并冻结。`gen-cordis-api` 收集导出的类(公开成员,剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。
在内部,`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `queue()` 项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默。排队消息或 steering 消息上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 上,后者只携带路由事实。每一次 FIFO 退出都恰好发布一个生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`,既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时;而由 loop 生成的继续原因会像公开 steering 一样被快照并冻结。
## 相关
- [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md)——本决策所依托的“每轮次只认领一条消息”规则。
- [remove-agent-steering-mirror](../simplification/2026-07-04-remove-agent-steering-mirror.md)——折叠镜像实时事件的先例。
- [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md)——`keepInbox` 所扩展的取消原因信号。
- [intent-named-agent-delivery](2026-07-24-intent-named-agent-delivery.md)——公开接口和私有路由的归属位置。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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-24-intent-named-agent-delivery.md: df58435e5a59dbc5e3c2ba17c624728533ed3b96
2026-07-24-intent-named-agent-delivery.zh.md: 78cf25870d3f220373692caf943870b920fb151e

View File

@@ -0,0 +1,50 @@
# Agent Note: Name public agent delivery by intent
Status: implemented
English | [中文](2026-07-24-intent-named-agent-delivery.zh.md)
## Problem
A public `send(content, { target, wakeup, ... })` makes every caller learn the loop's routing matrix, its defaults, and the interaction between active-turn targeting and model activation. Optional routing fields also let advanced-looking calls silently become ordinary sends. The mechanism is useful inside the concrete driver, but exposing it gives plugins implementation knowledge without leverage.
Sharing helper implementations through an abstract `Agent` class also makes the public seam nominal in practice. Object-literal adapters and tests must inherit prototype methods even though the package promises a swappable structural handle. The shared base exists only to forward fixed arguments, while the concrete loop remains the sole production adapter.
## Decision
`Agent` is a structural interface with four intent-named delivery methods:
- `send()` queues an ordinary turn and wakes the driver.
- `queue()` queues an ordinary turn without waking an idle driver.
- `steer()` targets the running turn and requests another step; while idle it becomes a waking ordinary turn.
- `inject()` appends model-facing context without running the model.
`send`, `queue`, and `steer` accept `SendOptions`; `inject` accepts `InjectOptions`, which omits attached contexts because injection has no inbox item to own them. `followup` is absent: ordinary `send` already names the established common operation, and “follow-up” is false for a session's first message.
`ReactLoopAgent` resolves each public call into one module-private `ResolvedDelivery` and passes it to native-private `#acceptDelivery`. Every internal field is mandatory: content, source, contexts, metadata (possibly `undefined`), target, and wakeup. Injection resolves contexts to the empty tuple. The private name says what the synchronous boundary guarantees: acceptance can still lead to later dequeue, discard, or durable injection rather than eventual delivery.
The target/wakeup matrix remains an implementation mechanism in `dsh-agent-loop`. It is not exported, protected, or represented by a base class. With one concrete adapter, a subclass delivery seam would be hypothetical; callers and tests use the same public `Agent` interface.
## Alternatives considered
**Keep a public configurable primitive.** Mandatory routing arguments remove defaulting mistakes but still require every caller to learn the matrix and allow invalid intent combinations such as attached contexts on injection. The concrete loop needs that flexibility; ordinary plugins do not.
**Rename the primitive to `sendInternal` or `addMessageAdvanced`.** Visibility belongs in the type and runtime boundary, not a warning in a public name. `addMessageAdvanced` is also inaccurate because acceptance may wake, queue, steer, inject, or later discard work.
**Keep `followup` as the waking-turn helper.** Existing production callers use `send`, while `followup` has no TypeScript caller and does not describe the first ordinary message. Reusing `send` preserves the familiar intent without retaining an alias.
**Bind source first through a public sender object.** A source-bound adapter can make attribution explicit for repeated producers, but it adds another public object and does not simplify one-off human input. The existing source default remains, with the standing requirement that non-human producers label their content.
## Verification
Focused agent-loop coverage exercises waking sends, quiet queues, active and idle steering, injection, source/context snapshots, cancellation, and inbox lifecycle correlation through the public methods. Type-level coverage uses structural `Agent` fakes and rejects routing fields on `SendOptions` and contexts on `InjectOptions`. The keyless Cordis inspection snapshot pins the model-facing interface without a configurable delivery primitive or abstract-class implementation.
## Consequences
Callers choose one verb instead of encoding two routing axes. The concrete loop retains one acceptance path and one ownership boundary, while the structural interface restores simple adapters and fakes. Adding a delivery intent now requires an explicit public name and mapping rather than another matrix combination.
Four short public methods duplicate a small amount of argument resolution inside the concrete adapter. That duplication is deliberate locality: defaults and routing stay beside the only implementation that owns them, and no generator support is needed merely to expose a class-shaped `Agent`.
## Related
- [unified delivery and coalesced user messages](2026-07-22-unified-send-and-coalesced-user-messages.md) owns the shared acceptance mechanism, inbox lifecycle, and durable event convergence this decision narrows at the public seam.

View File

@@ -0,0 +1,50 @@
# Agent Note: 按意图命名公开的 agent 投递
Status: implemented
[English](2026-07-24-intent-named-agent-delivery.md) | 中文
## 问题
公开的 `send(content, { target, wakeup, ... })` 会迫使每个调用方理解循环的路由矩阵、默认值,以及活跃轮次目标与模型激活之间的相互作用。可选路由字段还会让看似高级的调用悄然变成普通投递。该机制在具体驱动器内部很有用,但将其公开只会让插件背负实现知识,却得不到相应收益。
通过抽象 `Agent` 类共享辅助方法的实现,实际上也会让公开 seam 具有名义类型约束。对象字面量适配器和测试必须继承原型方法,尽管该包承诺提供一个可替换的结构化句柄。共享基类只负责转发固定参数,而具体循环仍是唯一的生产适配器。
## 决策
`Agent` 是一个结构化接口,提供四种按意图命名的投递方法:
- `send()` 将一个普通轮次入队并唤醒驱动器。
- `queue()` 将一个普通轮次入队,但不唤醒空闲驱动器。
- `steer()` 以运行中的轮次为目标并请求另一个步骤;空闲时,它会变成一个唤醒式普通轮次。
- `inject()` 追加面向模型的上下文,但不运行模型。
`send`、`queue` 和 `steer` 接收 `SendOptions`;`inject` 接收 `InjectOptions`,后者不包含附加上下文,因为注入没有 inbox 项来拥有它们。接口不提供 `followup`:普通 `send` 已经为既有的常见操作命名,而「follow-up」不适用于会话的第一条消息。
`ReactLoopAgent` 把每次公开调用解析为一个模块私有的 `ResolvedDelivery`,再将其传给原生私有的 `#acceptDelivery`。每个内部字段都必须提供:内容、来源、上下文、元数据(可以是 `undefined`)、目标和唤醒标志。注入会把上下文解析为空元组。这个私有名称说明同步边界所保证的事实:接受之后,工作仍可能在稍后出队、被丢弃或持久注入,而不是最终必然送达。
target/wakeup 矩阵仍是 `dsh-agent-loop` 中的实现机制。它不会导出,不是 protected 成员,也不由基类表示。只有一个具体适配器时,子类投递 seam 只是假想的;调用方和测试使用同一个公开 `Agent` 接口。
## 考虑过的替代方案
**保留公开的可配置原语。** 强制提供路由参数可以消除默认值错误,但仍会要求每个调用方理解矩阵,也允许出现无效的意图组合,例如为注入附加上下文。具体循环需要这种灵活性;普通插件不需要。
**把原语重命名为 `sendInternal` 或 `addMessageAdvanced`。** 可见性应由类型和运行时边界表达,而不是在公开名称中加入警告。`addMessageAdvanced` 也不准确,因为接受操作可能唤醒、排队、中途引导、注入,或在之后丢弃工作。
**保留 `followup` 作为唤醒轮次的辅助方法。** 现有生产调用方使用 `send`,而 `followup` 没有 TypeScript 调用方,也无法描述第一条普通消息。复用 `send` 可以保留熟悉的意图,同时不保留别名。
**先通过公开的发送方对象绑定来源。** 对于重复产生消息的来源,来源绑定适配器可以明确标注归属,但它会增加一个公开对象,也不会简化一次性的人类输入。现有的来源默认值予以保留,同时继续要求非人类生产方标注其内容。
## 验证
聚焦的 agent-loop 覆盖率测试通过公开方法覆盖唤醒式投递、静默排队、活跃与空闲状态下的 steering(中途引导)、注入、来源与上下文快照、取消,以及 inbox 生命周期关联。类型级覆盖使用结构化 `Agent` 测试替身,并拒绝 `SendOptions` 上的路由字段和 `InjectOptions` 上的上下文。无密钥的 Cordis 检查快照固定了面向模型的接口,其中既没有可配置的投递原语,也没有抽象类实现。
## 后果
调用方选择一个动词即可,无需编码两条路由轴。具体循环保留一条接受路径和一个归属边界,而结构化接口重新支持简单的适配器和测试替身。新增一种投递意图时,需要显式给出公开名称和映射,而不是再增加一种矩阵组合。
四个简短的公开方法会在具体适配器中重复少量参数解析。这项重复是为了让实现保持局部:默认值和路由都留在拥有它们的唯一实现旁边,无需仅为了暴露类形态的 `Agent` 而增加生成器支持。
## 相关
- [统一投递并合并 user 消息](2026-07-22-unified-send-and-coalesced-user-messages.md)负责定义共享的接受机制、inbox 生命周期和持久事件趋同;本决策只收窄它们的公开 seam。