diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml new file mode 100644 index 0000000000..b0afe427ce --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -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-22-unified-send-and-coalesced-user-messages.md: bf0ae468c4783b73e2dbd0e1bc50b9bd2f50cb3f +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 17913d2636e3ee5e5ae69f9c554935ba861d14d9 diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md new file mode 100644 index 0000000000..bf0ae468c4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -0,0 +1,46 @@ +# Agent Note: Unify agent delivery and coalesce injected context into user/message + +Status: implemented + +English | [中文](2026-07-22-unified-send-and-coalesced-user-messages.zh.md) + +## 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 `source`/`meta` 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 acceptance mechanism, four intent helpers.** The concrete loop resolves `followup`, `queue`, `steer`, and `inject` into one (`target` × `wakeup`) acceptance mechanism. `followup` 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 that mechanism as `send(ResolvedAgentInput)` for callers that already have fully resolved routing; every field is mandatory, and the discriminated input type excludes attached contexts from injection. 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.** `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. + +**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`, 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). + +## 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. 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 accepted routing facts, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe. + +## Consequences + +The concrete driver has one delivery mechanism. Four common helpers hide its (`target` × `wakeup`) matrix behind caller intent, while `send` exposes the fully resolved matrix for advanced callers. 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`. + +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 follow-up, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is left hanging). `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 enqueued id gets exactly one terminal lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` both at the in-turn stop point and on the post-turn drain of late steering, and disposal discards any still-pending items before the loop exits. The `agent/inbox/*` payload is frozen so a listener cannot mutate the shared correlation object mid-dispatch, and a loop-authored continuation reason is snapshotted and frozen like public steering. Injection validates its payload before opening an idle one-shot turn; `InjectOptions` omits attached contexts, while the non-waking next-step variant of `ResolvedAgentInput` requires an empty context tuple. + +## 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 helpers and fully resolved acceptance interface. diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md new file mode 100644 index 0000000000..17913d2636 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -0,0 +1,46 @@ +# Agent Note: 统一 agent 投递并把注入的上下文合并进 user/message + +Status: implemented + +[English](2026-07-22-unified-send-and-coalesced-user-messages.md) | 中文 + +## 问题 + +agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`、`steer`、`inject`——各自带有独立的选项类型、独立的实时事件叙事,以及独立的持久事件。`send` 和 `steer` 都会把一条冻结的 inbox 记录入队并发出 `agent/queued`;`inject` 则绕过 inbox,写入一条独立的 `context/message` 持久事件。这三个动词实际上只沿两条独立的轴变化:一个队列项加入哪个队列(一个全新的轮次,还是当前活跃的轮次),以及这个队列项是否让模型运行。把这个 2×2 编码成三个手写方法,掩盖了其中的对称性,让“排入一个轮次但不唤醒驱动器”无法表达,也让 `cancel()` 无从在保留排队工作的前提下中止一个轮次。 + +另外,`context/message` 与 `user/message` 已经趋同:对外接口把二者都投影为逐字的 user 角色内容,唯一真正的区别是注入的上下文携带 `source`/`meta` 且“不是提示词”。一个投影对应两种事件类型,意味着每个消费方都要根据事件类型分支来回答“这是不是一条人类提示词?”,而 goal 系统把这种类型区分当作侧信道使用(第 0 轮的状态变更是 `context/message`,已准入的轮次是 `user/message`)。 + +## 决策 + +**一种接受机制,四种意图辅助方法。** 具体循环把 `followup`、`queue`、`steer` 和 `inject` 解析到同一个(`target` × `wakeup`)接受机制中。`followup` 是 `next-turn`/wakeup,`queue` 是 `next-turn`/no-wakeup,`steer` 是 `next-step`/wakeup,`inject` 是 `next-step`/no-wakeup。公开的结构化接口将该机制暴露为 `send(ResolvedAgentInput)`;调用方若已持有完全解析的路由信息,即可使用该方法。使用时必须提供所有字段,可辨识输入类型也不允许注入携带附加上下文。取代旧接口的选择由[按意图命名的投递决策](2026-07-24-intent-named-agent-delivery.md)负责说明。内部的 `wakeup` 表示「让模型运行」:为一条普通消息唤醒处于停泊状态的驱动器,或强制运行中的 steering 继续执行。 + +**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 来源缺少元数据时立即报错。 + +**投递返回一个 id。** 每种投递方法都为被接受的输入返回一个不透明的 branded `AgentMessageId`。FIFO 方法通过其 inbox 生命周期事件携带这个 id;注入绕过这些事件。 + +**三个 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 事件,尚未启动的工作也不会被丢弃)。 + +## 考虑过的替代方案 + +- **为注入内容设立专门的 `MessageSource` 类别 `context`。** 不予采纳,因为 `plugin` 已经表示“不是人类”,因此第四种类别会增加一条平行的轴,让授权检查不得不去学习它。注入的上下文改为默认使用 plugin 来源。 +- **在 `PromptMessageData` 上设一个类型化的判别字段**(例如 `origin: 'prompt' | 'context'`)来取代事件类型的区分。不予采纳,转而采用 `source`——每个消费方都已经携带它,goal 系统也已经以它为键;第二个判别字段会重复这一事实。 +- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/enqueue` 是同一个入队时刻的信号,只是多带了已接受的路由事实,而 dequeue/discard 事件补全了单个事件无法描述的 FIFO 生命周期。 + +## 后果 + +具体驱动器只有一个投递机制。四种常用辅助方法以调用方意图封装其(`target` × `wakeup`)矩阵,而 `send` 则向高级调用方暴露完全解析后的矩阵。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处「是否人类提示词?」检查都简化为一次 `source` 判断。goal 折叠的通道区分从事件类型改到 `source.round`,此前过滤 `context/message` 的每个消费方都改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变:空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。 + +在内部,`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `queue()` 项会停泊在空闲状态,并随下一条会唤醒驱动器的后续消息一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会让任何等待者悬而未决)。排队消息或 steering 消息上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 上,后者只携带路由事实。每个已入队的 id 都恰好得到一个终止性生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`,既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时;dispose(资源释放)会在 loop 退出前丢弃所有仍在等待的项。`agent/inbox/*` 的事件载荷已被冻结,因此监听器无法在分发中途修改共享的关联对象,而由 loop 生成的继续原因会像公开 steering 一样被快照并冻结。注入会在打开空闲状态的一次性轮次之前校验其载荷;`InjectOptions` 不包含附加上下文,而 `ResolvedAgentInput` 中不唤醒的下一步变体要求使用空上下文元组。 + +## 相关 + +- [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)——公开辅助方法以及接受完全解析输入的接口。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml new file mode 100644 index 0000000000..ec8c9d105f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml @@ -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: 32b0502350063610efff746cbef779e8225055eb +2026-07-24-intent-named-agent-delivery.zh.md: ce8860b397497f4de587a9373d1cd300cf7dab29 diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md new file mode 100644 index 0000000000..32b0502350 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md @@ -0,0 +1,52 @@ +# Agent Note: Name public agent delivery by intent + +Status: implemented + +English | [中文](2026-07-24-intent-named-agent-delivery.zh.md) + +## Problem + +A configurable `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. Most callers have one semantic intent, while some adapters already possess exact routing facts and should not have to reverse-map them into a helper name. + +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 helpers: + +- `followup()` 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. + +`followup`, `queue`, and `steer` accept `SendOptions`; `inject` accepts `InjectOptions`, which omits attached contexts because injection has no inbox item to own them. `followup` names the waking next-turn operation used for both initial prompts and later independent prompts. + +`Agent` also exposes `send(ResolvedAgentInput)` for callers that already hold the complete route. Every field is mandatory: content, source, contexts, metadata (possibly `undefined`), target, and wakeup. The discriminated union requires the empty context tuple for non-waking next-step injection. `ReactLoopAgent` implements this method once, and all four helpers resolve their defaults before delegating to it. The method accepts the delivery facts as one resolved input; acceptance can still lead to later dequeue, discard, or durable injection rather than eventual delivery. + +The target/wakeup matrix is an explicit advanced part of the structural `Agent` interface, not the ordinary helper options and not a base-class implementation seam. With one concrete adapter, a protected subclass seam would be hypothetical; callers and tests use the same public interface. + +## Alternatives considered + +**Keep the resolved primitive private.** This minimizes the public method count, but forces adapters that already hold exact target/wakeup facts to reverse-map them into helper calls and removes the reusable type for that resolved state. + +**Use configurable `send(content, options)` as the primitive.** Optional routing fields would let advanced-looking calls silently become ordinary sends. One mandatory discriminated input keeps the resolved route explicit and rejects attached contexts on injection. + +**Name the primitive `acceptInput`, `sendInternal`, or `addMessageAdvanced`.** `acceptInput` describes the synchronous acceptance boundary but not the caller's delivery action. A public method must not describe itself as internal, and `addMessageAdvanced` is inaccurate because the input may later be discarded. + +**Use `send(content, options)` as the waking-turn helper.** This reserves the shortest delivery name for one preset and forces callers with complete target/wakeup facts through a less direct primitive name. `followup` distinguishes the next-turn/wakeup intent while leaving `send` for the resolved operation. + +**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 direct fully resolved acceptance, 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, requires every `ResolvedAgentInput` field, requires empty contexts on its injection variant, and keeps routing fields out of `SendOptions`. The keyless Cordis inspection snapshot pins the structural interface without an abstract-class implementation. + +## Consequences + +Ordinary callers choose one verb instead of encoding two routing axes; advanced callers may submit the exact discriminated route. The concrete loop retains one acceptance path and one ownership boundary, while the structural interface preserves simple adapters and fakes. Adding a common delivery intent still requires an explicit public helper and mapping rather than another optional matrix combination. + +The advanced method adds interface surface and requires structural fakes to implement it. In return, resolved routing has one typed representation, while helper defaults and mappings stay beside the only implementation that owns them. + +## 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. diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md new file mode 100644 index 0000000000..ce8860b397 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md @@ -0,0 +1,52 @@ +# Agent Note: 按意图命名公开的 agent 投递 + +Status: implemented + +[English](2026-07-24-intent-named-agent-delivery.md) | 中文 + +## 问题 + +可配置的 `send(content, { target?, wakeup?, ... })` 会迫使每个调用方理解循环的路由矩阵、默认值,以及活跃轮次目标与模型激活之间的相互作用。可选路由字段还会让看似高级的调用悄然变成普通投递。大多数调用方只有一种语义意图,而有些适配器已经持有确切的路由信息,不应再被迫将这些信息反向映射为某个辅助方法名称。 + +通过抽象 `Agent` 类共享辅助方法的实现,实际上也会让公开 seam 具有名义类型约束。对象字面量适配器和测试必须继承原型方法,尽管该包承诺提供一个可替换的结构化句柄。共享基类只负责转发固定参数,而具体循环仍是唯一的生产适配器。 + +## 决策 + +`Agent` 是一个结构化接口,提供四种按意图命名的投递辅助方法: + +- `followup()` 将一个普通轮次入队并唤醒驱动器。 +- `queue()` 将一个普通轮次入队,但不唤醒空闲驱动器。 +- `steer()` 以运行中的轮次为目标并请求另一个步骤;空闲时,它会变成一个唤醒式普通轮次。 +- `inject()` 追加面向模型的上下文,但不运行模型。 + +`followup`、`queue` 和 `steer` 接收 `SendOptions`;`inject` 接收 `InjectOptions`,后者不包含附加上下文,因为注入没有 inbox 项来拥有它们。`followup` 为唤醒式下一轮操作命名,这项操作既用于初始提示词,也用于后续的独立提示词。 + +`Agent` 还公开 `send(ResolvedAgentInput)`,供已经持有完整路由的调用方使用。每个字段都必须提供:内容、来源、上下文、元数据(可以是 `undefined`)、目标和唤醒标志。对于目标为下一步且不触发唤醒的注入,可辨识联合类型要求上下文为空元组。`ReactLoopAgent` 统一实现这个方法;四个辅助方法都会先解析各自的默认值,再委托给它。调用方以一个解析后的输入向该方法提交各项投递事实;接受之后,工作仍可能在稍后出队、被丢弃或持久注入,而不是最终必然送达。 + +结构化 `Agent` 接口显式包含面向高级用法的 target/wakeup 矩阵;该矩阵不属于普通辅助方法的选项,也不是基类实现 seam。只有一个具体适配器时,protected 子类 seam 只是假想的;调用方和测试使用同一个公开接口。 + +## 考虑过的替代方案 + +**让解析后的原语保持私有。** 这会把公开方法数量降到最低,但会迫使已经持有精确 target/wakeup 路由信息的适配器将其反向映射为辅助方法调用,也会移除表示该解析后状态的可复用类型。 + +**使用可配置的 `send(content, options)` 作为原语。** 可选路由字段会让看似高级的调用悄然变成普通投递。一个各字段均为必填项的可辨识输入既能让解析后的路由保持显式,也会拒绝为注入附加上下文。 + +**把原语命名为 `acceptInput`、`sendInternal` 或 `addMessageAdvanced`。** `acceptInput` 描述了同步接受边界,却没有描述调用方的投递操作。公开方法不应在名称中把自己称为内部方法,`addMessageAdvanced` 也不准确,因为输入可能在之后被丢弃。 + +**使用 `send(content, options)` 作为唤醒轮次的辅助方法。** 这会让最简短的投递名称只表示一种预设操作,并迫使持有完整 target/wakeup 信息的调用方改用一个不够直接的原语名称。`followup` 明确区分下一轮/唤醒意图,并把 `send` 留给解析后的操作。 + +**先通过公开的发送方对象绑定来源。** 对于重复产生消息的来源,来源绑定适配器可以明确标注归属,但它会增加一个公开对象,也不会简化一次性的人类输入。现有的来源默认值予以保留,同时继续要求非人类生产方标注其内容。 + +## 验证 + +聚焦的 agent-loop 覆盖率测试通过公开方法覆盖直接接受完全解析的输入、唤醒式投递、静默排队、活跃与空闲状态下的 steering(中途引导)、注入、来源与上下文快照、取消,以及 inbox 生命周期关联。类型级覆盖使用结构化 `Agent` 测试替身,要求提供 `ResolvedAgentInput` 的每个字段,要求其注入变体的上下文为空,并确保 `SendOptions` 不包含路由字段。无密钥的 Cordis 检查快照固定了不采用抽象类实现的结构化接口。 + +## 后果 + +普通调用方选择一个动词即可,无需编码两条路由轴;高级调用方则可提交经过判别的精确路由。具体循环保留一条接受路径和一个归属边界,而结构化接口保留了对简单适配器和测试替身的支持。新增一种常见投递意图时,仍需要显式提供公开辅助方法及其映射,而不是再增加一种可选的矩阵组合。 + +这个高级方法会扩大接口范围,并要求结构化测试替身实现它。作为回报,解析后的路由只有一种类型化表示,而辅助方法的默认值和映射仍留在拥有它们的唯一实现旁边。 + +## 相关 + +- [统一投递并合并 user 消息](2026-07-22-unified-send-and-coalesced-user-messages.md)负责定义共享的接受机制、inbox 生命周期和持久事件趋同;本决策只收窄它们的公开 seam。 diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index e53c591aa5..70ca07b414 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -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-19-model-facing-goal-tools.md: 7cc3907d708115207e166455ea988120a03d768b -2026-07-19-model-facing-goal-tools.zh.md: 1a381160354d6a2a24f957f41bc9e375c1ab01ca +2026-07-19-model-facing-goal-tools.md: 286329390a058c0302520fd2203e5becb8c81395 +2026-07-19-model-facing-goal-tools.zh.md: b0b4fc99ada3597fbab58081f52309e21dd43bac diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md index 7cc3907d70..286329390a 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -16,11 +16,11 @@ The surface also needs to preserve the separation between durable state and live ### Tools and model contract -`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`, while a non-empty `blocked_reason` is required only for `blocked` and persists under the stable `model-reported` code. +`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`, while a non-empty `blocked_reason` is required only for `blocked` and persists under the stable `model-reported` code. The executor treats exact empty-string optional fields and a zero `max_goal_rounds` as strict-schema fillers: they count as omitted, an edit still requires at least one meaningful replacement, and all non-filler values retain the action restrictions. The prompt tells the model that it may infer goal intent from a direct human request in any wording or language, but should not convert routine single-turn work into a goal. It must read the current goal before updating and copy the exact id and revision. On a restored or forked active-but-disarmed goal, a semantic human request to continue is grounds for `resume`. Completion is reserved for an achieved objective, and difficulty or uncertainty alone is not a blocker; a block report must name the concrete condition. -All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. ACP presentation is a pure function of arguments and uses generic read or mutation cards; activation is reported only as live observation and is never written into replay state. +All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. ACP presentation is a pure function of arguments and uses generic read or mutation cards; mutation cards select meaningful action values before the goal id, so accepted fillers cannot blank their input. Activation is reported only as live observation and is never written into replay state. An autonomous goal round that successfully reports completion or blocking contributes the existing terminal `agent/turn-stop` decision for that physical turn, preventing an unnecessary follow-up request. Direct-human mutations do not contribute a terminal stop: the assistant can acknowledge the change, and concurrent human steering remains available to ordinary continuation folding. @@ -38,7 +38,7 @@ Complete and blocked accept either direct-human authority or the exact current g ## Testing -Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, conditional blocker explanations, rearming after a session-start edge, authority-before-conditional-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless replay snapshot mounts the goal domain and tools into the real headless one-shot application, drives `create_goal` and `get_goal` through the shipped loop and persistence stack, pins its stream-json transcript, and inspects the externally persisted goal change. The echo-agent fixture is intentionally not used as an application-UX surrogate. +Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, filler-safe generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/partial-edit/pause/resume behavior including strict-schema fillers, conditional blocker explanations, rearming after a session-start edge, authority-before-conditional-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless replay snapshot mounts the goal domain and tools into the real headless one-shot application, drives a strict-filler `update_goal` probe plus `create_goal` and `get_goal` through the shipped loop and persistence stack, pins its stream-json transcript, and inspects the externally persisted goal change. The echo-agent fixture is intentionally not used as an application-UX surrogate. ## Alternatives considered @@ -48,6 +48,7 @@ Unit coverage pins registration and disposal, exclusive scheduling, generated pr - **Authorize from persisted root or fork metadata** — rejected because a fork that becomes an independently resumed top-level session should accept new human authority, while a currently owned child should not. - **Let autonomous rounds edit or resume the goal** — rejected because continuation authority is narrower than authority to redefine or restart the human objective. - **Treat the blocked threshold as an evaluator** — rejected because event counts cannot prove that an obstacle is semantically unchanged or truly terminal. +- **Reject every present action-specific field** — rejected because strict-schema providers can serialize zero-value placeholders for every optional field; only meaningful values can express a conflicting action. ## Consequences @@ -56,6 +57,7 @@ Unit coverage pins registration and disposal, exclusive scheduling, generated pr - Human requests can create and rearm goals through ordinary natural language, while restored sessions remain inert until such input arrives. - Goal rounds can finish or report a repeated blocker but cannot broaden their own mandate. - Deployment policy selects the blocking lower bound; the same resolved value controls enforcement and prompt guidance. +- Strict-schema provider fillers interoperate without allowing meaningful cross-action updates. ## Known limitations and deferred work diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index 1a38116035..b0b4fc99ad 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -16,11 +16,11 @@ Status: implemented ### 工具与模型契约 -`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。 +`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。执行器把值恰好为空字符串的可选字段和值为 0 的 `max_goal_rounds` 视为严格 schema 占位值:这些值等同于省略;编辑时仍必须提供至少一个有实际意义的替换字段;所有非占位值仍受对应操作的限制。 提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或派生后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞;阻塞报告必须说明具体条件。 -三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;激活态仅作为实时观察返回,绝不会写入回放状态。 +三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;变更卡片选择输入时,先取有实际意义的操作值,再取目标 id,因此允许的占位值不会使卡片输入留空。激活态仅作为实时观察返回,绝不会写入回放状态。 自主目标回合成功报告完成或阻塞后,插件会为该物理轮次贡献现有的终止型 `agent/turn-stop` 决策,避免再发起一次不必要的模型请求。直接人类发起的变更不会贡献终止决策:智能体可以确认该变更,并且并发的人类 steering(转向)仍可参与普通的继续执行折叠。 @@ -38,7 +38,7 @@ Status: implemented ## 测试 -单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、条件式阻塞说明、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动 `create_goal` 和 `get_goal`,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。 +单元测试固定注册与释放、独占调度、生成的提示词策略、可安全处理占位值的通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/部分字段编辑/暂停/恢复行为(包括严格 schema 占位值)、条件式阻塞说明、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动一次携带严格 schema 占位值的 `update_goal` 探测,以及对 `create_goal` 和 `get_goal` 的调用,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。 ## 考虑过的替代方案 @@ -48,6 +48,7 @@ Status: implemented - **根据持久的根或派生元数据授权**——不予采纳,因为成为独立恢复顶层会话的派生应接受新的人类权限,而当前仍受所有权约束的子智能体则不应接受。 - **允许自主回合编辑或恢复目标**——不予采纳,因为继续执行权限比重新定义或重启人类目标的权限更窄。 - **把阻塞阈值当作评估器**——不予采纳,因为事件计数无法证明障碍在语义上未改变或确实不可继续。 +- **拒绝所有已提供的特定操作字段**——不予采纳,因为采用严格 schema 的提供方可能为每个可选字段序列化零值占位符;只有有实际意义的字段值才能表示与指定操作相冲突的另一项操作。 ## 后果 @@ -56,6 +57,7 @@ Status: implemented - 人类可以通过普通自然语言请求创建和重新激活目标,而恢复后的会话在收到此类输入前保持静止。 - 目标回合可以完成或报告重复阻塞,但不能自行扩大任务权限。 - 部署策略选择阻塞下限;同一个解析后的值同时控制执行与提示词指导。 +- 系统可兼容采用严格 schema 的提供方所填入的占位值,同时不会放行有实际意义的跨操作更新。 ## 已知限制与延期工作 diff --git a/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml new file mode 100644 index 0000000000..233cc3901c --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml @@ -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-separate-context-injection-from-turn-execution.md: 652c3d410ab625d91a828f854bce302adcb0c9e0 +2026-07-24-separate-context-injection-from-turn-execution.zh.md: 1064e7a869ab9ea46c0145eb010119894a03aacf diff --git a/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.md b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.md new file mode 100644 index 0000000000..652c3d410a --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.md @@ -0,0 +1,75 @@ +# Agent Note: Separate context injection from turn execution + +Status: proposed + +English | [中文](2026-07-24-separate-context-injection-from-turn-execution.zh.md) + +## Problem + +The agent API currently represents supplementary model-facing input in three overlapping ways: callers attach `HookContext[]` through `SendOptions.contexts`, interception and tool hooks return `additionalContexts`, and plugins call `agent.inject()`. These paths eventually write context into the same model history, but they carry different placement, metadata, admission, queue, and turn-lifecycle rules. + +Atomic attachment to an inbox message forces the loop to preserve context through prompt admission, steering conversion, cancellation, and terminal discard. `prompt-prefix` placement then combines context and the direct prompt into one event, requiring a model-hidden envelope so transcript consumers can recover what the user actually wrote. The result makes outbox entries, session projection, and UI replay responsible for a distinction that belongs to the producer. + +Idle `inject()` exposes a second mismatch. Injection does not request model execution, yet the current implementation opens and closes a zero-step `injection` turn solely to satisfy the turn-enclosure invariant and obtain a durability checkpoint. A turn therefore sometimes means “run the agent loop” and sometimes means “persist context without running it.” + +`HookContext` also names its producer rather than its role. The value may come from a native plugin, a hook bridge, prompt admission, or tool post-processing. Its stable meaning is simply additional model-facing context with provenance. + +## Proposal + +Make `inject()` the only caller-facing operation for adding supplementary model-facing input, and define a turn exclusively as one execution of the model loop. + +Remove `SendOptions.contexts`. A caller that owns context delivers it with `inject()` and independently submits the direct message with `send()` or `steer()`. Rename `HookContext` to `AdditionalContext`; retain only `content` and `source`, and remove placement and model-hidden metadata from this shared shape. + +Prompt and tool extension points may still return `additionalContexts`. These values are outputs of the extension point, not attachments captured from a caller's inbox item. Prompt admission runs before `run()` opens a turn. An allowed prompt enters the outbox together with its returned additional contexts; a blocked prompt writes neither and opens no turn. Tool-produced additional contexts enter the same outbox after the corresponding tool results. + +Every additional context becomes an independent `user/message` whose `source` records provenance. Remove `context/message`, prompt-prefix placement, the stable request delimiter, and the prompt envelope. Transcript and UI consumers distinguish direct user messages from injected context by `source`, not by recovering a hidden direct-prompt field from combined model content. + +## Injection lifecycle + +When a turn is open, `inject()` stages the context in the loop outbox. The loop drains the outbox at a safe step boundary, preserving tool protocol adjacency: a context accepted during an assistant tool-call batch appears only after that batch's complete ordered results. Taking the outbox as a whole makes steering and injected context accepted for one boundary visible to the same following request. + +When no turn is open, `inject()` appends its `user/message` immediately and starts a session flush. It does not increment turn numbering, emit `turn/start` or `turn/end`, change agent status, or run the model. The synchronous API still returns before the asynchronous flush settles; `whenIdle()` and agent disposal include outstanding idle-injection flushes in their quiescence boundary. + +A failed idle flush has no legitimate turn or step coordinates. It is reported through logging or a persistence-owned error surface, not by inventing an `agent/error` payload for a nonexistent turn. The in-memory event remains accepted and a later flush may retry persistence. + +The session invariant therefore permits `user/message` between turns while continuing to require turn enclosure for execution events, steering, assistant output, tools, and package-added events by default. Persistence, recovery, resume, fork, and compaction code must treat a valid out-of-turn `user/message` as committed session history rather than an interrupted or discardable turn tail. + +## Extension and caller semantics + +`PromptDecision.content` continues to replace only the direct prompt. `PromptDecision.additionalContexts` and tool-result `additionalContexts` retain FIFO order and individual provenance, but no longer select placement. A waterfall listener that delegates with `next()` must preserve downstream prompt content and additional contexts unless it intentionally returns replacements. + +Caller-driven injection and hook-produced additional context deliberately have different admission ownership. A hook's additional contexts materialize only after that hook allows the prompt or tool result. A caller that invokes `inject(context)` and then `send(prompt)` has already committed context independently; if prompt admission later blocks the prompt, the injected context remains in history. Callers requiring all-or-nothing domain behavior must perform their own preparation before either operation or expose a domain-specific admission seam. + +Cross-session references follow the ordinary composition: the host prepares the snapshot, injects it with session-reference provenance, then sends or steers the readable direct prompt. The target log contains two simple messages, so later source mutation cannot change replay and transcript consumers do not need a prompt envelope. This supersedes the attachment mechanism in the [cross-session reference decision](../../implemented/feature/2026-07-21-cross-session-references.md) while retaining its snapshot and trust-boundary rules. + +This proposal preserves the caller-owned framing decision from [unwrapped injected content](../../implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), the one-item turn rule from [one send, one turn](../../implemented/simplification/2026-07-17-one-send-one-turn.md), and narrows the [turn-enclosure decision](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md) so turns enclose execution rather than every session event. + +## Alternatives considered + +**Keep `SendOptions.contexts` as an atomic attachment.** This preserves all-or-nothing delivery when prompt admission blocks, but it keeps context inside inbox lifecycle state and requires every queue transition and observation event to carry it. The generic agent API should not encode a domain transaction that most callers can express as context injection followed by message delivery. + +**Keep a distinct `context/message` session event.** A separate event makes the out-of-turn exception narrower, but user-role model input would again have two event types with identical projection. `user/message.source` already carries the distinction needed by policy, transcript, and replay consumers. + +**Keep one-shot turns for idle injection.** This retains universal turn enclosure and a convenient flush boundary, but it makes turn counts and turn observers report work that never ran the model. Durability is an independent session concern and can be awaited without fabricating execution. + +**Keep `prompt-prefix` as an optional placement.** Prefix baking can make the context and request appear in one provider message, but it introduces a second representation of the direct prompt and spreads placement handling across admission, steering, logging, replay, and UI code. Producers that require textual framing may include it in their own context content. + +**Let hooks call `inject()` directly instead of returning additional contexts.** Direct injection would erase the extension point's admission ownership: a listener could append context before a downstream listener blocks the operation. Returning `additionalContexts` keeps the waterfall result authoritative while sharing the same post-admission outbox path. + +## Acceptance criteria + +- `SendOptions` and steering inbox records contain no attached contexts; `agent/queued` reports only the retained message and steering facts. +- `AdditionalContext` replaces `HookContext` across prompt interception, tool execution, hook bridges, guards, and context producers, with only `content` and `source`. +- Prompt-prefix placement, prompt envelopes, and `context/message` are absent from public types, durable events, projection, and UI replay. +- Idle `inject()` appends and flushes one sourced `user/message` without a turn or model call; `whenIdle()` and disposal await the flush. +- Active-turn injection and hook-produced contexts drain at safe boundaries after complete tool-result batches and before the request that consumes them. +- Blocked prompt admission opens no turn and appends neither the prompt nor hook-produced additional contexts; independently injected caller context remains. +- Unit, persistence/resume, invariant, ACP/TUI replay, and keyless assembled-application snapshots cover the new event order and durability semantics. + +## Risks + +- Allowing one surface event outside turns weakens a simple invariant and may expose hidden assumptions in persistence scanning, crash repair, forking, compaction, and session queries. +- Consecutive user-role messages replace one baked prompt message; provider adapters and cache behavior must accept and preserve that ordering. +- `inject()` followed by a blocked `send()` leaves context without its intended direct prompt unless the caller accepts the independent-commit contract. +- A synchronous injection API cannot return flush failure. Logging alone is less structured than `agent/error`, while adding a new persistence event solely for this case may create another unnecessary seam. +- Removing attachment, placement, metadata, envelopes, and a durable event type is a broad pre-release migration that must update every producer and consumer atomically. diff --git a/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md new file mode 100644 index 0000000000..1064e7a869 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md @@ -0,0 +1,75 @@ +# Agent Note: 将上下文注入与轮次执行分离 + +Status: proposed + +[English](2026-07-24-separate-context-injection-from-turn-execution.md) | 中文 + +## 问题 + +agent API 目前用三种相互重叠的方式表示面向模型的补充输入:调用方通过 `SendOptions.contexts` 附加 `HookContext[]`,拦截钩子和工具钩子返回 `additionalContexts`,插件则调用 `agent.inject()`。这些路径最终都会把上下文写入同一份模型历史,但各自携带不同的放置、元数据、准入、队列和轮次生命周期规则。 + +将上下文原子附加到收件箱消息后,agent loop(智能体循环)必须让上下文跟随提示词准入、steering(中途引导)转换、取消和终止丢弃的完整生命周期。`prompt-prefix` 放置方式又会把上下文与直接提示词合并为一个事件,因此 transcript(文本记录)消费方需要依赖模型不可见的封套,才能还原用户实际输入。这样一来,outbox 条目、会话投影和 UI 回放都必须处理本应由生产方负责的区分。 + +空闲状态下的 `inject()` 还暴露了另一处语义错位。注入并不请求模型执行,但当前实现仅为了满足轮次封闭不变量并获得持久性检查点,就会打开并关闭一个零步骤的 `injection` 轮次。于是,轮次有时表示「运行 agent loop」,有时却表示「不运行 agent,仅持久化上下文」。 + +`HookContext` 的名字也描述了生产方,而非该值的职责。它可能来自原生插件、hook bridge、提示词准入或工具后处理;其稳定含义只是带来源信息的额外模型上下文。 + +## 提案 + +将 `inject()` 设为调用方添加补充模型输入的唯一操作,并把轮次严格定义为一次模型循环执行。 + +移除 `SendOptions.contexts`。拥有上下文的调用方通过 `inject()` 交付上下文,再独立使用 `send()` 或 `steer()` 提交直接消息。将 `HookContext` 重命名为 `AdditionalContext`;这个共享结构只保留 `content` 和 `source`,移除放置方式与模型不可见元数据。 + +提示词和工具扩展点仍可返回 `additionalContexts`。这些值是扩展点的输出,而不是从调用方收件箱条目捕获的附件。提示词准入在 `run()` 打开轮次之前执行。提示词获准后,它与返回的额外上下文一同进入 outbox;提示词被阻止时,两者都不写入,也不打开轮次。工具产生的额外上下文则在对应工具结果之后进入同一个 outbox。 + +每项额外上下文都成为独立的 `user/message`,并由 `source` 记录来源。移除 `context/message`、prompt-prefix 放置方式、稳定请求分隔符和提示词封套。transcript 与 UI 消费方通过 `source` 区分直接用户消息和注入上下文,无需从合并后的模型内容中恢复隐藏的直接提示词字段。 + +## 注入生命周期 + +轮次打开时,`inject()` 将上下文暂存在 loop outbox 中。agent loop 会在安全的步骤边界排空 outbox,同时保持工具协议要求的相邻关系:在助手工具调用批次期间接纳的上下文,只能出现在该批次所有有序结果之后。系统整体取走 outbox,确保同一边界接纳的 steering 和注入上下文对后续同一次请求可见。 + +没有打开的轮次时,`inject()` 会立即追加对应的 `user/message` 并启动会话刷新。它不会增加轮次编号、发出 `turn/start` 或 `turn/end`、改变 agent 状态,也不会运行模型。同步 API 仍会在异步刷新完成前返回;`whenIdle()` 和 agent dispose(资源释放)会把尚未结束的空闲注入刷新纳入静止边界。 + +空闲刷新失败时不存在合法的轮次或步骤坐标。系统通过日志或持久化所属的错误接口报告该失败,而不是为不存在的轮次伪造 `agent/error` 载荷。内存中的事件仍已接纳,后续刷新可以重试持久化。 + +因此,会话不变量允许 `user/message` 位于两个轮次之间,同时继续要求执行事件、steering、助手输出、工具事件以及默认的包扩展事件均受轮次边界约束。持久化、恢复、resume、fork、压缩和查询逻辑必须把合法的轮次外 `user/message` 当作已提交会话历史,而不是中断轮次或可丢弃的日志尾部。 + +## 扩展点与调用方语义 + +`PromptDecision.content` 仍只替换直接提示词。`PromptDecision.additionalContexts` 和工具结果的 `additionalContexts` 保留 FIFO 顺序及各自来源,但不再选择放置方式。waterfall(瀑布式事件)监听器调用 `next()` 委托时,必须保留下游返回的提示词内容和额外上下文,除非它有意返回替代值。 + +调用方主动注入与钩子产生的额外上下文具有不同的准入归属。钩子的额外上下文只会在该钩子允许提示词或工具结果后落入日志。调用方执行 `inject(context)` 后再执行 `send(prompt)` 时,上下文已独立提交;后续提示词准入即使阻止该提示词,注入上下文仍保留在历史中。需要领域级全有或全无语义的调用方,必须在执行任一操作前自行完成准备,或提供领域专用的准入 seam。 + +跨会话引用使用普通组合方式:宿主先准备快照,以会话引用来源调用 `inject()`,再发送或 steer 可读的直接提示词。目标日志包含两条简单消息,因此来源会话后续变化不会改变回放,transcript 消费方也不需要提示词封套。本提案取代[跨会话引用决策](../../implemented/feature/2026-07-21-cross-session-references.md)中的附件机制,但保留其快照与信任边界规则。 + +本提案保留[移除注入内容封套](../../implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md)确立的调用方自主管理框架原则,以及[一次 send、一个轮次](../../implemented/simplification/2026-07-17-one-send-one-turn.md)确立的单条目轮次规则;同时收窄[轮次封闭决策](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md),使轮次约束执行过程,而不是约束所有会话事件。 + +## 曾考虑的替代方案 + +**保留 `SendOptions.contexts` 作为原子附件。** 提示词准入阻止消息时,这种方式能保留全有或全无交付,但也会让上下文继续成为收件箱生命周期状态的一部分,并迫使每次队列转换和观察事件携带它。大多数调用方都可以通过先注入上下文、再交付消息来表达需求,通用 agent API 不应内置领域事务。 + +**保留独立的 `context/message` 会话事件。** 独立事件可以缩小轮次外事件的例外范围,但面向模型的 user-role 输入会再次拥有两个投影完全相同的事件类型。`user/message.source` 已能为策略、transcript 和回放消费方提供所需区分。 + +**为空闲注入保留一次性轮次。** 这种方式能保留通用轮次封闭和方便的刷新边界,却会让轮次计数与轮次观察方报告从未运行模型的工作。持久性是独立的会话关注点,无需伪造执行即可等待。 + +**保留 `prompt-prefix` 可选放置方式。** 前缀烘焙可以让上下文和请求位于同一条提供方消息中,但它会引入直接提示词的第二种表示,并把放置处理扩散到准入、steering、日志、回放和 UI 代码。需要文本框架的生产方可以直接把它写入自身上下文内容。 + +**让钩子直接调用 `inject()`,而不是返回额外上下文。** 直接注入会破坏扩展点的准入归属:下游监听器阻止操作之前,上游监听器就可能已经追加上下文。返回 `additionalContexts` 能维持 waterfall 结果的最终权威性,同时复用准入后的 outbox 路径。 + +## 验收标准 + +- `SendOptions` 与 steering 收件箱记录不再包含附加上下文;`agent/queued` 只报告保留的消息和 steering 事实。 +- `AdditionalContext` 在提示词拦截、工具执行、hook bridge、guard 和上下文生产方中取代 `HookContext`,且只包含 `content` 与 `source`。 +- 公共类型、持久事件、投影和 UI 回放中均不存在 prompt-prefix 放置方式、提示词封套与 `context/message`。 +- 空闲 `inject()` 在不产生轮次或模型调用的情况下,追加并刷新一条带来源的 `user/message`;`whenIdle()` 和 dispose 会等待该刷新。 +- 活跃轮次注入和钩子产生的上下文会在完整工具结果批次之后的安全边界排空,并在消费它们的请求之前进入日志。 +- 被提示词准入阻止的消息不会打开轮次,也不会追加提示词或钩子产生的额外上下文;调用方此前独立注入的上下文仍保留。 +- 单元测试、持久化与 resume 测试、不变量测试、ACP/TUI 回放测试,以及无需密钥的组装应用快照覆盖新的事件顺序和持久性语义。 + +## 风险 + +- 允许一个表层事件位于轮次之外,会削弱一条简单不变量,并可能暴露持久化扫描、崩溃恢复、fork、压缩和会话查询中的隐含假设。 +- 两条连续的 user-role 消息会取代一条烘焙后的提示词消息;提供方适配器和缓存行为必须接受并保留这一顺序。 +- 如果调用方不能接受独立提交契约,`inject()` 后跟一个被阻止的 `send()` 会留下缺少预期直接提示词的上下文。 +- 同步注入 API 无法返回刷新失败。只记录日志的结构化程度低于 `agent/error`,但仅为此场景增加新的持久化事件也可能产生另一个不必要的 seam。 +- 移除附件、放置方式、元数据、封套和一种持久事件类型,是一次影响面较广的预发布迁移,必须原子更新所有生产方和消费方。 diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index b732708e1f..5f1e43eb17 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -17,8 +17,8 @@ sequenceDiagram participant Session participant Persistence participant SDK as UI or SDK listener - User->>Agent: send(content) - Agent-->>SDK: agent/queued + User->>Agent: followup(content) + Agent-->>SDK: agent/inbox/enqueue Agent->>Driver: queued work wakes driver Driver-->>SDK: agent/status running Driver->>Session: turn/start diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 466a96dd30..214e2c9fba 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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 -architecture.md: 5a0ff63413a0c2a59d042f935d341dd39234f669 -architecture.zh.md: e1fb143982ad968fe6be2a5f6154722a602a297b +architecture.md: 76c58e03282ef6d736da7d65b05c534c05c4c318 +architecture.zh.md: dddccf1e9238e617a453395731ee3f620ba5749d diff --git a/docs/architecture.md b/docs/architecture.md index 5a0ff63413..76c58e0328 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,7 +6,7 @@ English | [中文](architecture.zh.md) ## Overview -Harnesses are [Cordis](cordis-primer.md) contexts whose packages contribute services, typed events, and disposable registrations. +Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed services, typed events, and disposable registrations. `packages/core/` groups the default agent flow; capabilities remain plugins. @@ -49,7 +49,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts whose packages contribute serv ## Event -Events form the service extension API; see the exhaustive [events catalog](cordis-catalog/events.md) and [producer/consumer map](event-producer-consumer.md). +Events form the service extension API; see the [catalog](cordis-catalog/events.md) and [producer/consumer map](event-producer-consumer.md). ### Event Domains @@ -63,11 +63,11 @@ Waterfall events behave like around-middleware: a listener delegates by calling ## Default Loop Lifecycle -The shipped loop runs prompt-to-checkpoint work through plugin services and events. +The loop runs through plugin services and events. -A **session** is append-only. Each ordinary **turn** claims one queued `send()` item; injection claims none. A successor awaits the preceding claimed turn's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model and plugins stop it; a **step** is one model request plus tools. In the [sequence below](agent-lifecycle.md), quotes mark durable events. +A **session** is append-only. Each ordinary **turn** claims one queued message; injection claims none. Successors await the preceding checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A **step** is one model request plus tools; quotes in the [sequence below](agent-lifecycle.md) mark durable events. -Creation without an id mints `-session-`; `sessionId` restores-or-creates, while `resumeSessionId` requires history. Resume restores lineage and delegation depth before publication. Startup failures emit `agent-loop/config-start-failed`; teardown is otherwise silent. +Creation without an id mints `-session-`; `sessionId` resumes or creates, while `resumeSessionId` requires history. Resume restores lineage and delegation depth before publication. Setup failures emit `agent-loop/config-start-failed`; teardown is silent. ### Turn Flow @@ -115,39 +115,39 @@ forever: checkpoint persistence and notify idle/running status ``` -Each step assembles ordered prompt sections, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona, while the loop supplies `model` and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). +Steps assemble ordered prompt sections, tool schemas, and variables; unknown references fail turns. `dsh-system-prompt` owns identity and persona; the loop supplies `model` and `cwd` ([ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -Tool-time context—including async `inject()` and post-tool `additionalContexts`—settles after results. Steering drains before `agent/post-step`, which sees durable output, results, context, and steering. Leftovers queue. Terminal `agent/turn-stop` remains authoritative through close/flush; later steering is discarded while queued prompts remain. +Async `inject()` and post-tool `additionalContexts` settle after results; steering drains before `agent/post-step`. Leftovers queue. Terminal `agent/turn-stop` remains authoritative through close/flush and discards later steering, not queued prompts. -Pruning precedes summaries; overflow retries require durable progress. Bounded transient retries compose on `agent/request-error`; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)). +Pruning precedes summaries; overflow retries require durable progress. Bounded retries compose on `agent/request-error`; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)). ### Failure Boundaries -Adapter failures close the step before `agent/request-error` with exact `Error`, `LlmFailure`, and history. Retry opens another step; success clears history; exhaustion stores failure on `turn/end`. Failed chunks commit no message/tool. +Adapter failures close the step before `agent/request-error` with exact `Error`, `LlmFailure`, and history. Retries open steps; success clears history; exhaustion stores failure on `turn/end`. Failed chunks commit nothing. -Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched tool calls get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The turn signal retires before `turn/end`. Effective `cancel()` emits its typed cause before clearing queues and aborting; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). +Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The signal retires before `turn/end`. Effective `cancel()` emits its cause, clears queues, and aborts; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). -Session events are turn-enclosed. Reload closes an interrupted tail with a synthetic `interrupted` turn end. Post-close failures report only through `agent/error`; no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants. +Session events are turn-enclosed; reload closes an interrupted tail with a synthetic `interrupted` turn end. Post-close failures use `agent/error`. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). ### Agent Handles -`ctx.agents` owns live agents and returns `AgentHandle { agent, dispose() }`. Plugins use `send()`, `steer()`, `inject()`, `cancel()`, and `whenIdle()`. The caller fiber, factory provider, and consumer handle co-own teardown through one awaited disposer. +`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins use intent helpers `followup()`, `queue()`, `steer()`, and `inject()`; callers with exact routing facts use mandatory-field `send()` ([decision](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). `cancel()` and `whenIdle()` control lifecycle. Caller, provider, and handle co-own teardown. ### Agent Scope -Each agent owns a scoped `agent.ctx`; shared storage overlays global tool, prompt, and command entries while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Scoped listeners filter dispatch, and every scoped contribution unwinds with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication. Typed resolvers derive carrier checks from merged `Events` and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). See [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md) and [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs inside `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, while turn, step, signal, cwd, and authority remain explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). +Each agent owns a scoped `agent.ctx` over global tool, prompt, and command storage ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)); scoped listeners filter and contributions unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication; typed resolvers derive carrier checks from `Events` and `scopeTarget` ([gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). `AgentLoop` runs inside `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, while turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). See [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md) and [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). ## State ### Session Log -The session log is authoritative. `deriveMessages()` projects model history; raw `assistant/chunk` events remain for replay and UI fidelity. Fork, resume, transcript rendering, telemetry, and persistence derive from the same stream. +The session log is authoritative. `deriveMessages()` projects model history; raw `assistant/chunk` events preserve replay and UI fidelity. Fork, resume, transcripts, telemetry, and persistence share that stream. -**Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start` fronted by the header's session prefix, and headers by folding `request/header` — and the package-owned `dsh-agent-loop/invariant` can assert it through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). +**Model-visible ⟺ logged**: `step/start` messages plus the header's session prefix and folded `request/header` reconstruct every request; `dsh-agent-loop/invariant` asserts this through `ctx.invariants` ([decision](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). -Durability is a plugin concern. Backends buffer synchronous `session/event` notifications. The semantic checkpoint policy drains requests before adapter dispatch, recorded top-level calls before tool dispatch, and complete response/result batches at `agent/post-step`; the loop retains the final turn-end checkpoint. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, with SQLite under one contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). +Durability is a plugin concern; backends buffer synchronous `session/event` notifications. Checkpoints drain before adapter dispatch, recorded top-level tool calls before tool dispatch, complete response/result batches at `agent/post-step`, and final turn ends. `SessionPersistence` stores `SessionEvent` plus `SessionHeader` metadata; JSONL defaults to checksummed Zstandard, with SQLite under one contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). -`ctx.sessions.appendOutOfBand()` joins log-only events to an open turn or creates a flushed zero-step turn. `session/title` folds latest-wins with source seqs/provenance; fallback and its optional provider never delay responses. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). +`ctx.sessions.appendOutOfBand()` joins plugin-owned log-only events to an open turn or creates a balanced, flushed zero-step turn. `session/title` folds latest-wins with source seqs and provenance; its immediate fallback and sole optional async provider never delay the agent response. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). ### Model Content diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index e1fb143982..dddccf1e92 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -49,7 +49,7 @@ ## 事件 -事件构成服务的扩展 API;完整清单见[事件目录](cordis-catalog/events.md)和[生产方与消费方映射](event-producer-consumer.md)。 +事件构成服务的扩展 API;参见[事件目录](cordis-catalog/events.md)和[生产方与消费方映射](event-producer-consumer.md)。 ### 事件域 @@ -63,11 +63,11 @@ waterfall(瀑布式事件)的行为类似环绕中间件:监听器调用 ` ## 默认循环生命周期 -已交付的循环通过插件服务和事件,处理从提示词到检查点的工作。 +循环通过插件服务和事件运行。 -**会话**采用仅追加方式。每个普通**轮次**领取一项已排队的 `send()` 输入;注入不领取输入。后续轮次会等待前一个已领取轮次的检查点,但可以与其共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。模型和插件停止轮次时,该轮次结束;一个**步骤**包含一次模型请求及其工具。在[下文时序](agent-lifecycle.md)中,引号标记持久事件。 +**会话**采用仅追加方式。每个普通**轮次**领取一条已排队的消息;注入不领取消息。后续轮次会等待前一个检查点,但可以与前一轮次共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。一个**步骤**包含一次模型请求及其工具;在[下文时序](agent-lifecycle.md)中,引号标记持久事件。 -未提供 id 时会生成 `-session-`;`sessionId` 用于恢复或创建,而 `resumeSessionId` 要求已有历史。恢复流程会在发布前还原沿袭关系和委托深度。初始化失败会发出 `agent-loop/config-start-failed`;其余拆卸过程保持静默。 +未提供 id 时,创建流程会生成 `-session-`;`sessionId` 用于恢复或创建会话,而 `resumeSessionId` 要求已有历史。恢复流程在发布前还原沿袭关系和委托深度。初始化失败会发出 `agent-loop/config-start-failed`;拆卸过程保持静默。 ### 轮次流程 @@ -115,39 +115,39 @@ forever: checkpoint persistence and notify idle/running status ``` -每个步骤都会组装有序提示词片段、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定,循环则提供 `model` 和 `cwd`([提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。 +各步骤会组装有序提示词片段、工具 schema 和变量;未知引用会使轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `model` 和 `cwd`([归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。 -工具执行阶段的上下文,包括异步 `inject()` 和工具执行后的 `additionalContexts`,会在结果产生后稳定。steering(中途引导)会在 `agent/post-step` 前排空;该事件会观察持久输出、结果、上下文和 steering。余留内容进入队列。终止型 `agent/turn-stop` 在关闭和刷写期间始终具有最终决定权;后续 steering 会被丢弃,排队提示词仍予保留。 +异步 `inject()` 和工具执行后的 `additionalContexts` 会在结果产生后稳定;steering(中途引导)会在 `agent/post-step` 前排空。余留内容进入队列。终止型 `agent/turn-stop` 在关闭和刷写期间始终具有最终决定权,会丢弃后续 steering,而不丢弃排队提示词。 -裁剪先于摘要;溢出重试必须取得持久进展。有界的瞬态重试在 `agent/request-error` 上组合;取消优先([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md))。 +裁剪先于摘要;溢出重试必须取得持久进展。有界重试在 `agent/request-error` 上组合;取消优先([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md))。 ### 失败边界 -适配器故障会先关闭步骤,再进入 `agent/request-error`;该事件会收到准确的 `Error`、`LlmFailure` 和历史记录。重试会开启另一个步骤;成功会清除历史记录;重试耗尽后,故障存入 `turn/end`。失败分片不会提交消息或工具。 +适配器故障会先关闭步骤,再进入 `agent/request-error`;该事件会收到准确的 `Error`、`LlmFailure` 和历史记录。重试会开启步骤;成功会清除历史记录;重试耗尽后,故障存入 `turn/end`。失败分片不会提交任何内容。 -其他故障使用 `agent/error`。取消和资源释放均优先于恢复;尚未分派的工具调用会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。轮次信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会在清空队列和中止前发出类型化原因;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 +其他故障使用 `agent/error`。取消和资源释放均优先于恢复;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会发出原因、清空队列并中止;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 -会话事件均位于轮次边界内。重新加载会用合成的 `interrupted` 轮次结束事件闭合中断的日志尾部。关闭后的故障只通过 `agent/error` 报告;此时已没有安全的轮次内位置。每个轮次有一个 `TurnEndReason`;各变体由 [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) 统一定义。 +会话事件均位于轮次边界内;重新加载会用合成的 `interrupted` 轮次结束事件闭合中断的日志尾部。关闭后的故障使用 `agent/error`。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。 ### Agent 句柄 -`ctx.agents` 拥有活跃 agent,并返回 `AgentHandle { agent, dispose() }`。插件使用 `send()`、`steer()`、`inject()`、`cancel()` 和 `whenIdle()`。调用方 fiber、工厂提供方和消费方句柄通过同一个需等待完成的 disposer 共同拥有拆卸过程。 +`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件使用按意图命名的辅助方法 `followup()`、`queue()`、`steer()` 和 `inject()`;持有确切路由信息的调用方使用各字段均为必填项的 `send()`([决策](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md))。`cancel()` 和 `whenIdle()` 控制生命周期。调用方、提供方和句柄共同拥有拆卸过程。 ### Agent 作用域 -每个 agent 都拥有一个作用域化的 `agent.ctx`;共享存储会在全局工具、提示词和命令条目之上叠加作用域条目,同时保留各领域视图([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md))。作用域监听器会过滤分派,每项作用域贡献都会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合。类型化解析器从合并后的 `Events` 和 `scopeTarget` 推导载体检查([语义门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。参见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,而轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。 +每个 agent 都拥有一个作用于全局工具、提示词和命令存储的作用域化 `agent.ctx`([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md));作用域监听器会过滤分派,各项贡献会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合;类型化解析器从 `Events` 和 `scopeTarget` 推导载体检查([门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,而轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。参见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。 ## 状态 ### 会话日志 -会话日志是权威依据。`deriveMessages()` 投影出模型历史;原始 `assistant/chunk` 事件留在日志中,以保证回放和 UI 保真。fork、恢复、transcript(文本记录)渲染、遥测和持久化均派生自同一个事件流。 +会话日志是权威依据。`deriveMessages()` 投影出模型历史;原始 `assistant/chunk` 事件保留回放和 UI 保真。fork、恢复、transcript(文本记录)、遥测和持久化共用该事件流。 -**模型可见 ⟺ 已记录**:日志可以重建每个请求,包括由请求头会话前缀置于开头的 `step/start` 时消息,以及通过折叠 `request/header` 得到的请求头;开发期不变量会断言这一点([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 +**模型可见 ⟺ 已记录**:`step/start` 消息、请求头中的会话前缀和折叠后的 `request/header` 共同重建每个请求;`dsh-agent-loop/invariant` 通过 `ctx.invariants` 断言这一点([决策](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 -持久性由插件负责。后端会缓冲同步的 `session/event` 通知。语义检查点策略会在适配器分发前刷写请求,在工具分发前刷写已记录的顶层调用,并在 `agent/post-step` 刷写完整的响应与结果批次;循环仍保留最终的轮次结束检查点。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 +持久性由插件负责;后端会缓冲同步的 `session/event` 通知。检查点会在适配器分发前排空,在工具分发前刷写已记录的顶层工具调用,在 `agent/post-step` 刷写完整的响应与结果批次,并刷写最终的轮次结束。`SessionPersistence` 存储 `SessionEvent` 和 `SessionHeader` 元数据;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 -`ctx.sessions.appendOutOfBand()` 会把纯日志事件加入开放轮次,或创建一个已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq/来源信息;回退标题及其可选提供方都不会延迟响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 +`ctx.sessions.appendOutOfBand()` 会把插件所属的纯日志事件加入开放轮次,或创建一个平衡且已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq 和来源信息;其即时回退标题和唯一可选异步提供方都不会延迟 agent 响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 ### 模型内容 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 2f0ed06b5b..a6d2ca2e38 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -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 -extension-cookbook.md: 056be4298ed2bec2b78ed777d58f1f8a60a34b78 -extension-cookbook.zh.md: 4574bcb0fa8b27d35e0fe612da028c471b4f9533 +extension-cookbook.md: c13b46e06a3b34512cd371e6a4868a6e932a575f +extension-cookbook.zh.md: ef099ff318488ba26a3ba4fe736c5f320bdce4f3 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 056be4298e..c13b46e06a 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -36,7 +36,7 @@ This waterfall is the reorderable policy layer. Use `ctx.tools.guard()` when an ## A UI plugin -A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.send()` / `agent.steer()`. +A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.followup()` / `agent.steer()`. ```ts import type { Context } from 'cordis' @@ -54,13 +54,13 @@ export function apply(ctx: Context) { render(event.data.chunk.text) } }) - onUserInput(text => ctx.agents.get(SessionId('client-session'))?.send([{ type: 'text', text }])) + onUserInput(text => ctx.agents.get(SessionId('client-session'))?.followup([{ type: 'text', text }])) } ``` ## A client-driver plugin (external protocol bridge) -A *client driver* is a UI plugin for a wire-protocol peer. It owns stdio, so stdout logging must be disabled, creates or resumes agents through the factory, maps harness events to protocol messages, and maps requests to `send()` or `cancel()`. Settle each request exactly once from durable `turn/end`, even if rendering fails, and tear agents down with `AgentHandle.dispose()` so disposal reaches quiescence. +A *client driver* is a UI plugin for a wire-protocol peer. It owns stdio, so stdout logging must be disabled, creates or resumes agents through the factory, maps harness events to protocol messages, and maps requests to `followup()` or `cancel()`. Settle each request exactly once from durable `turn/end`, even if rendering fails, and tear agents down with `AgentHandle.dispose()` so disposal reaches quiescence. `packages/ui/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the permission-prompt answerer it registers on the approval seam. @@ -99,9 +99,9 @@ Every product feature maps to a listener on a documented extension seam — the |---|---| | Hook system (user + project level) | listeners on `agent/session-start`, `agent/prompt-submit`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation` — each interception waterfall returns a typed Decision; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams | | `/goal` | `ctx.goals` owns durable state, `dsh-goal-session` schedules same-session rounds through the public `Agent`, and separate command/tool producers expose human/model control | -| `/loop` | on the `turn/end` session event, `send()` the next iteration; or force-continue | +| `/loop` | on the `turn/end` session event, `followup()` the next iteration; or force-continue | | Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and terminal `agent/turn-stop` | -| Queued + steering messages | core `Agent.send()` / `Agent.steer()` | +| Queued + steering messages | core `Agent.followup()` / `Agent.steer()` | | Context compaction (auto + manual) | the `ctx.compact` seam + `dsh-compact-basic`; automatic pressure runs on serial `agent/post-step`, canonical overflow recovery runs on `agent/request-error`, and manual callers use the same compact service ([compaction Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) | | System prompt configurability | `ctx.systemPrompt.section()` with ordering and scope-local shadowing | | AGENTS.md (root) | a section provider reading the file | @@ -118,8 +118,8 @@ Every product feature maps to a listener on a documented extension seam — the | MCP | one plugin per server: discover tools → `ctx.tools.register()` | | Skills | section + tool registration; `inject()` skill content on invocation | | Memory | section provider + tool | -| Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → `send(…, {source: {kind: 'cron', …}})` when idle / `inject()` notification when busy | -| UI (GUI; CLI emits JSONL) | listen `session/event` (assistant chunks, boundaries, tool activity); input → `send()` | +| Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → `followup(…, {source: {kind: 'cron', …}})` when idle / `inject()` notification when busy | +| UI (GUI; CLI emits JSONL) | listen `session/event` (assistant chunks, boundaries, tool activity); input → `followup()` | | Telemetry / replayable trace | `session/event` → JSONL; replay = `sessions.create(id, { seed })` | | Model adapters | `LlmAdapter` subclass via `registerAdapter` (`dsh-llm-deepseek`, `dsh-llm-pi-ai`) | | Plugin hot-reload | every registration is a `ctx.effect` → vendored HMR just works | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 4574bcb0fa..ef099ff318 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -36,7 +36,7 @@ export function apply(ctx: Context) { ## UI 插件 -UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.send()` / `agent.steer()` 将输入驱动回去。 +UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.followup()` / `agent.steer()` 将输入驱动回去。 ```ts import type { Context } from 'cordis' @@ -54,13 +54,13 @@ export function apply(ctx: Context) { render(event.data.chunk.text) } }) - onUserInput(text => ctx.agents.get(SessionId('client-session'))?.send([{ type: 'text', text }])) + onUserInput(text => ctx.agents.get(SessionId('client-session'))?.followup([{ type: 'text', text }])) } ``` ## 客户端驱动插件(外部协议桥接) -*客户端驱动*是面向协议格式(wire format)对端的 UI 插件。它拥有 stdio,因此必须禁用 stdout 日志;通过工厂创建或恢复 agent(智能体);将 harness 事件映射为协议消息;将请求映射为 `send()` 或 `cancel()`。每个请求从持久的 `turn/end` 恰好结算一次(即使渲染失败),并通过 `AgentHandle.dispose()` 拆除 agent,确保 dispose(资源释放)流程完全停稳。 +*客户端驱动*是面向协议格式(wire format)对端的 UI 插件。它拥有 stdio,因此必须禁用 stdout 日志;通过工厂创建或恢复 agent(智能体);将 harness 事件映射为协议消息;将请求映射为 `followup()` 或 `cancel()`。每个请求从持久的 `turn/end` 恰好结算一次(即使渲染失败),并通过 `AgentHandle.dispose()` 拆除 agent,确保 dispose(资源释放)流程完全停稳。 `packages/ui/acp` 是完整的工作示例:它将 agent 桥接到 ACP(Agent Client Protocol)(基于 stdio 的 JSON-RPC),使 Zed 及其他 ACP 编辑器能够驱动它。其 README 描述了完整的方法接口以及它在审批 seam 上注册的权限提示应答器。 @@ -99,9 +99,9 @@ export function apply(ctx: Context) { |---|---| | 钩子系统(用户级 + 项目级) | `agent/session-start`、`agent/prompt-submit`、`agent/request`、`agent/step-result`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-continuation` 上的监听器——每个拦截 waterfall 返回一个类型化 Decision;`dsh-hooks-claude` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些 seam 上 | | `/goal` | `ctx.goals` 管理持久状态,`dsh-goal-session` 通过公共 `Agent` 调度同会话回合,独立的命令/工具生产方分别提供人类/模型控制 | -| `/loop` | 在 `turn/end` 会话事件上 `send()` 下一次迭代;或强制继续 | +| `/loop` | 在 `turn/end` 会话事件上 `followup()` 下一次迭代;或强制继续 | | 动态工作流 | `ctx.workflows` + worker-thread 引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的 prompt/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和终端 `agent/turn-stop` 来强制输出 | -| 排队消息 + steering(中途引导) | 核心 `Agent.send()` / `Agent.steer()` | +| 排队消息 + steering(中途引导) | 核心 `Agent.followup()` / `Agent.steer()` | | 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + `dsh-compact-basic`;自动压力检查运行在串行 `agent/post-step`,规范化溢出恢复运行在 `agent/request-error`,手动调用方使用同一个压缩服务([压缩 Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) | | 系统提示词可配置性 | `ctx.systemPrompt.section()`,支持排序与作用域局部覆盖 | | AGENTS.md(根目录) | 一个读取该文件的 section provider | @@ -118,8 +118,8 @@ export function apply(ctx: Context) { | MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` | | Skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 | | 记忆 | section provider + 工具 | -| 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `send(…, {source: {kind: 'cron', …}})`/忙碌时 `inject()` 通知 | -| UI(GUI;CLI 输出 JSONL) | 监听 `session/event`(助手分片、边界、工具活动);输入 → `send()` | +| 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `followup(…, {source: {kind: 'cron', …}})`/忙碌时 `inject()` 通知 | +| UI(GUI;CLI 输出 JSONL) | 监听 `session/event`(助手分片、边界、工具活动);输入 → `followup()` | | 遥测 / 可回放 trace | `session/event` → JSONL;回放 = `sessions.create(id, { seed })` | | 模型适配器 | 通过 `registerAdapter` 注册 `LlmAdapter` 子类(`dsh-llm-deepseek`、`dsh-llm-pi-ai`) | | 插件热重载 | 每个注册都是一个 `ctx.effect` → vendor 的 HMR(热模块替换)直接生效 | diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 6becfd9434..ed9bebdc92 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/steering work is clear Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:350`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:294`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,77 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:365`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:498`](../../packages/core/agent/src/types.ts) + +### `agent/inbox/dequeue` — emit + +The driver claimed one item out of the inbox: a queued item at a turn boundary, or steering drained between steps. Fires after the item leaves its FIFO and before it becomes a durable message. + +```ts cordis-catalog +/** + * The driver claimed one item out of the inbox: a queued item at a turn + * boundary, or steering drained between steps. Fires after the item leaves + * its FIFO and before it becomes a durable message. + * @param agent - the agent whose inbox item was claimed. + * @param message - the claimed message (matching the `id` from its `agent/inbox/enqueue`). + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ +'agent/inbox/dequeue'(this: Scoped, agent: Agent, message: AgentMessage): void +``` + +Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) + +Source: [`packages/core/agent/src/types.ts:326`](../../packages/core/agent/src/types.ts) + +### `agent/inbox/discard` — emit + +Pending inbox items were dropped without delivering them, so every enqueued id receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop` dropping pending steering (in-turn and on the post-turn late-steering drain); and disposal of any still-pending items (before `agent/status('disposed')`). Fires once per drop with every dropped item. + +```ts cordis-catalog +/** + * Pending inbox items were dropped without delivering them, so every + * enqueued id receives exactly one terminal `agent/inbox/dequeue` OR + * `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after + * `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop` + * dropping pending steering (in-turn and on the post-turn late-steering + * drain); and disposal of any still-pending items (before + * `agent/status('disposed')`). Fires once per drop with every dropped item. + * @param agent - the agent whose inbox items were dropped. + * @param messages - the discarded messages in FIFO order (queued then steering); never empty. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ +'agent/inbox/discard'(this: Scoped, agent: Agent, messages: AgentMessage[]): void +``` + +Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) + +Source: [`packages/core/agent/src/types.ts:340`](../../packages/core/agent/src/types.ts) + +### `agent/inbox/enqueue` — emit + +A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `message` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection through `agent.inject()` or equivalent `send()` routing bypasses the FIFOs and does not emit this. + +```ts cordis-catalog +/** + * A detached, frozen item entered the agent's inbox (queued or steering + * FIFO). Source defaults are already applied, so `message` holds the exact + * accepted values. This is the enqueue-time live signal; the durable record + * is the eventual `user/message`/`steering/message`. Injection through + * `agent.inject()` or equivalent `send()` routing bypasses the FIFOs + * and does not emit this. + * @param agent - the agent whose inbox received the item. + * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts). + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ +'agent/inbox/enqueue'(this: Scoped, agent: Agent, message: AgentMessage): void +``` + +Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) + +Source: [`packages/core/agent/src/types.ts:316`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -119,7 +189,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:448`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -142,7 +212,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:246`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:379`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -169,28 +239,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:262`](../../packages/core/agent/src/types.ts) - -### `agent/queued` — emit - -Detached, frozen content entered the agent's inbox. Source defaults have already been applied, so these are the exact values retained for the log. - -```ts cordis-catalog -/** - * Detached, frozen content entered the agent's inbox. Source defaults have - * already been applied, so these are the exact values retained for the log. - * @param agent - the agent whose inbox received the message. - * @param content - the accepted content blocks retained by the inbox. - * @param info - the accepted source, contexts, and whether it entered as steering. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode emit - */ -'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void -``` - -Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [HookContext](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) - -Source: [`packages/core/agent/src/types.ts:207`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:395`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -215,7 +264,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:276`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:409`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -241,7 +290,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:330`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:463`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -267,7 +316,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:291`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:424`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -289,16 +338,16 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:363`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit -Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does not enter `running` synchronously; drive lifecycle from this event. +Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking delivery does not enter `running` synchronously; drive lifecycle from this event. ```ts cordis-catalog /** - * Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does - * not enter `running` synchronously; drive lifecycle from this event. + * Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking + * delivery does not enter `running` synchronously; drive lifecycle from this event. * @param agent - the agent whose status flipped. * @param status - the status just entered (the transition's destination). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. @@ -309,7 +358,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:197`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:303`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -332,7 +381,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:303`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:436`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -354,7 +403,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:341`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:474`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -376,7 +425,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:352`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:485`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5858799e7c..2d1917934f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1246,7 +1246,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:605`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:604`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index b352fc37b4..47310dd09b 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -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 -core.md: b0aa719974c5e5839027a6f958ce08259053da76 -core.zh.md: 157fcfb0409fdb63ea50186802a682b5fc476ffc +core.md: 781267cccdb5bbda33e5be6a9e807fdbe47dbc83 +core.zh.md: d0f67983b98b0cf679a8e599a5f8ab3c64490dd0 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index b0aa719974..781267cccd 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -327,7 +327,7 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, - * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * `assistant/message`, `tool/result`, `steering/message`). * Non-surface events (boundary markers, chunks, usage, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` * call sites. @@ -355,7 +355,7 @@ type SessionEvent = { }[T] ``` -The fourteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `context/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. +The thirteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. ## The agent handle @@ -365,8 +365,9 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types ```ts type-equiv /** - * Message options. An omitted source attests direct human input as `{ kind: 'user' }` - * and may authorize policy consumers, so non-human producers must label their content. + * Options for {@link Agent.followup}, {@link Agent.queue}, and {@link Agent.steer}. + * An omitted source attests direct human input as `{ kind: 'user' }` and may + * authorize policy consumers, so non-human producers must label their content. */ interface SendOptions { source?: MessageSource @@ -376,19 +377,90 @@ interface SendOptions { * records them directly at its next checkpoint. */ contexts?: HookContext[] + /** Opaque JSON state retained on the durable message but hidden from the model. */ + meta?: JsonValue } ``` -`InjectOptions` accepts ordinary message attribution and durable model-hidden JSON metadata. Attached contexts belong only to queued or steering input, so synthetic injection cannot accept them: - ```ts type-equiv /** Options specific to durable synthetic context injection. */ -interface InjectOptions extends Omit { - /** Opaque JSON state retained in the session event but hidden from the model. */ +interface InjectOptions { + /** Defaults to `{ kind: 'plugin', plugin: '' }`; non-human producers should identify themselves. */ + source?: MessageSource + /** Opaque JSON state retained on the durable message but hidden from the model. */ meta?: JsonValue } ``` +The advanced acceptance form makes every default explicit and rules out attached contexts on injection: + +```ts type-equiv +/** + * Fully specified input for {@link Agent.send}. Unlike the intent-named + * helpers, this form applies no defaults: callers provide content, source, + * contexts, metadata (including explicit `undefined`), target, and wakeup. + * The union excludes attached contexts from non-waking next-step injection. + */ +type ResolvedAgentInput = { + content: ContentBlock[] + source: MessageSource + meta: JsonValue | undefined +} & ( + | { target: 'next-turn'; wakeup: boolean; contexts: HookContext[] } + | { target: 'next-step'; wakeup: true; contexts: HookContext[] } + | { target: 'next-step'; wakeup: false; contexts: [] } +) +``` + +FIFO delivery methods return an opaque `AgentMessageId`, stable across that message's `agent/inbox/*` events. Injection returns an id but bypasses those events: + +```ts type-equiv +/** + * Opaque id assigned to one accepted agent input. FIFO inputs carry the same id + * on their `agent/inbox/*` events; injection bypasses those events. + */ +type AgentMessageId = Branded<'AgentMessageId'> +``` + +The `agent/inbox/*` live events carry one accepted message; injection bypasses the FIFOs and never appears on them: + +```ts type-equiv +/** + * One accepted FIFO message, carried by the `agent/inbox/*` live events. `id` + * is the value returned by the accepting helper or {@link Agent.send}, + * stable across this message's enqueue, dequeue, and discard events. Source + * defaults, when applicable, are already applied, so these are the exact values + * the item was accepted with. + * `steering` is true for an item drained between steps; otherwise it is claimed + * at a turn boundary. `SendOptions.meta` is intentionally omitted: it is durable + * model-hidden state that lands on the eventual `user/message`/ + * `steering/message`, not live-event routing data. + */ +interface AgentMessage { + /** The id returned by the accepting helper or {@link Agent.send}. */ + id: AgentMessageId + content: ContentBlock[] + source: MessageSource + contexts: HookContext[] + /** Whether the item joined the steering FIFO rather than the queued FIFO. */ + steering: boolean + /** Whether the item wakes the driver or requests another step. */ + wakeup: boolean +} +``` + +```ts type-equiv +/** Options for {@link Agent.cancel}. */ +interface CancelOptions { + /** + * Preserve queued and steering inbox items instead of discarding them. The + * active turn is still aborted, but un-started and pending work survives for a + * later turn and no `agent/inbox/discard` fires. + */ + keepInbox?: boolean +} +``` + ```ts type-equiv /** Stable runtime cause accepted by {@link Agent.cancel}. */ type AgentCancelCause = @@ -396,59 +468,97 @@ type AgentCancelCause = | { readonly kind: 'parent' } ``` +The structural `Agent` interface exposes four intent helpers plus the fully resolved acceptance method. The concrete driver implements the matrix once, and each helper supplies its fixed routing and defaults. + ```ts type-equiv /** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ interface Agent { /** The single identity shared with {@link session}. */ readonly id: SessionId + /** The provider route and model this agent's requests use. */ readonly options: AgentOptions + /** The live session this agent drives; its log is the durable source of truth. */ readonly session: Session + /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context /** - * Queue one detached, frozen lossless-JSON item. If claimed, it is the sole - * ordinary message in its FIFO-ordered turn; the next claimed item waits for - * that turn's checkpoint. - * Attached contexts share the same snapshot and ownership boundary. Invalid - * input throws synchronously before notification or enqueue. + * Queue an ordinary message as its own FIFO-ordered turn and wake the driver. + * Content, resolved source, and attached contexts are detached, validated, + * and frozen together; invalid input throws synchronously before notification + * or enqueue. + * @param content - the prompt content blocks. + * @param options - source, attached contexts, and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - send(content: ContentBlock[], options?: SendOptions): void + followup(content: ContentBlock[], options?: SendOptions): AgentMessageId /** - * Submit steering while the agent is `running`. An open turn records it at - * the next steering checkpoint before a request or continuation decision; - * policy may stop before another step. After turn close and its checkpoint, - * any remainder is queued for a later turn; terminal `agent/turn-stop`, - * cancellation, or disposal may discard it. Uses the same synchronous - * snapshot-and-validation boundary as {@link send}; when idle, delegates to it. + * Queue an ordinary message without waking an idle driver. The item retains + * FIFO order and is claimed only after another input wakes the driver. A lone + * queued item leaves `whenIdle()` resolved. + * @param content - the prompt content blocks. + * @param options - source, attached contexts, and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - steer(content: ContentBlock[], options?: SendOptions): void + queue(content: ContentBlock[], options?: SendOptions): AgentMessageId + + /** + * Submit steering into the running turn and request another step. An open turn + * records it at the next steering checkpoint before a request or continuation + * decision; policy may stop before another step. After turn close and its + * checkpoint, any remainder is queued for a later turn; terminal + * `agent/turn-stop`, cancellation, or disposal may discard it. Idle steering + * becomes a waking ordinary turn. + * @param content - the steering content blocks. + * @param options - source, attached contexts, and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. + */ + steer(content: ContentBlock[], options?: SendOptions): AgentMessageId /** * Append detached model-facing context without running the model. An open-turn * injection joins at the current log position unless the current tool batch is - * executing; then it waits FIFO until that batch settles and drains before turn - * close even when interrupted. Idle injection uses a one-shot turn and durability - * checkpoint. Disposal awaits idle checkpoints; flush failures report through `agent/error`. + * executing; then it waits FIFO until that batch settles and drains before + * turn close even when interrupted. Idle injection uses a one-shot turn and + * durability checkpoint. Disposal awaits idle checkpoints; flush failures + * report through `agent/error`. An omitted source defaults to + * `{ kind: 'plugin', plugin: '' }`. + * @param content - the injected context content blocks. + * @param options - source and durable model-hidden meta. + * @returns the accepted injection's {@link AgentMessageId}; injection emits no `agent/inbox/*` events. */ - inject(content: ContentBlock[], options?: InjectOptions): void + inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId /** - * Clear all queued and steering work, including items waiting to start, and - * abort the active turn. An effective call first emits - * `agent/cancel-requested` with the resolved typed cause. The first cause wins - * for the active turn, and `whenIdle()` resolves after cancellation reaches - * quiescence. Omission means `{ kind: 'user' }`. Idle cancellation is a no-op - * and does not arm later work. The active turn snapshots and freezes the cause. - * @param cause - the stable caller intent carried by the current turn signal. + * Accept one fully specified input through the same snapshot and routing path + * as the four intent-named helpers. `next-turn` targets the ordinary FIFO; + * `next-step`/wakeup targets steering (falling back to an ordinary waking turn + * while idle); and `next-step` without wakeup injects durable context without + * running the model. Every field is mandatory and no source or routing default + * is applied. Invalid input throws synchronously before notification, enqueue, + * or append. + * @param input - the resolved content, attribution, context, metadata, and routing facts. + * @returns the accepted input's {@link AgentMessageId}, carried by FIFO lifecycle events when applicable. */ - cancel(cause?: AgentCancelCause): void + send(input: ResolvedAgentInput): AgentMessageId + + /** + * Clear queued and steering work — unless `keepInbox` — and abort the active + * turn. An effective call first emits `agent/cancel-requested` with the + * resolved typed cause. The first cause wins for the active turn, and + * `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause + * means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm + * later work. The active turn snapshots and freezes the cause. + * @param cause - the stable caller intent carried by the current turn signal. + * @param options - cancellation options; `keepInbox` preserves pending work. + */ + cancel(cause?: AgentCancelCause, options?: CancelOptions): void /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ whenIdle(): Promise - } ``` @@ -464,7 +574,7 @@ The process-local initiator carried by `ctx.agents` is the exact `Agent` above, ## Interception decisions -Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext`, which carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its `content` reaches the model verbatim as user-role input, while JSON `meta` persists plugin state without exposing it to the model. Absent or `separate` placement becomes `context/message`; `prompt-prefix` placement is available to prompt and steering inbox attachments and bakes the context before the effective request in the same message. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance, metadata, and placement. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape. +Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext`, which carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its `content` reaches the model verbatim as user-role input, while JSON `meta` persists plugin state without exposing it to the model. Absent or `separate` placement becomes an injected `user/message` (plugin/goal source); `prompt-prefix` placement is available to prompt and steering inbox attachments and bakes the context before the effective request in the same message. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance, metadata, and placement. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape. Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) @@ -474,8 +584,8 @@ interface HookContext { content: ContentBlock[] source: MessageSource /** - * Model placement. Absent or `separate` records an independent - * `context/message`; `prompt-prefix` prepends this context and a stable + * Model placement. Absent or `separate` records an independent injected + * `user/message`; `prompt-prefix` prepends this context and a stable * request delimiter to the same user-role message as its attached prompt. */ placement?: 'separate' | 'prompt-prefix' diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 157fcfb040..d0f67983b9 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -333,7 +333,7 @@ interface LlmCallConfig { * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, - * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * `assistant/message`, `tool/result`, `steering/message`). * Non-surface events (boundary markers, chunks, usage, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` * call sites. @@ -361,7 +361,7 @@ type SessionEvent = { }[T] ``` -十四种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`prompt/blocked`、`context/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及轮次封闭不变量都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 +十三种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`prompt/blocked`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及轮次封闭不变量都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 @@ -373,8 +373,9 @@ type SessionEvent = { ```ts type-equiv /** - * Message options. An omitted source attests direct human input as `{ kind: 'user' }` - * and may authorize policy consumers, so non-human producers must label their content. + * Options for {@link Agent.followup}, {@link Agent.queue}, and {@link Agent.steer}. + * An omitted source attests direct human input as `{ kind: 'user' }` and may + * authorize policy consumers, so non-human producers must label their content. */ interface SendOptions { source?: MessageSource @@ -384,19 +385,90 @@ interface SendOptions { * records them directly at its next checkpoint. */ contexts?: HookContext[] + /** Opaque JSON state retained on the durable message but hidden from the model. */ + meta?: JsonValue } ``` -`InjectOptions` 接受普通消息归属信息和对模型隐藏的持久 JSON 元数据。附加上下文只属于排队输入或 steering(中途引导)输入,因此合成注入不接受这类上下文: - ```ts type-equiv /** Options specific to durable synthetic context injection. */ -interface InjectOptions extends Omit { - /** Opaque JSON state retained in the session event but hidden from the model. */ +interface InjectOptions { + /** Defaults to `{ kind: 'plugin', plugin: '' }`; non-human producers should identify themselves. */ + source?: MessageSource + /** Opaque JSON state retained on the durable message but hidden from the model. */ meta?: JsonValue } ``` +高级接收形式会显式给出所有默认值,并禁止为注入附加上下文: + +```ts type-equiv +/** + * Fully specified input for {@link Agent.send}. Unlike the intent-named + * helpers, this form applies no defaults: callers provide content, source, + * contexts, metadata (including explicit `undefined`), target, and wakeup. + * The union excludes attached contexts from non-waking next-step injection. + */ +type ResolvedAgentInput = { + content: ContentBlock[] + source: MessageSource + meta: JsonValue | undefined +} & ( + | { target: 'next-turn'; wakeup: boolean; contexts: HookContext[] } + | { target: 'next-step'; wakeup: true; contexts: HookContext[] } + | { target: 'next-step'; wakeup: false; contexts: [] } +) +``` + +FIFO 投递方法返回不透明的 `AgentMessageId`,该 id 在同一条消息的各个 `agent/inbox/*` 事件中保持稳定。注入也返回 id,但会绕过这些事件: + +```ts type-equiv +/** + * Opaque id assigned to one accepted agent input. FIFO inputs carry the same id + * on their `agent/inbox/*` events; injection bypasses those events. + */ +type AgentMessageId = Branded<'AgentMessageId'> +``` + +`agent/inbox/*` 实时事件承载一条已接收的消息;注入绕过两个 FIFO,从不出现在这些事件中: + +```ts type-equiv +/** + * One accepted FIFO message, carried by the `agent/inbox/*` live events. `id` + * is the value returned by the accepting helper or {@link Agent.send}, + * stable across this message's enqueue, dequeue, and discard events. Source + * defaults, when applicable, are already applied, so these are the exact values + * the item was accepted with. + * `steering` is true for an item drained between steps; otherwise it is claimed + * at a turn boundary. `SendOptions.meta` is intentionally omitted: it is durable + * model-hidden state that lands on the eventual `user/message`/ + * `steering/message`, not live-event routing data. + */ +interface AgentMessage { + /** The id returned by the accepting helper or {@link Agent.send}. */ + id: AgentMessageId + content: ContentBlock[] + source: MessageSource + contexts: HookContext[] + /** Whether the item joined the steering FIFO rather than the queued FIFO. */ + steering: boolean + /** Whether the item wakes the driver or requests another step. */ + wakeup: boolean +} +``` + +```ts type-equiv +/** Options for {@link Agent.cancel}. */ +interface CancelOptions { + /** + * Preserve queued and steering inbox items instead of discarding them. The + * active turn is still aborted, but un-started and pending work survives for a + * later turn and no `agent/inbox/discard` fires. + */ + keepInbox?: boolean +} +``` + ```ts type-equiv /** Stable runtime cause accepted by {@link Agent.cancel}. */ type AgentCancelCause = @@ -404,59 +476,97 @@ type AgentCancelCause = | { readonly kind: 'parent' } ``` +结构化 `Agent` 接口公开四个按意图命名的辅助方法,以及接受完全解析输入的方法。具体驱动器只需实现一次这套路由矩阵,每个辅助方法提供其固定路由与默认值。 + ```ts type-equiv /** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ interface Agent { /** The single identity shared with {@link session}. */ readonly id: SessionId + /** The provider route and model this agent's requests use. */ readonly options: AgentOptions + /** The live session this agent drives; its log is the durable source of truth. */ readonly session: Session + /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context /** - * Queue one detached, frozen lossless-JSON item. If claimed, it is the sole - * ordinary message in its FIFO-ordered turn; the next claimed item waits for - * that turn's checkpoint. - * Attached contexts share the same snapshot and ownership boundary. Invalid - * input throws synchronously before notification or enqueue. + * Queue an ordinary message as its own FIFO-ordered turn and wake the driver. + * Content, resolved source, and attached contexts are detached, validated, + * and frozen together; invalid input throws synchronously before notification + * or enqueue. + * @param content - the prompt content blocks. + * @param options - source, attached contexts, and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - send(content: ContentBlock[], options?: SendOptions): void + followup(content: ContentBlock[], options?: SendOptions): AgentMessageId /** - * Submit steering while the agent is `running`. An open turn records it at - * the next steering checkpoint before a request or continuation decision; - * policy may stop before another step. After turn close and its checkpoint, - * any remainder is queued for a later turn; terminal `agent/turn-stop`, - * cancellation, or disposal may discard it. Uses the same synchronous - * snapshot-and-validation boundary as {@link send}; when idle, delegates to it. + * Queue an ordinary message without waking an idle driver. The item retains + * FIFO order and is claimed only after another input wakes the driver. A lone + * queued item leaves `whenIdle()` resolved. + * @param content - the prompt content blocks. + * @param options - source, attached contexts, and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - steer(content: ContentBlock[], options?: SendOptions): void + queue(content: ContentBlock[], options?: SendOptions): AgentMessageId + + /** + * Submit steering into the running turn and request another step. An open turn + * records it at the next steering checkpoint before a request or continuation + * decision; policy may stop before another step. After turn close and its + * checkpoint, any remainder is queued for a later turn; terminal + * `agent/turn-stop`, cancellation, or disposal may discard it. Idle steering + * becomes a waking ordinary turn. + * @param content - the steering content blocks. + * @param options - source, attached contexts, and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. + */ + steer(content: ContentBlock[], options?: SendOptions): AgentMessageId /** * Append detached model-facing context without running the model. An open-turn * injection joins at the current log position unless the current tool batch is - * executing; then it waits FIFO until that batch settles and drains before turn - * close even when interrupted. Idle injection uses a one-shot turn and durability - * checkpoint. Disposal awaits idle checkpoints; flush failures report through `agent/error`. + * executing; then it waits FIFO until that batch settles and drains before + * turn close even when interrupted. Idle injection uses a one-shot turn and + * durability checkpoint. Disposal awaits idle checkpoints; flush failures + * report through `agent/error`. An omitted source defaults to + * `{ kind: 'plugin', plugin: '' }`. + * @param content - the injected context content blocks. + * @param options - source and durable model-hidden meta. + * @returns the accepted injection's {@link AgentMessageId}; injection emits no `agent/inbox/*` events. */ - inject(content: ContentBlock[], options?: InjectOptions): void + inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId /** - * Clear all queued and steering work, including items waiting to start, and - * abort the active turn. An effective call first emits - * `agent/cancel-requested` with the resolved typed cause. The first cause wins - * for the active turn, and `whenIdle()` resolves after cancellation reaches - * quiescence. Omission means `{ kind: 'user' }`. Idle cancellation is a no-op - * and does not arm later work. The active turn snapshots and freezes the cause. - * @param cause - the stable caller intent carried by the current turn signal. + * Accept one fully specified input through the same snapshot and routing path + * as the four intent-named helpers. `next-turn` targets the ordinary FIFO; + * `next-step`/wakeup targets steering (falling back to an ordinary waking turn + * while idle); and `next-step` without wakeup injects durable context without + * running the model. Every field is mandatory and no source or routing default + * is applied. Invalid input throws synchronously before notification, enqueue, + * or append. + * @param input - the resolved content, attribution, context, metadata, and routing facts. + * @returns the accepted input's {@link AgentMessageId}, carried by FIFO lifecycle events when applicable. */ - cancel(cause?: AgentCancelCause): void + send(input: ResolvedAgentInput): AgentMessageId + + /** + * Clear queued and steering work — unless `keepInbox` — and abort the active + * turn. An effective call first emits `agent/cancel-requested` with the + * resolved typed cause. The first cause wins for the active turn, and + * `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause + * means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm + * later work. The active turn snapshots and freezes the cause. + * @param cause - the stable caller intent carried by the current turn signal. + * @param options - cancellation options; `keepInbox` preserves pending work. + */ + cancel(cause?: AgentCancelCause, options?: CancelOptions): void /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ whenIdle(): Promise - } ``` @@ -472,7 +582,7 @@ cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancella ## 拦截决策 -每个 `agent/*` 拦截 waterfall 都返回一个小型、特定于 seam 的类型化联合——统一的 Decision 惯用形状([tools.md](tools.md) 中工具 seam 的 `PreToolDecision`/`PostToolDecision` 也采用相同形状)。CC/Codex 钩子桥接层把其 `permissionDecision`/`decision`/`continue`/`additionalContext` 字段映射到这些联合上;原生插件则直接返回它们。提示词决策与工具后决策共享一种面向模型的上下文形状 `HookContext`,它必须携带 `source`(缺少 source 会默认成 `{kind:'user'}`,从而把插件上下文错标为用户提示词)。其中的 `content` 作为 user-role 输入逐字到达模型,而 JSON `meta` 持久保存插件状态但不向模型暴露。未指定放置方式或指定为 `separate` 时,上下文会成为 `context/message`;`prompt-prefix` 放置方式可用于提示词和 steering 收件箱附件,会在同一条消息中把上下文置于最终生效的请求之前。两种决策都携带 `additionalContexts[]`,使每一项保留各自的 provenance、元数据与放置方式。Continuation reason 则是 steering 消息,并有意使用更窄的 content/source 形状。 +每个 `agent/*` 拦截 waterfall 都返回一个小型、特定于 seam 的类型化联合——统一的 Decision 惯用形状([tools.md](tools.md) 中工具 seam 的 `PreToolDecision`/`PostToolDecision` 也采用相同形状)。CC/Codex 钩子桥接层把其 `permissionDecision`/`decision`/`continue`/`additionalContext` 字段映射到这些联合上;原生插件则直接返回它们。提示词决策与工具后决策共享一种面向模型的上下文形状 `HookContext`,它必须携带 `source`(缺少 source 会默认成 `{kind:'user'}`,从而把插件上下文错标为用户提示词)。其中的 `content` 作为 user-role 输入逐字到达模型,而 JSON `meta` 持久保存插件状态但不向模型暴露。未指定放置方式或指定为 `separate` 时,上下文会成为一条注入的 `user/message`(来源类别为插件或 goal);`prompt-prefix` 放置方式可用于提示词和 steering 收件箱附件,会在同一条消息中把上下文置于最终生效的请求之前。两种决策都携带 `additionalContexts[]`,使每一项保留各自的 provenance、元数据与放置方式。Continuation reason 则是 steering 消息,并有意使用更窄的 content/source 形状。 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) @@ -482,8 +592,8 @@ interface HookContext { content: ContentBlock[] source: MessageSource /** - * Model placement. Absent or `separate` records an independent - * `context/message`; `prompt-prefix` prepends this context and a stable + * Model placement. Absent or `separate` records an independent injected + * `user/message`; `prompt-prefix` prepends this context and a stable * request delimiter to the same user-role message as its attached prompt. */ placement?: 'separate' | 'prompt-prefix' diff --git a/docs/core-data-structures/goal.md b/docs/core-data-structures/goal.md index d45847ba0f..c0351f2f77 100644 --- a/docs/core-data-structures/goal.md +++ b/docs/core-data-structures/goal.md @@ -69,7 +69,7 @@ interface GoalView extends GoalSnapshot { ## Durable changes -Every mutation is a `context/message` whose metadata is either a complete snapshot or a clear tombstone. The version, metadata, goal source, and verbatim rendered content form one replay invariant. +Every mutation is a round-zero goal-sourced `user/message` whose metadata is either a complete snapshot or a clear tombstone. The version, metadata, goal source, and verbatim rendered content form one replay invariant. ```ts type-equiv /** Full-snapshot goal mutation retained in a model-visible context event. */ diff --git a/docs/core-data-structures/session-reference.md b/docs/core-data-structures/session-reference.md index dbe3c43d35..d9a73c08c6 100644 --- a/docs/core-data-structures/session-reference.md +++ b/docs/core-data-structures/session-reference.md @@ -36,7 +36,7 @@ interface SessionReferenceCandidate { ## Prepared messages -Preparation preserves readable current-message content and returns at most one aggregated context. The host binds `contexts` to that exact `send()` or `steer()` call. +Preparation preserves readable current-message content and returns at most one aggregated context. The host binds `contexts` to that exact `followup()` or `steer()` call. ```ts type-equiv /** Message payload and the zero-or-one durable snapshot contexts bound to it. */ diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index b173cb3506..f215956acf 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -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 -session.md: b342e1c5c3bff030d67c61a6f1daa0c8167182c1 -session.zh.md: 9d221c9bfd7b3e8e60bd964fc0a55b439057e2c6 +session.md: 7a9ae919fecef7db19f3ca67feb128bcba1db4f0 +session.zh.md: 9e1e9a4f39ed6919416ff5cebf345b43bbc450ea diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index b342e1c5c3..7a9ae919fe 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -11,7 +11,13 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the [compaction seam](compaction.md) adds `compact/start` / `compact/summary` / `compact/end`, and `@deepseek-ai/dsh-hook-protocol` adds log-only `hook/invoked` / `hook/result` provenance for a hook bridge. Like `compact/*`, these are NOT `SurfaceEventType`s (no `surfaceOp`). The generated [persistence log event catalog](../persistence-catalog.md) enumerates every member — core and merged — with its payload, surface badge, and declaration site. ```ts type-equiv -/** Shared payload for ordinary and steering prompt messages. */ +/** + * Shared payload for user, injected-context, and steering prompt messages. A + * direct human prompt, a synthetic `agent.inject()` context, and mid-turn + * steering all project into the model transcript as verbatim user-role content; + * they are told apart by `source` (a non-`user` kind marks injected context), + * not by event type. `meta` carries durable model-hidden producer state. + */ interface PromptMessageData { /** Exact model-facing blocks, including any baked prompt-prefix contexts. */ content: ContentBlock[] @@ -19,6 +25,15 @@ interface PromptMessageData { source: MessageSource /** Present only when prompt-prefix contexts were baked into `content`. */ envelope?: PromptMessageEnvelope + /** + * Opaque durable JSON state retained on the event but hidden from the model + * projection. It is the intended channel for a future framing directive (a + * producer declares the frame, a dedicated renderer applies it — see the + * deferred note in + * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), + * so the surface keeps projecting `content` verbatim rather than wrapping it. + */ + meta?: JsonValue } ``` @@ -48,29 +63,21 @@ interface SessionEventMap { 'step/start': { turn: number; step: number } /** Closes step `step` of turn `turn`. */ 'step/end': { turn: number; step: number } - /** A user-visible prompt (the queued message claimed for this turn). */ + /** + * A user-role message on the model-visible surface: a direct human prompt + * (the queued message claimed for this turn), a synthetic `agent.inject()` + * context (file-change notices, subdir AGENTS.md, skill content, cron + * notifications, …), or an admitted goal continuation round. All three + * project their `content` verbatim; `source` (with a non-`user` kind marking + * injected context) is the only channel that tells them apart. An idle + * injection wraps this event in a one-shot turn so the log stays turn-enclosed. + */ 'user/message': PromptMessageData /** * 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. */ 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } - /** - * In-session context injection (file-change notices, subdir AGENTS.md, - * skill content, cron notifications, …). Rendered into the derived history - * as a synthetic user-role message carrying `content` verbatim — NOT a - * user prompt. `meta` is durable JSON state omitted from the model - * projection; it is also the intended channel for any future framing - * directive (a producer declares the frame, a dedicated renderer applies it — - * see the deferred note in - * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), - * so the surface keeps projecting `content` verbatim rather than wrapping it. - */ - 'context/message': { - content: ContentBlock[] - source: MessageSource - meta?: JsonValue - } /** Raw stream chunk — token-level replay fidelity. */ 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } /** @@ -201,7 +208,7 @@ A proper discriminated union over `type` (not independent `type`/`data` unions), * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, - * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * `assistant/message`, `tool/result`, `steering/message`). * Non-surface events (boundary markers, chunks, usage, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` * call sites. @@ -235,7 +242,7 @@ For `assistant/message`, a present `sourceEventSeqs: []` is a complete known-emp ## Surface types -The five message-producing types (`SurfaceEventType` — `user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`) carry surface metadata declaring how they join the ordered derived surface. See the [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md). +The four message-producing types (`SurfaceEventType` — `user/message`, `assistant/message`, `tool/result`, `steering/message`) carry surface metadata declaring how they join the ordered derived surface. See the [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md). ### `SurfaceEventType` — the message-producing subset of event types @@ -249,7 +256,6 @@ type SurfaceEventType = | 'user/message' | 'assistant/message' | 'tool/result' - | 'context/message' | 'steering/message' ``` @@ -260,7 +266,7 @@ type SurfaceEventType = * How a session event entered the ordered surface. Only valid on * {@link SurfaceEventType} events. * - * - `'append'`: added to the tail — normal path for user/assistant/tool/context + * - `'append'`: added to the tail — normal path for user/assistant/tool/steering * messages. * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` * (inclusive) through `end` (inclusive) with this node. Both must exist as @@ -467,7 +473,7 @@ declare class Session { - `user/message` → a user message carrying exact `content`; an optional envelope remains log-only display metadata. - `assistant/message` → an assistant message with the event's provider/model provenance and optional adapter-private replay state. Raw `assistant/chunk` events are replay/UI data and are **skipped** in derivation (the assembled message is authoritative). An **empty-content** `assistant/message` is also skipped — a max-tokens step cut off with no content still records an `assistant/message` to host its usage/provenance, but a content-less assistant turn must not enter the provider transcript. - `tool/result` → a user message carrying a `tool-result` block. -- `context/message` → a user-role message carrying its `content` verbatim at its chronological position. Optional JSON `meta` remains in the event log and is never rendered. +- `user/message` (injected context, i.e. non-`user` source) → a user-role message carrying its `content` verbatim at its chronological position. Optional JSON `meta` remains in the event log and is never rendered. - `steering/message` → a user-role message carrying exact `content` at its chronological position; an optional envelope remains log-only display metadata. Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and does not project into a message. Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message, so their usage chunk is the durable accounting record. An operational error's step number is on `turn/end.reason` for `kind: 'error'`, with normalized `LlmFailure` facts for a final model-request failure and message/code for other live errors. Because this unreleased format intentionally has no compatibility promise, seed/load validation rejects request headers without provider+model and assistant messages without provider/model provenance instead of guessing a route for historical data. @@ -491,11 +497,12 @@ interface TurnTriggerMap { message: { kind: 'message'; source: MessageSource } /** * An out-of-band context injection (`agent.inject()`) made while the agent - * was idle. The loop wraps the injected `context/message` in a one-shot turn - * (`turn/start` → `context/message` → `turn/end`) so every event in the log - * stays turn-enclosed — the durability/replay boundary is the turn, and a - * bare event between turns would otherwise be indistinguishable from a crash - * tail on reload. + * was idle. The loop wraps the injected `user/message` (a non-`user` source, + * plugin by default) in a one-shot turn (`turn/start` → `user/message` → + * `turn/end`) so every event in the log stays turn-enclosed — the + * durability/replay boundary is the turn, and a bare event between turns would + * otherwise be indistinguishable from a crash tail on reload. The trigger's + * `source` mirrors that message's producer. */ injection: { kind: 'injection'; source: MessageSource } } @@ -544,13 +551,13 @@ interface TurnEndReasonMap { ## The turn-enclosure invariant -Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn, and `appendOutOfBand()` similarly wraps an eligible log-only event when no turn is open. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The optional `dsh-session/invariant` companion enforces it in dev through `ctx.invariants` (a message event outside an open turn throws). See [the turn-enclosure invariant Agent Note](../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). +Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, an idle `agent.inject()` wraps its `user/message` in a one-shot `injection` turn, and `appendOutOfBand()` similarly wraps an eligible log-only event when no turn is open. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The optional `dsh-session/invariant` companion enforces it in dev through `ctx.invariants` (a message event outside an open turn throws). See [the turn-enclosure invariant Agent Note](../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). ## Plugin-contributed log-only events A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history), but, like every event, they must sit inside an open turn. The full per-event enumeration — core and plugin-contributed alike, with payloads and provenance — is the generated [persistence log event catalog](../persistence-catalog.md); the compaction seam's `compact/*` semantics are discussed on [compaction.md](compaction.md). -The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record — its injected `context/message` is the durable evidence — because it has no open turn to enclose one (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)). +The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record — its injected `user/message` is the durable evidence — because it has no open turn to enclose one (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)). ## Durability contract diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 9d221c9bfd..9e1e9a4f39 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -11,7 +11,13 @@ 仅追加的事件类型。可通过声明合并扩展:插件通过 declaration merging 声明额外的事件类型。例如[压缩(compaction) seam](compaction.md) 添加了 `compact/start` / `compact/summary` / `compact/end`,`@deepseek-ai/dsh-hook-protocol` 添加了仅记录日志的 `hook/invoked` / `hook/result` 溯源事件,用于钩子桥接。与 `compact/*` 一样,这些都不是 `SurfaceEventType`(没有 `surfaceOp`)。生成的[持久化日志事件目录](../persistence-catalog.md)列举了所有成员(核心与合并扩展的),包含其 payload、surface 标记与声明位置。 ```ts type-equiv -/** Shared payload for ordinary and steering prompt messages. */ +/** + * Shared payload for user, injected-context, and steering prompt messages. A + * direct human prompt, a synthetic `agent.inject()` context, and mid-turn + * steering all project into the model transcript as verbatim user-role content; + * they are told apart by `source` (a non-`user` kind marks injected context), + * not by event type. `meta` carries durable model-hidden producer state. + */ interface PromptMessageData { /** Exact model-facing blocks, including any baked prompt-prefix contexts. */ content: ContentBlock[] @@ -19,6 +25,15 @@ interface PromptMessageData { source: MessageSource /** Present only when prompt-prefix contexts were baked into `content`. */ envelope?: PromptMessageEnvelope + /** + * Opaque durable JSON state retained on the event but hidden from the model + * projection. It is the intended channel for a future framing directive (a + * producer declares the frame, a dedicated renderer applies it — see the + * deferred note in + * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), + * so the surface keeps projecting `content` verbatim rather than wrapping it. + */ + meta?: JsonValue } ``` @@ -48,29 +63,21 @@ interface SessionEventMap { 'step/start': { turn: number; step: number } /** Closes step `step` of turn `turn`. */ 'step/end': { turn: number; step: number } - /** A user-visible prompt (the queued message claimed for this turn). */ + /** + * A user-role message on the model-visible surface: a direct human prompt + * (the queued message claimed for this turn), a synthetic `agent.inject()` + * context (file-change notices, subdir AGENTS.md, skill content, cron + * notifications, …), or an admitted goal continuation round. All three + * project their `content` verbatim; `source` (with a non-`user` kind marking + * injected context) is the only channel that tells them apart. An idle + * injection wraps this event in a one-shot turn so the log stays turn-enclosed. + */ 'user/message': PromptMessageData /** * 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. */ 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } - /** - * In-session context injection (file-change notices, subdir AGENTS.md, - * skill content, cron notifications, …). Rendered into the derived history - * as a synthetic user-role message carrying `content` verbatim — NOT a - * user prompt. `meta` is durable JSON state omitted from the model - * projection; it is also the intended channel for any future framing - * directive (a producer declares the frame, a dedicated renderer applies it — - * see the deferred note in - * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), - * so the surface keeps projecting `content` verbatim rather than wrapping it. - */ - 'context/message': { - content: ContentBlock[] - source: MessageSource - meta?: JsonValue - } /** Raw stream chunk — token-level replay fidelity. */ 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } /** @@ -203,7 +210,7 @@ interface EpochHeader { * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, - * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * `assistant/message`, `tool/result`, `steering/message`). * Non-surface events (boundary markers, chunks, usage, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` * call sites. @@ -237,7 +244,7 @@ type SessionEvent = { ## Surface 类型 -五种产生消息的类型(`SurfaceEventType`:`user/message`、`assistant/message`、`tool/result`、`context/message`、`steering/message`)携带 surface 元数据,用来声明它们如何加入有序的派生 surface。见 [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)。 +四种产生消息的类型(`SurfaceEventType`:`user/message`、`assistant/message`、`tool/result`、`steering/message`)携带 surface 元数据,用来声明它们如何加入有序的派生 surface。见 [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)。 ### `SurfaceEventType`:事件类型中产生消息的子集 @@ -251,7 +258,6 @@ type SurfaceEventType = | 'user/message' | 'assistant/message' | 'tool/result' - | 'context/message' | 'steering/message' ``` @@ -262,7 +268,7 @@ type SurfaceEventType = * How a session event entered the ordered surface. Only valid on * {@link SurfaceEventType} events. * - * - `'append'`: added to the tail — normal path for user/assistant/tool/context + * - `'append'`: added to the tail — normal path for user/assistant/tool/steering * messages. * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` * (inclusive) through `end` (inclusive) with this node. Both must exist as @@ -469,7 +475,7 @@ declare class Session { - `user/message` → 一条携带确切 `content` 的 user 消息;可选 envelope 仅作为日志中的展示元数据保留。 - `assistant/message` → 一条 assistant 消息,包含事件的提供方/模型溯源信息和可选的适配器私有回放状态。原始 `assistant/chunk` 事件属于回放/UI 数据,在派生时会被**跳过**(组装后的消息才是权威)。**内容为空的** `assistant/message` 也会跳过:因 max-tokens 而截断且无内容的步骤仍会记录一条 `assistant/message` 以承载用量和溯源信息,但无内容的 assistant 轮次不得进入提供方 transcript。 - `tool/result` → 一条携带 `tool-result` 块的 user 消息。 -- `context/message` → 按时间顺序在相应位置生成一条 user-role 消息,并原样承载其 `content`。可选的 JSON `meta` 保留在事件日志中,绝不渲染。 +- `user/message`(注入上下文,即非 `user` 来源)→ 按时间顺序在相应位置生成一条 user-role 消息,并原样承载其 `content`。可选的 JSON `meta` 保留在事件日志中,绝不渲染。 - `steering/message` → 按时间顺序在相应位置生成一条携带确切 `content` 的 user-role 消息;可选 envelope 仅作为日志中的展示元数据保留。 其余所有事件(`turn/*`、`step/*`、插件所有的 `llm/retry`)均为结构信息,不会投影为消息。token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息,因此其用量分片是持久化的记账记录。操作错误的步骤号记录在 `turn/end.reason`(`kind: 'error'`)中;如果是最终模型请求失败,其中包含规范化的 `LlmFailure` 事实,其他实时错误则包含消息/代码。由于这一尚未发布的格式有意不提供兼容性承诺,seed/load 校验会拒绝缺少提供方和模型的请求头,以及缺少提供方/模型溯源信息的 assistant 消息,而不会猜测历史数据应走的提供方路由。 @@ -493,11 +499,12 @@ interface TurnTriggerMap { message: { kind: 'message'; source: MessageSource } /** * An out-of-band context injection (`agent.inject()`) made while the agent - * was idle. The loop wraps the injected `context/message` in a one-shot turn - * (`turn/start` → `context/message` → `turn/end`) so every event in the log - * stays turn-enclosed — the durability/replay boundary is the turn, and a - * bare event between turns would otherwise be indistinguishable from a crash - * tail on reload. + * was idle. The loop wraps the injected `user/message` (a non-`user` source, + * plugin by default) in a one-shot turn (`turn/start` → `user/message` → + * `turn/end`) so every event in the log stays turn-enclosed — the + * durability/replay boundary is the turn, and a bare event between turns would + * otherwise be indistinguishable from a crash tail on reload. The trigger's + * `source` mirrors that message's producer. */ injection: { kind: 'injection'; source: MessageSource } } @@ -548,13 +555,13 @@ interface TurnEndReasonMap { ## 轮次封闭不变式 -每个会话事件都位于一个轮次**之内**(在 `turn/start` 和对应的 `turn/end` 之间)。loop 在 `turn/start` *之后*追加已排队的 `user/message` 事件;空闲时的 `agent.inject()` 会用一次性的 `injection` 轮次包住其 `context/message`;没有打开的轮次时,`appendOutOfBand()` 同样会用一个轮次包住符合条件的仅日志事件。这使轮次成为唯一的持久性/回放边界:后端可以将最后一个 `turn/end` 之后的任何内容视为崩溃中断尾部,而不会丢失合法记录在轮次之间的上下文。可选的 `dsh-session/invariant` 配套插件通过 `ctx.invariants` 在开发环境中强制此不变式(消息事件若位于打开的轮次之外便会抛出)。见[轮次封闭不变式 Agent Note](../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)。 +每个会话事件都位于一个轮次**之内**(在 `turn/start` 和对应的 `turn/end` 之间)。loop 在 `turn/start` *之后*追加已排队的 `user/message` 事件;空闲时的 `agent.inject()` 会用一次性的 `injection` 轮次包住其 `user/message`;没有打开的轮次时,`appendOutOfBand()` 同样会用一个轮次包住符合条件的仅日志事件。这使轮次成为唯一的持久性/回放边界:后端可以将最后一个 `turn/end` 之后的任何内容视为崩溃中断尾部,而不会丢失合法记录在轮次之间的上下文。可选的 `dsh-session/invariant` 配套插件通过 `ctx.invariants` 在开发环境中强制此不变式(消息事件若位于打开的轮次之外便会抛出)。见[轮次封闭不变式 Agent Note](../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)。 ## 插件贡献的仅日志事件 插件可以通过 declaration merging 添加额外的 `SessionEventMap` 类型。这些是**仅日志**事件:不是 `SurfaceEventType`(不携带 `surfaceOp`,不参与派生历史),但与所有事件一样,必须位于一个打开的轮次内。完整的逐事件枚举(核心与插件贡献的,含 payload 与溯源信息)见生成的[持久化日志事件目录](../persistence-catalog.md);压缩 seam 的 `compact/*` 语义在 [compaction.md](compaction.md) 中讨论。 -钩子桥接层的 `hook/invoked` / `hook/result` 溯源对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。轮次中间的钩子点(`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`)在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 不生成 `hook/*` 记录:它注入的 `context/message` 已是持久证据,而且当时没有已打开的轮次可容纳该记录(见[钩子桥接 Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md))。 +钩子桥接层的 `hook/invoked` / `hook/result` 溯源对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。轮次中间的钩子点(`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`)在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 不生成 `hook/*` 记录:它注入的 `user/message` 已是持久证据,而且当时没有已打开的轮次可容纳该记录(见[钩子桥接 Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md))。 ## 持久性契约 diff --git a/docs/defensive-patterns.i18n.yaml b/docs/defensive-patterns.i18n.yaml index acdfaf96df..96c938a74f 100644 --- a/docs/defensive-patterns.i18n.yaml +++ b/docs/defensive-patterns.i18n.yaml @@ -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 -defensive-patterns.md: 349b916df6f7544300dacd578acf42668d9436ac -defensive-patterns.zh.md: a6bbe317b220e31cce0e1bfc7c3b7c87d2d5cf46 +defensive-patterns.md: c69094db461048f5dbca5f8bdd1fb5581b08a962 +defensive-patterns.zh.md: eb57f035ad0bd67e62e285d451502d41e4efc2bc diff --git a/docs/defensive-patterns.md b/docs/defensive-patterns.md index 349b916df6..c69094db46 100644 --- a/docs/defensive-patterns.md +++ b/docs/defensive-patterns.md @@ -14,7 +14,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`) 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. +`agent.followup()` 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-follow-up result: several queued follow-ups 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 diff --git a/docs/defensive-patterns.zh.md b/docs/defensive-patterns.zh.md index a6bbe317b2..eb57f035ad 100644 --- a/docs/defensive-patterns.zh.md +++ b/docs/defensive-patterns.zh.md @@ -14,7 +14,7 @@ ## 异步状态不是同步状态 -`agent.send()` 不会在返回前翻转状态;后台任务的完成与轮次边界存在竞争;`reader.close()` 在 EOF 和 dispose(资源释放)两种情况下都会触发。切勿基于一个刚刚请求的状态来控制流程——应以实际触发的事件/promise(`agent/status`、`task.done`)驱动生命周期,并观察状态转换(先看到 `running` 再看到 `idle`),而不是把状态当作单次发送的结果:多个排队发送会在同一个 `running` 区间内连续运行多个轮次,而取消或资源释放可能丢弃尚未启动的项。这条守则是双向的:如果等待的转换永远不会发生(EOF 时没有提交过任何工作 → 永远不会进入 `running`),等待就会挂起——请显式处理「无需等待」的分支。 +`agent.followup()` 不会在返回前翻转状态;后台任务的完成与轮次边界存在竞争;`reader.close()` 在 EOF 和 dispose(资源释放)两种情况下都会触发。切勿基于一个刚刚请求的状态来控制流程——应以实际触发的事件/promise(`agent/status`、`task.done`)驱动生命周期,并观察状态转换(先看到 `running` 再看到 `idle`),而不是把状态当作逐次 `followup()` 的结果:多次排队的 `followup()` 会在同一个 `running` 区间内连续运行多个轮次,而取消或资源释放可能丢弃尚未启动的项。这条守则是双向的:如果等待的转换永远不会发生(EOF 时没有提交过任何工作 → 永远不会进入 `running`),等待就会挂起——请显式处理「无需等待」的分支。 ## Dispose 必须达到完全停稳,而不仅仅是请求停止 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 179ad0dcee..b36d390f55 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,22 +8,24 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:217`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:179`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:365`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:315`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:246`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:262`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:207`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:276`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:330`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:291`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:197`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:341`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:352`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:448`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:379`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:395`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:409`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:463`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:424`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:363`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:436`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:474`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:485`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/i18n/style-samples.md b/docs/i18n/style-samples.md index dd970b7c12..b20b2a4c90 100644 --- a/docs/i18n/style-samples.md +++ b/docs/i18n/style-samples.md @@ -28,9 +28,9 @@ **dispose(资源释放)必须等待所有任务完全停稳,不能仅下发终止指令就返回**:如果清理过程只发出终止或中断信号,却不等任务停止就返回,就会留下孤儿进程。清理应采用异步方式,等待所有子任务彻底退出(先发出终止信号,再等待退出);发出信号前应先关闭监听器与通知注册表,使延迟到达的完成事件不再触发通知。测试要证明 dispose 的确等到清理完成:执行完 `await fiber.dispose()` 后进程 PID 立即消失,不能只检查进程最终会自行消亡。 -> **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`) 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. +> **Async state is not synchronous state** — `agent.followup()` 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-follow-up result: several queued follow-ups run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. -**异步状态不等同于同步瞬时状态**:调用 `agent.send()` 不会在返回前同步更新状态;后台任务的完成时间与轮次边界存在竞态;`reader.close()` 既会在读到文件末尾时触发,也会在资源释放时触发。切勿把刚刚发起的状态变更当成已经生效,据此控制流程;生命周期逻辑应以实际触发的事件和已完成的 promise(`agent/status`、`task.done`)为准,并观察完整的状态变化(先 `running`,再 `idle`),不要把状态当作逐次 `send()` 的结果:多次排队的 `send()` 会作为连续轮次运行,但可能共用一个 `running` 区间;取消或资源释放还可能丢弃尚未启动的队列项。 +**异步状态不等同于同步瞬时状态**:调用 `agent.followup()` 不会在返回前同步更新状态;后台任务的完成时间与轮次边界存在竞态;`reader.close()` 既会在读到文件末尾时触发,也会在资源释放时触发。切勿把刚刚发起的状态变更当成已经生效,据此控制流程;生命周期逻辑应以实际触发的事件和已完成的 promise(`agent/status`、`task.done`)为准,并观察完整的状态变化(先 `running`,再 `idle`),不要把状态当作逐次 `followup()` 的结果:多次排队的 `followup()` 会作为连续轮次运行,但可能共用一个 `running` 区间;取消或资源释放还可能丢弃尚未启动的队列项。 ## ③ 测试政策清单 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 8c1d815bc0..973238d905 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -24,14 +24,13 @@ export type SurfaceEventType = | 'user/message' | 'assistant/message' | 'tool/result' - | 'context/message' | 'steering/message' /** * How a session event entered the ordered surface. Only valid on * {@link SurfaceEventType} events. * - * - `'append'`: added to the tail — normal path for user/assistant/tool/context + * - `'append'`: added to the tail — normal path for user/assistant/tool/steering * messages. * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` * (inclusive) through `end` (inclusive) with this node. Both must exist as @@ -51,7 +50,7 @@ export type SurfaceOp = * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, - * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * `assistant/message`, `tool/result`, `steering/message`). * Non-surface events (boundary markers, chunks, usage, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` * call sites. @@ -79,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:360`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:392`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:325`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:367`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:399`](../packages/core/session/src/types.ts) ## Events @@ -151,7 +150,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -167,7 +166,7 @@ Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts) ### `compact/*` @@ -221,33 +220,6 @@ Types: [ContentBlock](core-data-structures/core.md) Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact/src/types.ts) -### `context/*` - -#### `context/message` — surface - -```ts persistence-catalog -/** - * In-session context injection (file-change notices, subdir AGENTS.md, - * skill content, cron notifications, …). Rendered into the derived history - * as a synthetic user-role message carrying `content` verbatim — NOT a - * user prompt. `meta` is durable JSON state omitted from the model - * projection; it is also the intended channel for any future framing - * directive (a producer declares the frame, a dedicated renderer applies it — - * see the deferred note in - * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), - * so the surface keeps projecting `content` verbatim rather than wrapping it. - */ -'context/message': { - content: ContentBlock[] - source: MessageSource - meta?: JsonValue -} -``` - -Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) - -Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts) - ### `hook/*` #### `hook/invoked` — log-only @@ -357,7 +329,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:41`](../packages/plan/plan-mode/s Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:269`](../packages/core/session/src/types.ts) ### `request/*` @@ -371,7 +343,7 @@ Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -427,7 +399,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:44`](../packages 'steering/message': PromptMessageData & { turn: number } ``` -Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:306`](../packages/core/session/src/types.ts) ### `step/*` @@ -438,7 +410,7 @@ Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -447,7 +419,7 @@ Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts) ### `todo/*` @@ -460,7 +432,7 @@ Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) ### `tool/*` @@ -477,7 +449,7 @@ Source: [`packages/core/session/src/types.ts:300`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -531,7 +503,7 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts) ### `turn/*` @@ -549,7 +521,7 @@ Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:250`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -565,15 +537,23 @@ Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/ Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:227`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:243`](../packages/core/session/src/types.ts) ### `user/*` #### `user/message` — surface ```ts persistence-catalog -/** A user-visible prompt (the queued message claimed for this turn). */ +/** + * A user-role message on the model-visible surface: a direct human prompt + * (the queued message claimed for this turn), a synthetic `agent.inject()` + * context (file-change notices, subdir AGENTS.md, skill content, cron + * notifications, …), or an admitted goal continuation round. All three + * project their `content` verbatim; `source` (with a non-`user` kind marking + * injected context) is the only channel that tells them apart. An idle + * injection wraps this event in a one-shot turn so the log stays turn-enclosed. + */ 'user/message': PromptMessageData ``` -Source: [`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:264`](../packages/core/session/src/types.ts) diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 4f9de3644f..c10b0c90a7 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -23,12 +23,12 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | -| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `context/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | +| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `user/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. | | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | -| `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | +| `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflows`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. | diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md index 6904c31dd2..16a4461b6c 100644 --- a/docs/tool-execution-pipeline.md +++ b/docs/tool-execution-pipeline.md @@ -22,7 +22,7 @@ flowchart TD normalized["Registry outer normalization
pipeline/result snapshot throws become isError"] finalize["ToolDefinition.finalizeContent
last content-only invariant"] final["tools/result synchronous notification
frozen authoritative outcome"] - context["Active-batch additionalContexts FIFO
context/message after recorded tool results"] + context["Active-batch additionalContexts FIFO
injected user/message after recorded tool results"] toolResult["Session event: tool/result
single model-facing outcome"] allResults["Tool batch settled
recorded tool/result events complete"] presentResult["UI completed card
presentResult(args, result)"] diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index e35d47cea0..7d014991ed 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -140,11 +140,11 @@ const SCENARIOS: Scenario[] = [ // Keyless, authored (like error-finish/cancel): deterministically forcing a // LIVE model to repeat one call three times is not a stable recording, so // the fixture scripts five identical todo_write calls and pins BOTH reminder - // tiers (gentle at 3, detailed at 5) as context/message in transcript and log. + // tiers (gentle at 3, detailed at 5) as injected user/message in transcript and log. { name: 'repeat-tool-guard', hasModelTurn: true, recorded: false }, // Authored replay: a root AGENTS.md pins the session prefix, then a read in // nested/ discovers its narrower AGENTS.md as a raw, metadata-bearing - // context/message. Both AGENTS.md fixtures are symlinks to a sibling + // injected user/message. Both AGENTS.md fixtures are symlinks to a sibling // AGENTS.canonical.md, so this scenario also guards that discovery follows a // symlinked instruction file to its target's content. The scenario-specific // config keeps home/root discovery hermetic, and the resulting prefix needs @@ -220,7 +220,7 @@ const SCENARIOS: Scenario[] = [ // tool/code-dispatch events. Each overlay composes and pins its own header class. { name: 'code-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'code', configPath: CODE_MODE_CONFIG }, // A nested fs dispatch inside run_code discovers workspace instructions. The - // context/message must follow the outer result while retaining workspace + // injected user/message must follow the outer result while retaining workspace // provenance, which proves Code Mode carries deferred tool context end to end. { name: 'code-mode-workspace-context', diff --git a/examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl b/examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl index 10ffb8c507..cb28e3c646 100644 --- a/examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl +++ b/examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl @@ -688,7 +688,7 @@ {"type":"turn/start","seq":686,"time":1783421455801,"data":{"turn":4,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"approval/policy","seq":687,"time":1783421455801,"data":{"policy":"never"}} {"type":"user/message","seq":688,"time":1783421455801,"data":{"content":[{"type":"text","text":"帮我创建一个 c.md"}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":689,"time":1783421455802,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"approval"}},"surfaceOp":"append"} +{"type":"user/message","seq":689,"time":1783421455802,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"approval"}},"surfaceOp":"append"} {"type":"step/start","seq":690,"time":1783421455802,"data":{"turn":4,"step":1}} {"type":"request/header-delta","seq":691,"time":1783421455802,"data":{"system":{"keepStart":13,"keepEnd":0,"insert":["","Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation."]}}} {"type":"assistant/chunk","seq":692,"time":1783421456825,"data":{"turn":4,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -830,7 +830,7 @@ {"type":"turn/start","seq":828,"time":1783421478599,"data":{"turn":5,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"bash/sandbox-mode","seq":829,"time":1783421478599,"data":{"mode":"workspace-write"}} {"type":"user/message","seq":830,"time":1783421478599,"data":{"content":[{"type":"text","text":"帮我创建一个 d.md"}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":831,"time":1783421478600,"data":{"content":[{"type":"text","text":"The bash sandbox mode changed from \"read-only\" to \"workspace-write\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"tool-bash"}},"surfaceOp":"append"} +{"type":"user/message","seq":831,"time":1783421478600,"data":{"content":[{"type":"text","text":"The bash sandbox mode changed from \"read-only\" to \"workspace-write\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"tool-bash"}},"surfaceOp":"append"} {"type":"step/start","seq":832,"time":1783421478600,"data":{"turn":5,"step":1}} {"type":"request/header-delta","seq":833,"time":1783421478600,"data":{"system":{"keepStart":12,"keepEnd":2,"insert":["Bash commands run under the \"workspace-write\" file sandbox."]}}} {"type":"assistant/chunk","seq":834,"time":1783421479489,"data":{"turn":5,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -1482,7 +1482,7 @@ {"type":"turn/start","seq":1480,"time":1783421524030,"data":{"turn":7,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"approval/policy","seq":1481,"time":1783421524030,"data":{"policy":"ask"}} {"type":"user/message","seq":1482,"time":1783421524030,"data":{"content":[{"type":"text","text":"帮我在 ~ 创建一个 f.md"}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":1483,"time":1783421524030,"data":{"content":[{"type":"text","text":"The approval policy changed from \"never\" to \"ask\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"approval"}},"surfaceOp":"append"} +{"type":"user/message","seq":1483,"time":1783421524030,"data":{"content":[{"type":"text","text":"The approval policy changed from \"never\" to \"ask\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"approval"}},"surfaceOp":"append"} {"type":"step/start","seq":1484,"time":1783421524030,"data":{"turn":7,"step":1}} {"type":"request/header-delta","seq":1485,"time":1783421524030,"data":{"system":{"keepStart":13,"keepEnd":0,"insert":[]}}} {"type":"assistant/chunk","seq":1486,"time":1783421524940,"data":{"turn":7,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -1946,7 +1946,7 @@ {"type":"turn/start","seq":1944,"time":1783421552564,"data":{"turn":9,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"bash/sandbox-mode","seq":1945,"time":1783421552564,"data":{"mode":"danger-full-access"}} {"type":"user/message","seq":1946,"time":1783421552564,"data":{"content":[{"type":"text","text":"创建一个 h.md"}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":1947,"time":1783421552564,"data":{"content":[{"type":"text","text":"The bash sandbox mode changed from \"workspace-write\" to \"danger-full-access\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"tool-bash"}},"surfaceOp":"append"} +{"type":"user/message","seq":1947,"time":1783421552564,"data":{"content":[{"type":"text","text":"The bash sandbox mode changed from \"workspace-write\" to \"danger-full-access\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"tool-bash"}},"surfaceOp":"append"} {"type":"step/start","seq":1948,"time":1783421552564,"data":{"turn":9,"step":1}} {"type":"request/header-delta","seq":1949,"time":1783421552564,"data":{"system":{"keepStart":12,"keepEnd":0,"insert":["Bash commands run under the \"danger-full-access\" file sandbox."]}}} {"type":"assistant/chunk","seq":1950,"time":1783421553289,"data":{"turn":9,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl index 8954fd6bad..d598b34e37 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl @@ -12,7 +12,7 @@ {"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}} {"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} -{"type":"context/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":2},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal-session snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"} +{"type":"user/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":2},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal-session snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"} {"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -49,6 +49,6 @@ {"type":"step/start","seq":47,"time":0,"data":{"turn":3,"step":1}} {"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} -{"type":"context/message","seq":50,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"paused\",\"maxGoalRounds\":2},\"roundsStarted\":2,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"} +{"type":"user/message","seq":50,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"paused\",\"maxGoalRounds\":2},\"roundsStarted\":2,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"} {"type":"step/end","seq":51,"time":0,"data":{"turn":3,"step":1}} {"type":"turn/end","seq":52,"time":0,"data":{"turn":3,"reason":{"kind":"aborted"}}} diff --git a/examples/acp-agent/tests/goal.snapshot.ts b/examples/acp-agent/tests/goal.snapshot.ts index 4bb01acd16..23359f9982 100644 --- a/examples/acp-agent/tests/goal.snapshot.ts +++ b/examples/acp-agent/tests/goal.snapshot.ts @@ -83,6 +83,7 @@ describe('ACP same-session goal snapshot', () => { const calls = events.filter(event => event.type === 'tool/call').map(event => event.data.name) expect(calls).toEqual(['create_goal', 'get_goal']) const rounds = events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'goal' + && event.data.source.round > 0 ? [event.data.source.round] : []) expect(rounds).toEqual([1, 2]) diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index 88152d69cd..3f664e8e09 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -87,7 +87,7 @@ {"type":"tool/call","seq":85,"time":1783921767208,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content.lines.map(line => line.text).join(String.fromCharCode(10));\"}"}} {"type":"tool/code-dispatch","seq":86,"time":1783921767270,"data":{"parentCallId":"call_00_6APApmaKLRDlXKMdIcWL5139","subCallId":"call_00_6APApmaKLRDlXKMdIcWL5139:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"resultSummary":"./nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}} {"type":"tool/result","seq":87,"time":1783921767271,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","content":[{"type":"text","text":"Touch this file to discover the nested workspace instruction."}],"isError":false},"sourceEventSeqs":[85],"surfaceOp":"append"} -{"type":"context/message","seq":88,"time":1783921767272,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} +{"type":"user/message","seq":88,"time":1784811336862,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} {"type":"step/end","seq":89,"time":1783921767272,"data":{"turn":1,"step":1}} {"type":"step/start","seq":90,"time":1783921767272,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":91,"time":1783921768339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index de23b34e4b..487f0517b1 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions extends Omit {\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index 723a7c077d..56cc640977 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -3,7 +3,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter or leave plan mode","input":{"hint":"[off|message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Inspect the exact tools service","updatedAt":"{{updatedAt}}"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions extends Omit {\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl index c638389319..910bdfca68 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl @@ -63,7 +63,7 @@ {"type":"hook/invoked","seq":61,"time":1783352197968,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":62,"time":1783352197976,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"pass","exitCode":0,"durationMs":7.404540999999881}} {"type":"tool/result","seq":63,"time":1783352197976,"data":{"turn":1,"step":1,"callId":"call_00_HbCMzTslWBZTSphWN0z97382","content":[{"type":"text","text":"HELLO\n"}],"isError":false},"sourceEventSeqs":[60],"surfaceOp":"append"} -{"type":"context/message","seq":64,"time":1783352197976,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} +{"type":"user/message","seq":64,"time":1783352197976,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} {"type":"step/end","seq":65,"time":1783352197977,"data":{"turn":1,"step":1}} {"type":"step/start","seq":66,"time":1783352197977,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":67,"time":1783352198981,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl index 46561a4760..921bc270d9 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl @@ -3,7 +3,7 @@ {"type":"hook/invoked","seq":1,"time":1783352160546,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"claude","handlerId":"claude:UserPromptSubmit:1"}} {"type":"hook/result","seq":2,"time":1783352160564,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"claude:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":17.45639600000004}} {"type":"user/message","seq":3,"time":1783352160564,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":4,"time":1783352160564,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1783352160564,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} {"type":"session/title","seq":5,"time":1783352160564,"data":{"title":"What is my favorite color?","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"step/start","seq":6,"time":1783352160565,"data":{"turn":1,"step":1}} {"type":"request/header","seq":7,"time":1783352160566,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl index 9b58a769c5..deab3ab423 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl @@ -63,7 +63,7 @@ {"type":"hook/invoked","seq":61,"time":1783352229622,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":62,"time":1783352229632,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"pass","exitCode":0,"durationMs":9.27664199999981}} {"type":"tool/result","seq":63,"time":1783352229632,"data":{"turn":1,"step":1,"callId":"call_00_Q6wHtakaip2QNfIXaVJY5458","content":[{"type":"text","text":"HELLO\n"}],"isError":false},"sourceEventSeqs":[60],"surfaceOp":"append"} -{"type":"context/message","seq":64,"time":1783352229633,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} +{"type":"user/message","seq":64,"time":1783352229633,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} {"type":"step/end","seq":65,"time":1783352229633,"data":{"turn":1,"step":1}} {"type":"step/start","seq":66,"time":1783352229633,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":67,"time":1783352230757,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl index c1f23a6b5e..ac319964f3 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl @@ -3,7 +3,7 @@ {"type":"hook/invoked","seq":1,"time":1783352209687,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"codex","handlerId":"codex:UserPromptSubmit:1"}} {"type":"hook/result","seq":2,"time":1783352209706,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"codex:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":19.49695100000008}} {"type":"user/message","seq":3,"time":1783352209707,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":4,"time":1783352209707,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1783352209707,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} {"type":"session/title","seq":5,"time":1783352209707,"data":{"title":"What is my favorite color?","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"step/start","seq":6,"time":1783352209709,"data":{"turn":1,"step":1}} {"type":"request/header","seq":7,"time":1783352209710,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl index cbb52c1b08..381458957c 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl @@ -106,7 +106,7 @@ {"type":"sandbox/mode","seq":104,"time":1784518115842,"data":{"mode":"danger-full-access"}} {"type":"approval/policy","seq":105,"time":1783962244624,"data":{"policy":"never"}} {"type":"user/message","seq":106,"time":1783962244624,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: cat out.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":107,"time":1783962244624,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"user-approval"}},"surfaceOp":"append"} +{"type":"user/message","seq":107,"time":1783962244624,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"user-approval"}},"surfaceOp":"append"} {"type":"step/start","seq":108,"time":1783962244624,"data":{"turn":2,"step":1}} {"type":"request/header","seq":109,"time":1784000791271,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}} {"type":"assistant/chunk","seq":110,"time":1783860671025,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl index a277f2e997..1f7345dc80 100644 --- a/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl +++ b/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl @@ -35,7 +35,7 @@ {"type":"tool/call","seq":33,"time":0,"data":{"turn":1,"step":3,"callId":"call_3","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}} {"type":"todo/write","seq":34,"time":0,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}} {"type":"tool/result","seq":35,"time":0,"data":{"turn":1,"step":3,"callId":"call_3","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false},"sourceEventSeqs":[33],"surfaceOp":"append"} -{"type":"context/message","seq":36,"time":0,"data":{"content":[{"type":"text","text":"You are repeating the exact same tool call with identical arguments. Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"}},"surfaceOp":"append"} +{"type":"user/message","seq":36,"time":0,"data":{"content":[{"type":"text","text":"You are repeating the exact same tool call with identical arguments. Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"}},"surfaceOp":"append"} {"type":"step/end","seq":37,"time":0,"data":{"turn":1,"step":3}} {"type":"step/start","seq":38,"time":0,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -58,7 +58,7 @@ {"type":"tool/call","seq":56,"time":0,"data":{"turn":1,"step":5,"callId":"call_5","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}} {"type":"todo/write","seq":57,"time":0,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}} {"type":"tool/result","seq":58,"time":0,"data":{"turn":1,"step":5,"callId":"call_5","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false},"sourceEventSeqs":[56],"surfaceOp":"append"} -{"type":"context/message","seq":59,"time":0,"data":{"content":[{"type":"text","text":"Repeated tool call detected:\n- tool: todo_write\n- consecutive_calls: 5\n- arguments: {\"todos\":[{\"content\":\"watch the kettle boil\",\"status\":\"in_progress\"}]}\nThe repeated calls are not making progress. Do not call this tool with these exact arguments again. Inspect the latest result and choose a different action, different arguments, or finish the task if enough evidence has been gathered."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"}},"surfaceOp":"append"} +{"type":"user/message","seq":59,"time":0,"data":{"content":[{"type":"text","text":"Repeated tool call detected:\n- tool: todo_write\n- consecutive_calls: 5\n- arguments: {\"todos\":[{\"content\":\"watch the kettle boil\",\"status\":\"in_progress\"}]}\nThe repeated calls are not making progress. Do not call this tool with these exact arguments again. Inspect the latest result and choose a different action, different arguments, or finish the task if enough evidence has been gathered."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"}},"surfaceOp":"append"} {"type":"step/end","seq":60,"time":0,"data":{"turn":1,"step":5}} {"type":"step/start","seq":61,"time":0,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index 8293ea3abf..883e685a6d 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -12,7 +12,7 @@ {"type":"assistant/message","seq":10,"time":1783778297070,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1783778297070,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} {"type":"tool/result","seq":12,"time":1783778297072,"data":{"turn":1,"step":1,"callId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} -{"type":"context/message","seq":13,"time":1783778297072,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]}},"surfaceOp":"append"} +{"type":"user/message","seq":13,"time":1783778297072,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]}},"surfaceOp":"append"} {"type":"step/end","seq":14,"time":1783778297072,"data":{"turn":1,"step":1}} {"type":"step/start","seq":15,"time":1783778297072,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":16,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/cordis-agent/tests/cordis-tools.e2e.ts b/examples/cordis-agent/tests/cordis-tools.e2e.ts index ae3651fdaf..b5f8e27564 100644 --- a/examples/cordis-agent/tests/cordis-tools.e2e.ts +++ b/examples/cordis-agent/tests/cordis-tools.e2e.ts @@ -42,7 +42,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const agent = ctx.agentLoop.create(SessionId('cordis-e2e-listener'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ + agent.followup([{ type: 'text', text: 'Use cordis_mount to mount a plugin that listens to the \'agent/status\' ' + 'cordis event and logs every change with console.log. Reply "mounted" once done.', @@ -58,7 +58,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif }) expect(resultText(mid)).toContain('dyn-') - agent.send([{ type: 'text', text: 'Now unmount the plugin you just mounted.' }]) + agent.followup([{ type: 'text', text: 'Now unmount the plugin you just mounted.' }]) await waitForIdle(ctx, agent) const after = await ctx.tools.execute({ @@ -72,7 +72,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif ctx = await cordisHarness() const agent = ctx.agentLoop.create(SessionId('cordis-e2e-selftool'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ + agent.followup([{ type: 'text', text: 'Give yourself a new tool: use cordis_mount to mount a plugin with ' + 'inject ["tools"] that calls harness.registerTool(ctx, harness.defineTool({...})) ' @@ -119,7 +119,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif ctx = await cordisHarness() const agent = ctx.agentLoop.create(SessionId('cordis-e2e-compose'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ + agent.followup([{ type: 'text', text: 'Mount TWO separate plugins with cordis_mount. First a provider: apply calls ' + 'ctx.provide(\'shouter\', { shout: (s) => s.toUpperCase() }). Second a consumer with ' @@ -144,7 +144,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif .flatMap(event => event.data.content.filter(block => block.type === 'text').map(block => block.text)) expect(shoutResults.some(text => text.includes('QUIET'))).toBe(true) - agent.send([{ type: 'text', text: 'Now unmount ONLY the provider plugin (the one that provided shouter).' }]) + agent.followup([{ type: 'text', text: 'Now unmount ONLY the provider plugin (the one that provided shouter).' }]) await waitForIdle(ctx, agent) // The consumer must have been parked by cordis itself: service gone, diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index 071d91d55b..86c1559b83 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -311,7 +311,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p ctx = await codeModeHarness(workdir) const agent = ctx.agentLoop.create(SessionId('e2e-code-mode'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ + agent.followup([{ type: 'text', text: 'Using one run_code program: run `echo alpha-7` with the bash tool, run `echo beta-9` with the bash tool, ' + 'then write both outputs joined by a plus sign into combined.txt (bash heredoc or redirect), ' @@ -363,7 +363,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, }) - handle.agent.send([{ + handle.agent.followup([{ type: 'text', text: 'Use one run_code program to call tools.read on pkg/deep/task.txt. After it finishes, answer: Code Mode workspace handshake?', }]) @@ -372,7 +372,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p const events: SessionEvent[] = [...handle.agent.session.events] const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read') const outerResult = events.find(event => event.type === 'tool/result') - const workspaceContext = events.find(event => event.type === 'context/message' + const workspaceContext = events.find(event => event.type === 'user/message' + && event.data.source.kind === 'plugin' && typeof event.data.meta === 'object' && event.data.meta !== null && !Array.isArray(event.data.meta) diff --git a/examples/headless-agent/tests/coding-task.e2e.ts b/examples/headless-agent/tests/coding-task.e2e.ts index a5f525e5c3..4a827858bb 100644 --- a/examples/headless-agent/tests/coding-task.e2e.ts +++ b/examples/headless-agent/tests/coding-task.e2e.ts @@ -56,7 +56,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) const agent = ctx.agentLoop.create(SessionId('e2e-task'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ + agent.followup([{ type: 'text', text: 'In the current directory, `node add.test.js` fails because add.js has a bug. ' + 'Fix add.js so the test passes, run `node add.test.js` to verify, and report the result. ' diff --git a/examples/headless-agent/tests/compaction.e2e.ts b/examples/headless-agent/tests/compaction.e2e.ts index fcebddb863..a96b7f3611 100644 --- a/examples/headless-agent/tests/compaction.e2e.ts +++ b/examples/headless-agent/tests/compaction.e2e.ts @@ -46,7 +46,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa }) const agent = ctx.agentLoop.create(SessionId('e2e-compaction'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ + agent.followup([{ type: 'text', text: 'Read file1.txt, file2.txt, file3.txt, and file4.txt one at a ' + 'time using cat (a separate bash command for each). After reading all four, tell me how ' diff --git a/examples/headless-agent/tests/full-loop.e2e.ts b/examples/headless-agent/tests/full-loop.e2e.ts index db2eec63fc..4f61ec3fa3 100644 --- a/examples/headless-agent/tests/full-loop.e2e.ts +++ b/examples/headless-agent/tests/full-loop.e2e.ts @@ -30,7 +30,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bas ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) const agent = ctx.agentLoop.create(SessionId('e2e-loop'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }]) + agent.followup([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }]) await waitForIdle(ctx, agent) const events = [...agent.session.events] diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 1626489bf5..6852fed20b 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -222,9 +222,14 @@ describe('headless stream-json snapshots', () => { const records = parseJsonl(logs[0]?.content ?? '') const calls = records.filter(record => record.type === 'tool/call') .map(record => (record.data as JsonObject | undefined)?.name) - expect(calls).toEqual(['create_goal', 'get_goal']) + expect(calls).toEqual(['update_goal', 'create_goal', 'get_goal']) + const probeResult = records.find(record => record.type === 'tool/result' + && (record.data as JsonObject | undefined)?.callId === 'call_goal_probe') + const probeData = probeResult?.data as JsonObject | undefined + expect(probeData?.isError).toBe(true) + expect((probeData?.error as JsonObject | undefined)?.code).toBe('GOAL_NOT_FOUND') const goalChanges = records.filter((record) => { - if (record.type !== 'context/message') return false + if (record.type !== 'user/message') return false const data = record.data as JsonObject | undefined const meta = data?.meta as JsonObject | undefined return meta?.kind === 'goal/change' diff --git a/examples/headless-agent/tests/resume.e2e.ts b/examples/headless-agent/tests/resume.e2e.ts index 01c7d52393..a4ded767da 100644 --- a/examples/headless-agent/tests/resume.e2e.ts +++ b/examples/headless-agent/tests/resume.e2e.ts @@ -41,7 +41,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses sessionId: SESSION_ID, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, })).agent - first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }]) + first.followup([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }]) await waitForIdle(ctx, first) await ctx.fiber.dispose() ctx = undefined @@ -58,7 +58,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // The prior user turn is in the rehydrated log before the model is asked. expect(JSON.stringify(resumed.session.deriveMessages())).toContain(SECRET) - resumed.send([{ type: 'text', text: 'What was the code I asked you to remember? Reply with just the code.' }]) + resumed.followup([{ type: 'text', text: 'What was the code I asked you to remember? Reply with just the code.' }]) await waitForIdle(ctx, resumed) // The model recalls it — only possible from the resumed history. diff --git a/examples/headless-agent/tests/snapshots/goal-tools/input.json b/examples/headless-agent/tests/snapshots/goal-tools/input.json index 5263ccd4e2..8449d44c4c 100644 --- a/examples/headless-agent/tests/snapshots/goal-tools/input.json +++ b/examples/headless-agent/tests/snapshots/goal-tools/input.json @@ -2,7 +2,7 @@ "steps": [ { "op": "prompt", - "text": "Create a durable goal to finish the snapshot proof, then inspect it." + "text": "Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it." } ] } diff --git a/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json b/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json index aec5204c7d..c4716ba7b0 100644 --- a/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json +++ b/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json @@ -1,4 +1,14 @@ [ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_goal_probe", "name": "update_goal", "argumentsDelta": "{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_goal_probe", "name": "update_goal", "arguments": "{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}" } }, + { "type": "usage", "usage": { "inputTokens": 15, "outputTokens": 6 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, { "kind": "chunks", "chunks": [ diff --git a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl index 05a1282a6f..16518b2d03 100644 --- a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl @@ -1,35 +1,45 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Create a durable goal to finish the snapshot proof, then inspect it."}],"source":{"kind":"user"}},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Create a durable goal to","messageSeqs":[1],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it."}],"source":{"kind":"user"}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Probe strict-schema fillers against miss","messageSeqs":[1],"source":{"kind":"fallback"}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_create","name":"create_goal","argumentsDelta":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_probe","name":"update_goal","argumentsDelta":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":15,"outputTokens":6}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"context/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_get","name":"get_goal","argumentsDelta":"{}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[22],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":15,"outputTokens":6}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_probe","content":[{"type":"text","text":"Error: no current goal"}],"isError":true,"error":{"name":"GoalError","code":"GOAL_NOT_FOUND"}},"sourceEventSeqs":[11],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_create","name":"create_goal","argumentsDelta":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":23,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":31,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"text","text":"GOAL READY"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":35,"outputTokens":2}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":32,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":33,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} -{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"GOAL READY","reason":{"kind":"completed"},"usage":{"inputTokens":85,"outputTokens":14}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_get","name":"get_goal","argumentsDelta":"{}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":31,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":32,"time":0,"data":{"turn":1,"step":3,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":33,"time":0,"data":{"turn":1,"step":3,"callId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[32],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":34,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":35,"time":0,"data":{"turn":1,"step":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":41,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"text","text":"GOAL READY"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":35,"outputTokens":2}},"sourceEventSeqs":[36,37,38,39,40],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":42,"time":0,"data":{"turn":1,"step":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":43,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"GOAL READY","reason":{"kind":"completed"},"usage":{"inputTokens":100,"outputTokens":20}} diff --git a/examples/headless-agent/tests/todo-write.e2e.ts b/examples/headless-agent/tests/todo-write.e2e.ts index daf5c018b1..c3053572d9 100644 --- a/examples/headless-agent/tests/todo-write.e2e.ts +++ b/examples/headless-agent/tests/todo-write.e2e.ts @@ -28,7 +28,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a ctx = await codingHarness(workdir, { persona: TODO_SYSTEM_PROMPT }) const agent = ctx.agentLoop.create(SessionId('e2e-todo'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ type: 'text', text: + agent.followup([{ type: 'text', text: 'Use the todo_write tool to record a plan of exactly two steps: first ' + '"inspect the failing test" (in_progress), then "apply the fix" (pending). ' + 'Send both in one todo_write call, then reply with the single word DONE.' }]) diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 0dee21988b..26ba64f23b 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -341,7 +341,7 @@ async function runScenario(scenario: Scenario): Promise { } expect(exit.seq).toBeLessThan(afterExit.seq) expect(afterExit.data.header.system).not.toContain('Snapshot plan mode instructions.') - expect(events.filter(event => event.type === 'context/message').map(event => event.data.content)) + expect(events.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin').map(event => (event.data as { content: unknown }).content)) .toContainEqual([{ type: 'text', text: 'The user switched this session back to the default mode.' }]) } expect(events.filter(event => event.type === 'tool/result').every(event => !event.data.isError)).toBe(true) diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 7d04dfe952..8adf2165e0 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -109,7 +109,7 @@ describe('bash tool through the agent loop', () => { const location = ctx.sessionPersistence.locate(agent.session.header) expect(location?.kind).toBe('jsonl') - agent.send([{ type: 'text', text: 'inspect the current session' }]) + agent.followup([{ type: 'text', text: 'inspect the current session' }]) await waitForIdle(ctx, agent) const result = findEvent(events(agent), 'tool/result') @@ -128,7 +128,7 @@ describe('bash tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-fg'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'run echo integration-ok' }]) + agent.followup([{ type: 'text', text: 'run echo integration-ok' }]) await waitForIdle(ctx, agent) const log = events(agent) @@ -160,7 +160,7 @@ describe('bash tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-exit'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'run exit 9' }]) + agent.followup([{ type: 'text', text: 'run exit 9' }]) await waitForIdle(ctx, agent) const toolResult = findEvent(events(agent), 'tool/result') @@ -168,7 +168,7 @@ describe('bash tool through the agent loop', () => { expect(resultText(toolResult)).toContain('[exit code: 9]') }) - it('background: start ack → completion notice as context/message → task_output collects it', async () => { + it('background: start ack → completion notice as user/message → task_output collects it', async () => { // The task id is deterministic (a fresh TaskService counts per kind from 1), // so the script can name `bash-1` without threading a generated id. const adapter = new MockAdapter([ @@ -180,7 +180,7 @@ describe('bash tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-bg'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }]) + agent.followup([{ type: 'text', text: 'run echo bg-ok in the background' }]) await waitForIdle(ctx, agent) const firstResult = findEvent(events(agent), 'tool/result') @@ -188,17 +188,19 @@ describe('bash tool through the agent loop', () => { expect(resultText(firstResult)).toBe('started background task bash-1') // The task settles on its own; the tool-tasks notice listener injects a - // durable context/message into the owning agent's session (settlement may - // race turn end, so poll for it). - await pollUntil(() => events(agent).some(event => event.type === 'context/message')) - const notice = findEvent(events(agent), 'context/message') + // durable plugin-sourced user/message into the owning agent's session + // (settlement may race turn end, so poll for it). + const isNotice = (e: SessionEvent): e is SessionEvent<'user/message'> => + e.type === 'user/message' && e.data.source.kind === 'plugin' + await pollUntil(() => events(agent).some(isNotice)) + const notice = events(agent).find(isNotice)! expect(notice.data.content.some( block => block.type === 'text' && block.text.includes('background task bash-1 (bash: echo bg-ok) finished'), )).toBe(true) expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' }) // The next turn collects the output through the generic task tool. - agent.send([{ type: 'text', text: 'collect it' }]) + agent.followup([{ type: 'text', text: 'collect it' }]) await waitForIdle(ctx, agent) const readResult = findEvent(events(agent), 'tool/result', 'last') expect(readResult.data.isError).toBe(false) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 7585ca9fba..e1e21dfd78 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -76,7 +76,7 @@ function buildAlphaLog(): SessionEvent[] { }) } if (turn % 9 === 4) { - push({ type: 'context/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } }) + push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } }) } push({ type: 'step/start', data: { turn, step: 0 } }) const withTool = turn % 5 === 2 diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 0342bc3c85..23b35f86bf 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -40,6 +40,15 @@ function materializeNode( ): ConversationNode { switch (event.type) { case 'user/message': + // Injected context (plugin/goal source) folds to a context node, not a + // user message; only a direct human prompt is a user node. + if (event.data.source.kind !== 'user') { + return { + kind: 'context', seq: event.seq, time: event.time, + content: event.data.content, source: event.data.source, + meta: event.data.meta, + } + } return { kind: 'user', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, @@ -55,12 +64,6 @@ function materializeNode( kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn, content: event.data.content, source: event.data.source, } - case 'context/message': - return { - kind: 'context', seq: event.seq, time: event.time, - content: event.data.content, source: event.data.source, - meta: event.data.meta, - } case 'tool/result': { const call = callIndex.get(String(event.data.callId)) return { @@ -75,7 +78,7 @@ function materializeNode( resultView, } } - /* v8 ignore next 2 -- defensive arm: fold output only carries the five + /* v8 ignore next 2 -- defensive arm: fold output only carries the four surface-eligible types, and each has a case above; reachable only if core adds an eligible type. */ default: diff --git a/packages/client/runtime/tests/fold-adapter.spec.ts b/packages/client/runtime/tests/fold-adapter.spec.ts index 3214c44ee9..bb360e2a67 100644 --- a/packages/client/runtime/tests/fold-adapter.spec.ts +++ b/packages/client/runtime/tests/fold-adapter.spec.ts @@ -40,7 +40,7 @@ describe('FoldAdapter', () => { ev.user(0, '用户'), ev.assistant(1, 0, '助手'), at(2, { type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: [{ type: 'text', text: '插话' }], source: { kind: 'user' } } }), - at(3, { type: 'context/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }), + at(3, { type: 'user/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }), ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'), ev.toolResult(5, 0, 'c1', '结果'), ] diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 3ea658b64d..1db86d38e4 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -563,7 +563,10 @@ describe('pressure measurement and retention', () => { const result = await compactIfNeeded(compact, session) expect(result).not.toBeNull() expect(prefix).toHaveLength(1) - expect(session.events.some(event => event.type === 'context/message')).toBe(false) + // The routed request prefix must not reach the surface as its own message + // (the compaction summary itself is an expected plugin-sourced checkpoint). + expect(session.events.some(event => event.type === 'user/message' + && event.data.content.some(block => block.type === 'text' && block.text.includes('p'.repeat(600))))).toBe(false) }) it('uses the latest logged request envelope without an AgentOptions override', async () => { @@ -959,7 +962,7 @@ describe('compaction region transaction', () => { const compact = service() const session = conversation(2) compact.mutateDuringSummary = () => { - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'concurrent surface mutation' }], source: { kind: 'plugin', plugin: 'test' }, }, { surfaceOp: 'append' }) diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index f9f61e4096..83c18f78b4 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -197,7 +197,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () provider: 'unconfigured-agent-fallback', model: 'unconfigured-agent-fallback', }) - agent.send([{ type: 'text', text: 'do a routed multi-step task' }]) + agent.followup([{ type: 'text', text: 'do a routed multi-step task' }]) await waitForIdle(ctx, agent) expect(agent.session.requestHeader()?.config.model).toBe('mock') @@ -215,7 +215,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () const { ctx } = await harness(8) try { const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'do tool work' }]) + agent.followup([{ type: 'text', text: 'do tool work' }]) await waitForIdle(ctx, agent) const events = [...agent.session.events] @@ -241,7 +241,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () const { ctx } = await harness(8) try { const agent = ctx.agentLoop.create(SessionId('repro'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'do a long multi-step task' }]) + agent.followup([{ type: 'text', text: 'do a long multi-step task' }]) await waitForIdle(ctx, agent) const events = [...agent.session.events] @@ -297,7 +297,7 @@ describe('context-overflow recovery across the real loop and compact-basic', () }) seedOverflowHistory(agent) - agent.send([{ type: 'text', text: 'continue from history' }]) + agent.followup([{ type: 'text', text: 'continue from history' }]) await agent.whenIdle() expect(adapter.conversationRequests).toHaveLength(2) @@ -360,7 +360,7 @@ describe('context-overflow recovery across the real loop and compact-basic', () try { const agent = ctx.agentLoop.create(SessionId('alternating-recovery'), { provider: 'mock', model: 'mock' }) seedOverflowHistory(agent) - agent.send([{ type: 'text', text: 'continue from history' }]) + agent.followup([{ type: 'text', text: 'continue from history' }]) await agent.whenIdle() expect(adapter.conversationRequests).toHaveLength(3) diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 03b91d653e..ff47ef3b19 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -33,7 +33,7 @@ The private per-session cache is keyed by `session.surface.replaceGeneration` an ## Surface contract -`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead: +`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, `tool/result`, and `steering/message` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead: 1. appends `compact/start` (log-only) — acquires the lock, 2. summarizes the range, diff --git a/packages/compact/compact/tests/tool-pairing.spec.ts b/packages/compact/compact/tests/tool-pairing.spec.ts index 5f48e7f99f..3de0473d57 100644 --- a/packages/compact/compact/tests/tool-pairing.spec.ts +++ b/packages/compact/compact/tests/tool-pairing.spec.ts @@ -96,23 +96,23 @@ describe('tool-pairing boundaries', () => { content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }], provenance: { provider: 'mock', model: 'mock' }, }, SURFACE) - midStep.append('context/message', { + midStep.append('user/message', { content: [{ type: 'text', text: 'background update' }], source: { kind: 'plugin', plugin: 'test' }, }, SURFACE) midStep.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false, }, SURFACE) - expect(before(midStep, 'context/message')).toBe(false) - expect(after(midStep, 'context/message')).toBe(false) + expect(before(midStep, 'user/message')).toBe(false) + expect(after(midStep, 'user/message')).toBe(false) const free = new Session(SessionId('neutral-free')) - free.append('context/message', { + free.append('user/message', { content: [{ type: 'text', text: 'idle injection' }], source: { kind: 'user' }, }, SURFACE) - expect(before(free, 'context/message')).toBe(true) - expect(after(free, 'context/message')).toBe(true) + expect(before(free, 'user/message')).toBe(true) + expect(after(free, 'user/message')).toBe(true) }) }) diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index b3a4b2013d..4fca9a9977 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -5,7 +5,7 @@ ## Public API - `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id or cwd, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses its latest log-backed title as the mention label and falls back to the session id; titles and message bodies are not searched. -- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated `HookContext`. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `send()` or `steer()`. +- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated `HookContext`. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `followup()` or `steer()`. - `encodeSessionReferenceUri()` and `decodeSessionReferenceUri()` implement `dsh-session:` so every JavaScript string id round-trips exactly. `formatSessionReferenceMention()` emits `@[label](uri)`, and `parseSessionReferenceText()` replaces Markdown mentions or bare canonical URIs with readable `@label` text while returning structured references. Explicit Markdown mentions reject every malformed URI; bare text is considered a reference only when a non-empty base64url-shaped payload follows the scheme, and a matching noncanonical candidate still fails. Empty or punctuation-only scheme mentions remain ordinary discussion text. ## Snapshot semantics diff --git a/packages/context/session-reference/src/projection.ts b/packages/context/session-reference/src/projection.ts index bbb2a2c739..dea29b38ee 100644 --- a/packages/context/session-reference/src/projection.ts +++ b/packages/context/session-reference/src/projection.ts @@ -57,7 +57,6 @@ function projectSessionConversation(snapshot: SessionSurfaceSnapshot): Projected break } case 'tool/result': - case 'context/message': break /* v8 ignore next 2 -- SurfaceEventType is closed and every variant is handled above. */ default: diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index 2470ae8d93..937535b042 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -75,7 +75,7 @@ function appendConversation(session: Session): void { { surfaceOp: 'append' }, ) session.append( - 'context/message', + 'user/message', { content: [{ type: 'text', text: 'workspace secret' }], source: { kind: 'plugin', plugin: 'workspace' } }, { surfaceOp: 'append' }, ) diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index 2327da81b2..f4938982a1 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -18,9 +18,9 @@ When `timeZone` is omitted, the plugin resolves the Node process's system zone o ## Timing semantics -The plugin prepends an `agent/pre-step` listener. When an injection is due, it appends one `context/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing. +The plugin prepends an `agent/pre-step` listener. When an injection is due, it appends one injected `user/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing. -Positive-interval scheduling scans the raw durable session events for the latest `context/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently. +Positive-interval scheduling scans the raw durable session events for the latest `user/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently. Step 1 measures from the latest preceding model-visible message, including the prompt that opened the turn. Later steps measure from the preceding time-context event in the same turn. Both baselines use durable session-event timestamps; backward wall-clock movement clamps elapsed time to zero. A missing first-step baseline, or a later step with no earlier same-turn reading because interval suppression skipped it, reports `unavailable`. diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 96ea7165af..fcf9e36efc 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -64,7 +64,6 @@ function precedingMessageTime(agent: Agent): number | undefined { case 'user/message': case 'assistant/message': case 'tool/result': - case 'context/message': case 'steering/message': return event.time default: @@ -79,7 +78,7 @@ function precedingMessageTime(agent: Agent): number | undefined { function precedingStepContextTime(agent: Agent, turn: number): number | undefined { for (const event of [...agent.session.events].reverse()) { if (event.type === 'turn/start' && event.data.turn === turn) return undefined - if (event.type === 'context/message' + if (event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === name) { return event.time @@ -91,7 +90,7 @@ function precedingStepContextTime(agent: Agent, turn: number): number | undefine /** Find this plugin's latest durable injection, including a shadowed surface event. */ function latestInjectionTime(agent: Agent): number | undefined { for (const event of [...agent.session.events].reverse()) { - if (event.type === 'context/message' + if (event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === name) { return event.time diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index 45fdb48cba..aa8f0418dd 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -48,7 +48,7 @@ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFa /** Validate one plugin-attributed time reading against its session position and timestamp. */ function validateReading( history: readonly SessionEvent[], - event: SessionEvent<'context/message'>, + event: SessionEvent<'user/message'>, fail: InvariantFailure, ): void { const [block] = event.data.content @@ -84,7 +84,7 @@ function validateReading( /** Validate all package-owned readings already present in one session. */ function validateSession(session: Session, fail: InvariantFailure): void { for (const [index, event] of session.events.entries()) { - if (event.type !== 'context/message' + if (event.type !== 'user/message' || event.data.source.kind !== 'plugin' || event.data.source.plugin !== SOURCE_NAME) continue validateReading(session.events.slice(0, index), event, fail) @@ -97,7 +97,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] - if (event.type !== 'context/message' + if (event.type !== 'user/message' || event.data.source.kind !== 'plugin' || event.data.source.plugin !== SOURCE_NAME) return validateReading(session.events, event, fail) diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index cd65f1aa3d..855303b295 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -17,7 +17,7 @@ async function setup(): Promise { function event(text: string, time = SECOND + 456, content?: unknown[]): SessionEvent { return { - type: 'context/message', + type: 'user/message', seq: 0, time, data: { @@ -56,7 +56,7 @@ function preparing(turn: number, step: number): Session { } function appendReading(session: Session, text: string): void { - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'time-context' }, }, { surfaceOp: 'append' }) @@ -162,7 +162,7 @@ describe('time-context invariants', () => { it('ignores context messages owned by another package', async () => { const ctx = await setup() - const other = event('unrelated') as SessionEvent<'context/message'> + const other = event('unrelated') as SessionEvent<'user/message'> other.data.source = { kind: 'plugin', plugin: 'other' } expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow() other.data.source = { kind: 'user' } diff --git a/packages/context/time-context/tests/time-context.e2e.ts b/packages/context/time-context/tests/time-context.e2e.ts index 2a0c06fe51..02704d3eba 100644 --- a/packages/context/time-context/tests/time-context.e2e.ts +++ b/packages/context/time-context/tests/time-context.e2e.ts @@ -48,7 +48,8 @@ describe('time-context through a real headless cordis.yml', () => { expect(stderr).not.toContain('UNHANDLED') expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2) - const contexts = events.filter(event => event.type === 'context/message') + const contexts = events.filter( + (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin') const starts = events.filter(event => event.type === 'step/start') expect(contexts).toHaveLength(2) expect(starts).toHaveLength(2) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index a42ed71454..424363d4b1 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -3,8 +3,8 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import AgentRegistry, { agentEvents, AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -42,14 +42,17 @@ function sessionAgent(session: Session, id = 'agent'): Agent { session, status: 'running', ctx: new Context(), - send() {}, - steer() {}, + followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), inject(content, options) { - session.append('context/message', { + session.append('user/message', { content, source: options?.source ?? { kind: 'user' }, }, { surfaceOp: 'append' }) + return AgentMessageId('stub') }, + send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } @@ -66,7 +69,7 @@ function openMessageTurn(session: Session, turn: number): void { function contextTexts(session: Session): string[] { const texts: string[] = [] for (const event of session.events) { - if (event.type === 'context/message' + if (event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'time-context') { texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '') @@ -151,8 +154,8 @@ describe('durable step context', () => { + 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.', ]) const event = session.events.at(-1) - expect(event?.type).toBe('context/message') - if (event?.type !== 'context/message') throw new Error('missing time context') + expect(event?.type).toBe('user/message') + if (event?.type !== 'user/message') throw new Error('missing time context') expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' }) expect(event.surfaceOp).toBe('append') }) @@ -230,10 +233,10 @@ describe('durable step context', () => { const original = new Session(SessionId('seed-source')) openMessageTurn(original, 1) await fire(ctx, sessionAgent(original), 1, 1) - const user = original.events.find(event => event.type === 'user/message') - const reading = original.events.find(event => event.type === 'context/message') + const user = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'user') + const reading = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') if (user === undefined || reading === undefined) throw new Error('missing source surface events') - original.append('context/message', { + original.append('user/message', { content: [{ type: 'text', text: 'compacted history' }], source: { kind: 'plugin', plugin: 'compact-basic' }, }, { @@ -292,7 +295,7 @@ describe('durable step context', () => { openMessageTurn(session, 1) let ordinarySawContext = false ctx.on('agent/pre-step', (subject) => { - ordinarySawContext = subject.session.events.some(event => event.type === 'context/message') + ordinarySawContext = subject.session.events.some(event => event.type === 'user/message') }) await fire(ctx, agent, 1, 1) @@ -371,7 +374,7 @@ describe('real agent-loop request history', () => { }) const agent = ctx.agentLoop.create(SessionId(`late-${mode}`), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'start' }]) + agent.followup([{ type: 'text', text: 'start' }]) await agent.whenIdle() expect(laterSawReading).toBe(true) @@ -397,11 +400,12 @@ describe('real agent-loop request history', () => { })) const agent = ctx.agentLoop.create(SessionId('loop'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'start' }]) + agent.followup([{ type: 'text', text: 'start' }]) await agent.whenIdle() expect(adapter.requests).toHaveLength(2) - const contexts = agent.session.events.filter(event => event.type === 'context/message') + const contexts = agent.session.events.filter( + (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin') const starts = agent.session.events.filter(event => event.type === 'step/start') expect(contexts).toHaveLength(adapter.requests.length) expect(starts).toHaveLength(adapter.requests.length) diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index a7245df91f..67ab9b4245 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -28,7 +28,7 @@ Instructions from: AGENTS.md ``` -Newly reached scopes use a durable raw `context/message`: +Newly reached scopes use a durable injected `user/message` (plugin source): ```md @@ -42,11 +42,11 @@ These instructions apply to work under `packages/app`. Use them as guidance when A same-file edit starts with `Updated instructions from: ` and says to use the new content instead of the previously loaded content. When a candidate disappears or becomes a per-directory duplicate of an earlier candidate, the message is `Instructions removed: ` followed by `The previously loaded instructions from this file no longer apply.` Literal `` text inside an instruction file is escaped so file content cannot close the plugin-owned frame. -The plugin owns the complete `` framing, and every `context/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping. +The plugin owns the complete `` framing, and every injected `user/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping. ## State And Refresh -Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. +Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only. @@ -111,7 +111,7 @@ Prefix-stable within one loop instance because the baseline is frozen. A new or #### What the model sees -After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained raw `context/message` with the newly applicable instruction file. +After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained injected `user/message` with the newly applicable instruction file. ##### Additional instruction template diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index 66b70f639d..61db3f527b 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -145,7 +145,7 @@ function visibleInstructionChanges( const visibleSeqs = new Set(agent.session.surface.nodes) const visible = new Map() for (const [seq, event] of agent.session.events.entries()) { - if (event.type !== 'context/message' || !isWorkspaceContextSource(event.data.source)) continue + if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue const changes = workspaceInstructionChanges(event.data.meta) for (const change of changes) { const waiting = pending.get(change.scope) @@ -281,7 +281,7 @@ export function observeInstructionSessionEvent( if (pending === undefined) return switch (event.type) { - case 'context/message': { + case 'user/message': { if (!isWorkspaceContextSource(event.data.source)) return for (const change of workspaceInstructionChanges(event.data.meta)) { const waiting = pending.get(change.scope) diff --git a/packages/context/workspace-context/tests/workspace-context.e2e.ts b/packages/context/workspace-context/tests/workspace-context.e2e.ts index 9341eb33d0..fbeacb5496 100644 --- a/packages/context/workspace-context/tests/workspace-context.e2e.ts +++ b/packages/context/workspace-context/tests/workspace-context.e2e.ts @@ -78,7 +78,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode it('obeys a probe instruction loaded from the workspace', async () => { const live = await harness() - live.agent.send([{ type: 'text', text: 'Workspace context handshake?' }]) + live.agent.followup([{ type: 'text', text: 'Workspace context handshake?' }]) await waitForIdle(live.ctx, live.agent) expect(finalText([...live.agent.session.events])).toContain(PROBE) @@ -90,7 +90,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode await writeFile(join(workdir!, 'pkg/AGENTS.md'), `If the user asks for the nested instruction handshake, reply with exactly this string and nothing else: ${NESTED_PROBE}.\n`) await writeFile(join(workdir!, 'pkg/deep/file.txt'), 'This file exists only to trigger nested workspace instructions.\n') - live.agent.send([{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }]) + live.agent.followup([{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }]) await waitForIdle(live.ctx, live.agent) expect(finalText([...live.agent.session.events])).toContain(NESTED_PROBE) @@ -99,23 +99,23 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode it('appends changed baseline instructions after a real file-tool touch without rewriting the frozen prefix', async () => { const live = await harness() await writeFile(join(workdir!, 'trigger.txt'), 'This file triggers workspace instruction reconciliation.\n') - live.agent.send([{ type: 'text', text: 'Workspace context handshake?' }]) + live.agent.followup([{ type: 'text', text: 'Workspace context handshake?' }]) await waitForIdle(live.ctx, live.agent) await writeFile(join(workdir!, 'AGENTS.md'), `The old workspace handshake no longer applies. If the user asks for the updated workspace context handshake, reply with exactly this string and nothing else: ${UPDATED_PROBE}.\n`) - live.agent.send([{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }]) + live.agent.followup([{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }]) await waitForIdle(live.ctx, live.agent) const events = [...live.agent.session.events] - const update = events.find(event => event.type === 'context/message' + const update = events.find(event => event.type === 'user/message' && typeof event.data.meta === 'object' && event.data.meta !== null && !Array.isArray(event.data.meta) && event.data.meta.kind === 'workspace-instructions') - expect(update?.type === 'context/message' && update.data.meta).toMatchObject({ + expect(update?.type === 'user/message' && update.data.meta).toMatchObject({ changes: [{ action: 'replace', scope: candidateScopeKey('.', 'AGENTS.md'), path: 'AGENTS.md' }], }) - const updateText = update?.type === 'context/message' + const updateText = update?.type === 'user/message' ? update.data.content.filter(block => block.type === 'text').map(block => block.text).join('') : '' expect(updateText).toContain('Updated instructions from: AGENTS.md') diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 475db4b479..c8c33f3a4b 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -7,7 +7,7 @@ import Loader from '@cordisjs/plugin-loader' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session' -import AgentRegistry, { type Agent, type HookContext } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentMessageId, type Agent, type HookContext } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { @@ -177,15 +177,18 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { options: {}, session, status: 'idle', - send() {}, - steer() {}, + followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), inject(content, options) { - session.append('context/message', { + session.append('user/message', { content, source: options?.source ?? { kind: 'user' }, ...options?.meta !== undefined ? { meta: options.meta } : {}, }, { surfaceOp: 'append' }) + return AgentMessageId('stub') }, + send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } @@ -222,7 +225,7 @@ function workspaceChangeContext(scope: string, digest: string): HookContext { function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: HookContext[] }): number | undefined { let lastSeq: number | undefined for (const context of result.additionalContexts ?? []) { - lastSeq = agent.session.append('context/message', { + lastSeq = agent.session.append('user/message', { content: context.content, source: context.source, ...context.meta !== undefined ? { meta: context.meta } : {}, @@ -976,7 +979,7 @@ describe('workspace context request injection', () => { const second = await composeBaselinePrefix(ctx, agent) expect(second).toEqual(first) - expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(0) + expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(0) expect(derivedText(agent)).toContain('repo rule') } finally { await rm(root, { recursive: true, force: true }) @@ -1148,7 +1151,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) - expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(0) + expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(0) expect(derivedText(agent)).not.toContain('workspace-context:') } finally { await rm(root, { recursive: true, force: true }) @@ -1714,14 +1717,14 @@ describe('dynamic nested workspace context injection', () => { }, })) - agent.send([{ type: 'text', text: 'read and abort' }]) + agent.followup([{ type: 'text', text: 'read and abort' }]) await agent.whenIdle() - expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1) - agent.send([{ type: 'text', text: 'retry the read' }]) + agent.followup([{ type: 'text', text: 'retry the read' }]) await agent.whenIdle() - const contexts = agent.session.events.filter(event => event.type === 'context/message') + const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') // The aborted batch drained its accepted context before step close, so the // retry sees durable history without producing a duplicate instruction. expect(contexts).toHaveLength(1) @@ -2496,10 +2499,7 @@ describe('dynamic nested workspace context injection', () => { agent, }) appendAdditionalContexts(agent, first) - const resumed = { - ...agent, - session: new Session(agent.session.id, [...agent.session.events], agent.session.header), - } + const resumed = stubAgent(root, [...agent.session.events]) const afterResume = await ctx.tools.execute({ signal: testToolSignal, @@ -2537,11 +2537,11 @@ describe('dynamic nested workspace context injection', () => { await composeBaselinePrefix(ctx, resumed) - const update = resumed.session.events.findLast(event => event.type === 'context/message') - expect(update?.type === 'context/message' && update.data.meta).toMatchObject({ + const update = resumed.session.events.findLast(event => event.type === 'user/message' && event.data.source.kind !== 'user') + expect(update?.type === 'user/message' && update.data.meta).toMatchObject({ changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) - expect(update?.type === 'context/message' && blocksText(update.data.content)).toContain('new nested rule after resume') + expect(update?.type === 'user/message' && blocksText(update.data.content)).toContain('new nested rule after resume') } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -2687,7 +2687,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) - agent.session.append('context/message', { + agent.session.append('user/message', { content: [ { type: 'reasoning', text: 'Additional instructions from: pkg/AGENTS.md' }, { type: 'text', text: 'Updated instructions from: pkg/AGENTS.md' }, @@ -2704,12 +2704,12 @@ describe('dynamic nested workspace context injection', () => { ], }, }, { surfaceOp: 'append' }) - agent.session.append('context/message', { + agent.session.append('user/message', { content: [{ type: 'text', text: 'stale metadata version' }], source: { kind: 'plugin', plugin: 'workspace-context' }, meta: { kind: 'workspace-instructions', version: 0, changes: [] }, }, { surfaceOp: 'append' }) - agent.session.append('context/message', { + agent.session.append('user/message', { content: [{ type: 'text', text: 'foreign plugin context' }], source: { kind: 'plugin', plugin: 'other' }, meta: { @@ -3237,14 +3237,14 @@ describe('workspace context pending state', () => { path: join('pkg', 'AGENTS.md'), version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one', }]])) - const unrelated = agent.session.append('context/message', { + const unrelated = agent.session.append('user/message', { content: [], source: { kind: 'plugin', plugin: 'other' }, }, { surfaceOp: 'append' }) observeInstructionSessionEvent(agent.session, unrelated, pending, versions) expect(pending.get(agent.session)?.has('pkg')).toBe(true) const otherContext = workspaceChangeContext('other', 'other') - const otherWorkspaceEvent = agent.session.append('context/message', { + const otherWorkspaceEvent = agent.session.append('user/message', { content: otherContext.content, source: otherContext.source, ...otherContext.meta !== undefined ? { meta: otherContext.meta } : {}, @@ -3253,7 +3253,7 @@ describe('workspace context pending state', () => { expect(pending.get(agent.session)?.has('pkg')).toBe(true) const context = workspaceChangeContext('pkg', 'one') - const confirmed = agent.session.append('context/message', { + const confirmed = agent.session.append('user/message', { content: context.content, source: context.source, ...context.meta !== undefined ? { meta: context.meta } : {}, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 6939ec9927..c3e4e1858b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -885,6 +885,27 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * A step or turn errored. The loop reports a failure here (plus the logger)\n * even when the error has no in-turn position for a session `error` event.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A step or turn errored.', }, + { + name: 'agent/inbox/dequeue', + mode: 'emit', + signature: '\'agent/inbox/dequeue\'(this: Scoped, agent: Agent, message: AgentMessage): void', + jsDoc: '/**\n * The driver claimed one item out of the inbox: a queued item at a turn\n * boundary, or steering drained between steps. Fires after the item leaves\n * its FIFO and before it becomes a durable message.\n * @param agent - the agent whose inbox item was claimed.\n * @param message - the claimed message (matching the `id` from its `agent/inbox/enqueue`).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'The driver claimed one item out of the inbox: a queued item at a turn boundary, or steering drained between steps.', + }, + { + name: 'agent/inbox/discard', + mode: 'emit', + signature: '\'agent/inbox/discard\'(this: Scoped, agent: Agent, messages: AgentMessage[]): void', + jsDoc: '/**\n * Pending inbox items were dropped without delivering them, so every\n * enqueued id receives exactly one terminal `agent/inbox/dequeue` OR\n * `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after\n * `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop`\n * dropping pending steering (in-turn and on the post-turn late-steering\n * drain); and disposal of any still-pending items (before\n * `agent/status(\'disposed\')`). Fires once per drop with every dropped item.\n * @param agent - the agent whose inbox items were dropped.\n * @param messages - the discarded messages in FIFO order (queued then steering); never empty.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'Pending inbox items were dropped without delivering them, so every enqueued id receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`.', + }, + { + name: 'agent/inbox/enqueue', + mode: 'emit', + signature: '\'agent/inbox/enqueue\'(this: Scoped, agent: Agent, message: AgentMessage): void', + jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `message` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection through\n * `agent.inject()` or equivalent `send()` routing bypasses the FIFOs\n * and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'A detached, frozen item entered the agent\'s inbox (queued or steering FIFO).', + }, { name: 'agent/post-step', mode: 'serial', @@ -906,13 +927,6 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message. Call `next()` for the unchanged default. A listener wrapping a\n * downstream `allow` must preserve its `content` and `additionalContexts`\n * unless it intentionally replaces them. The signal controls only this turn;\n * listeners may cooperate with it but must not retain it to control another\n * turn. Steering messages do not dispatch this event; they join an open turn\n * at a steering checkpoint.\n * @param agent - the agent whose turn claimed the message.\n * @param content - the claimed message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Allow, rewrite, or block one claimed prompt before it becomes a user message.', }, - { - name: 'agent/queued', - mode: 'emit', - signature: '\'agent/queued\'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void', - jsDoc: '/**\n * Detached, frozen content entered the agent\'s inbox. Source defaults have\n * already been applied, so these are the exact values retained for the log.\n * @param agent - the agent whose inbox received the message.\n * @param content - the accepted content blocks retained by the inbox.\n * @param info - the accepted source, contexts, and whether it entered as steering.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'Detached, frozen content entered the agent\'s inbox.', - }, { name: 'agent/request', mode: 'waterfall', @@ -945,7 +959,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/status', mode: 'emit', signature: '\'agent/status\'(this: Scoped, agent: Agent, status: AgentStatus): void', - jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does\n * not enter `running` synchronously; drive lifecycle from this event.\n * @param agent - the agent whose status flipped.\n * @param status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking\n * delivery does not enter `running` synchronously; drive lifecycle from this event.\n * @param agent - the agent whose status flipped.\n * @param status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'Agent status changed (`idle` ⇄ `running`, or → `disposed`).', }, { @@ -1171,7 +1185,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ export const TYPE_API: readonly TypeApiEntry[] = [ { name: 'Agent', - declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n}', + declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n}', }, { name: 'AgentCancelCause', @@ -1185,6 +1199,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentHandle', declaration: 'export interface AgentHandle {\n agent: Agent;\n dispose(): Promise;\n}', }, + { + name: 'AgentMessageId', + declaration: 'export type AgentMessageId = Branded<\'AgentMessageId\'>;', + }, { name: 'AgentOptions', declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n}', @@ -1285,6 +1303,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CallId', declaration: 'export type CallId = Branded<\'CallId\'>;', }, + { + name: 'CancelOptions', + declaration: 'export interface CancelOptions {\n keepInbox?: boolean;\n}', + }, { name: 'CodeBindingErrorClass', declaration: 'export interface CodeBindingErrorClass {\n name: string;\n memberNameProperty: string;\n}', @@ -1503,7 +1525,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InjectOptions', - declaration: 'export interface InjectOptions extends Omit {\n meta?: JsonValue;\n}', + declaration: 'export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n}', }, { name: 'InvariantFailure', @@ -1529,6 +1551,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'JsonValue', declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};', }, + { + name: 'LlmAdapter', + declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n listModels(_provider: string): Promise;\n resolveModelContext(_provider: string, _model: string): Promise;\n abstract stream(options: GenerateOptions): AsyncIterable;\n}', + }, { name: 'LlmCallConfig', declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}', @@ -1591,7 +1617,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptMessageData', - declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n}', + declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n}', }, { name: 'PromptMessageEnvelope', @@ -1693,6 +1719,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ReasoningBlock', declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}', }, + { + name: 'RequestHeaderReason', + declaration: 'export type RequestHeaderReason = \'initial\' | \'resume\' | \'change\';', + }, + { + name: 'ResolvedAgentInput', + declaration: 'export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n} & ({\n target: \'next-turn\';\n wakeup: boolean;\n contexts: HookContext[];\n} | {\n target: \'next-step\';\n wakeup: true;\n contexts: HookContext[];\n} | {\n target: \'next-step\';\n wakeup: false;\n contexts: [\n ];\n});', + }, { name: 'ResumeAgentOptions', declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', @@ -1727,7 +1761,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SendOptions', - declaration: 'export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n}', + declaration: 'export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n}', + }, + { + name: 'Session', + declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', }, { name: 'SessionAvailability', @@ -1739,7 +1777,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventMap', - declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': PromptMessageData;\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': PromptMessageData & {\n turn: number;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: R /* …truncated — full shape in source */', + declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': PromptMessageData;\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': PromptMessageData & {\n turn: number;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n}', }, { name: 'SessionEventMetadataFilter', @@ -1865,6 +1903,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionSearchRequest', declaration: 'export interface SessionSearchRequest {\n query: string;\n sessionFilters?: readonly SessionResultFilter[];\n eventFilters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: SessionSearchCursor;\n}', }, + { + name: 'SessionSurface', + declaration: 'export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n}', + }, { name: 'SessionSurfaceSnapshot', declaration: 'export interface SessionSurfaceSnapshot {\n session: SessionHeader;\n capturedThroughSeq: number | null;\n events: SurfaceEvent[];\n}', @@ -1995,7 +2037,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SurfaceEventType', - declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\' | \'context/message\' | \'steering/message\';', + declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\' | \'steering/message\';', + }, + { + name: 'SurfaceIntent', + declaration: 'export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n}', }, { name: 'SurfaceOp', diff --git a/packages/cordis/tool-cordis/tests/inspect.spec.ts b/packages/cordis/tool-cordis/tests/inspect.spec.ts index 3085cfd057..18b4d04df7 100644 --- a/packages/cordis/tool-cordis/tests/inspect.spec.ts +++ b/packages/cordis/tool-cordis/tests/inspect.spec.ts @@ -61,6 +61,8 @@ describe('cordis_inspect', () => { // generated TYPE_API — a consumer can see field types, not just names). expect(report).toContain('type shapes (referenced by the signatures above') expect(report).toContain('export interface ToolExecution') + expect(report).toContain('export class Session') + expect(report).toContain('export interface SessionSurface') // A type only reachable through a NOT-live service (e.g. bash) is scoped out. expect(report).not.toContain('export interface BashRunResult') // The inherited ctx surface closes the section. diff --git a/packages/cordis/tool-cordis/tests/integration.spec.ts b/packages/cordis/tool-cordis/tests/integration.spec.ts index d68f6be349..7f917e5b3c 100644 --- a/packages/cordis/tool-cordis/tests/integration.spec.ts +++ b/packages/cordis/tool-cordis/tests/integration.spec.ts @@ -47,7 +47,7 @@ describe('cordis tools through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-cordis'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }]) + agent.followup([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }]) await waitForIdle(ctx, agent) const log = agent.session.events diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 79fbbd7824..47250c354f 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -50,9 +50,9 @@ Configured agents start automatically. A model call requires both `provider` and ### Internal concrete driver -The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. +The concrete `ReactLoopAgent` adapter, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. -Each concrete `send()` materializes content, resolved source, and attached contexts once as a detached, deeply frozen lossless-JSON FIFO item. If claimed, it is the sole ordinary message in its turn; its contexts are the prompt waterfall's default additional contexts and therefore materialize only after admission. Absent or `separate` placement appends an independent `context/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. Open-turn `inject()` uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls; successful batches place it after all results, and interrupted batches drain it before turn close. Malformed data throws before enqueue or append. +`ReactLoopAgent.send()` implements the public fully resolved acceptance path. The `followup()`/`queue()`/`steer()`/`inject()` helpers resolve every optional field before delegating to it; direct callers provide mandatory content, source, contexts, metadata, target, and wakeup facts through `ResolvedAgentInput`. `followup()` and `queue()` join the ordinary FIFO, respectively waking or leaving an idle driver parked. If claimed, an ordinary item is the sole message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` or equivalent `send()` routing enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `inject()` and non-waking next-step acceptance require an empty context tuple, bypass both FIFOs, and append durable context directly: an open-turn injection defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 91efbf7782..e09acfff40 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -6,15 +6,25 @@ * @module dsh-agent-loop/agent */ +import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' -import { agentEvents } from '@deepseek-ai/dsh-agent' -import type { AgentCancelCause, AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent' +import type { + Agent, + AgentCancelCause, + AgentOptions, + AgentStatus, + CancelOptions, + HookContext, + InjectOptions, + ResolvedAgentInput, + SendOptions, +} from '@deepseek-ai/dsh-agent' import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session' import { DISPOSED_INTERRUPT_REASON, TurnCancellation } from './cancellation.ts' -import { Inbox, type InboxMessage } from './inbox.ts' +import { Inbox, agentMessage, type InboxMessage } from './inbox.ts' import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' /** Sessions already claimed by a concrete driver construction. */ @@ -190,19 +200,17 @@ export class ReactLoopAgent implements Agent { for (const resolve of waiters) resolve() } - private resolveSource(options?: SendOptions): MessageSource { - return options?.source ?? { kind: 'user' } - } - /** * Accept one public message payload as a detached record. Lossless-JSON * materialization reads every nested field once; deep freeze prevents later * caller mutation before an inbox or deferred-injection queue drains it. */ - private acceptMessage(content: ContentBlock[], options?: SendOptions): InboxMessage { - const source = this.resolveSource(options) - const contexts = options?.contexts ?? [] - const accepted = snapshotJsonValue({ content, source, contexts }) + private snapshotMessage(id: AgentMessageId, input: ResolvedAgentInput): InboxMessage { + const { content, source, contexts, wakeup, meta } = input + const accepted = snapshotJsonValue({ + id, content, source, contexts, wakeup, + ...meta !== undefined ? { meta } : {}, + }) if (accepted === undefined) { throw new TypeError('agent message content, source, and contexts must be losslessly JSON-serializable') } @@ -223,33 +231,81 @@ export class ReactLoopAgent implements Agent { if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`) } - send(content: ContentBlock[], options?: SendOptions): void { + /** Accept one fully resolved agent input through the concrete driver's routing matrix. */ + send(input: ResolvedAgentInput): AgentMessageId { this.assertNotDisposed() - const accepted = this.acceptMessage(content, options) - this.#inbox.enqueue(accepted) - const info = { source: accepted.source, contexts: accepted.contexts, steering: false } as const - agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info) + const id = AgentMessageId(randomUUID()) + const { target, wakeup } = input + // next-step/no-wakeup is injection: durable context without running the model. + if (target === 'next-step' && !wakeup) { this.injectContext(input); return id } + // next-step/wakeup is steering into the running turn; idle falls back to a + // waking ordinary turn (there is no active turn to attach to). + const steering = target === 'next-step' && this._status === 'running' + const accepted = this.snapshotMessage(id, input) + if (steering) { + this.#inbox.steer(accepted) + } else { + this.#inbox.enqueue(accepted, wakeup) + } + agentEvents(this.loopCtx, this).emit('agent/inbox/enqueue', agentMessage(accepted, steering)) + return id } - steer(content: ContentBlock[], options?: SendOptions): void { - this.assertNotDisposed() - if (this._status !== 'running') { this.send(content, options); return } - const accepted = this.acceptMessage(content, options) - this.#inbox.steer(accepted) - const info = { source: accepted.source, contexts: accepted.contexts, steering: true } as const - agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info) + followup(content: ContentBlock[], options?: SendOptions): AgentMessageId { + return this.send({ + content, + target: 'next-turn', + wakeup: true, + source: options?.source ?? { kind: 'user' }, + contexts: options?.contexts ?? [], + meta: options?.meta, + }) } - inject(content: ContentBlock[], options?: InjectOptions): void { - this.assertNotDisposed() - const source = this.resolveSource(options) - const context = { + queue(content: ContentBlock[], options?: SendOptions): AgentMessageId { + return this.send({ + content, + target: 'next-turn', + wakeup: false, + source: options?.source ?? { kind: 'user' }, + contexts: options?.contexts ?? [], + meta: options?.meta, + }) + } + + steer(content: ContentBlock[], options?: SendOptions): AgentMessageId { + return this.send({ + content, + target: 'next-step', + wakeup: true, + source: options?.source ?? { kind: 'user' }, + contexts: options?.contexts ?? [], + meta: options?.meta, + }) + } + + inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId { + return this.send({ + content, + target: 'next-step', + wakeup: false, + source: options?.source ?? { kind: 'plugin', plugin: '' }, + contexts: [], + meta: options?.meta, + }) + } + + /** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */ + private injectContext(input: Extract): void { + const { content, source, meta } = input + // Detach and validate the payload before any append, so malformed input + // cannot open a one-shot turn or otherwise mutate the session. + const accepted = this.acceptContext({ content, source, - ...options?.meta !== undefined ? { meta: options.meta } : {}, - } + ...meta !== undefined ? { meta } : {}, + }) if (isTurnOpen(this.session)) { - const accepted = this.acceptContext(context) // Provider protocols require every assistant tool-call batch to be // followed only by its tool results. Historical interrupted batches do // not own new context; only the currently executing batch may defer it. @@ -257,27 +313,29 @@ export class ReactLoopAgent implements Agent { this.deferredInjections.push(accepted) return } - this.session.append('context/message', accepted, { surfaceOp: 'append' }) + this.session.append('user/message', accepted, { surfaceOp: 'append' }) return } // No turn open: wrap the injection in a one-shot turn so every event stays - // turn-enclosed (the durability/replay boundary is the turn). + // turn-enclosed (the durability/replay boundary is the turn). The payload is + // validated above, but `Session.append` can still reject a turn/start + // pre-commit (append re-entrancy from a session/event listener, or an + // internal-dispatch veto), so the finally owes a turn/end only when + // turn/start actually committed. const turn = lastTurnNumber(this.session) + 1 - // Once turn/start enters the log, a turn/end is owed even if the message - // append fails acceptance or pre-commit validation. The finally re-checks - // the log and closes only a turn that actually opened; post-commit observers - // are contained by Session and cannot create a false append failure. try { this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - this.session.append('context/message', context, { surfaceOp: 'append' }) + this.session.append('user/message', accepted, { surfaceOp: 'append' }) } finally { // Close the turn if turn/start made it into the log. A pre-commit veto // must escape rather than being mistaken for a committed turn/end. if (isTurnOpen(this.session)) { this.session.append('turn/end', { turn, reason: { kind: 'completed' } }) } - // Decide the durability checkpoint from the log: an accepted one-shot - // turn must be flushed even when its message append was the failing step. + // Checkpoint only an accepted one-shot turn: a turn/start rejected + // pre-commit recorded nothing, so it owes no flush (and a spurious flush + // would emit a phantom-turn agent/error). The payload is validated up + // front, so a committed turn/start is always followed by its user/message. const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn) // Keep inject() synchronous: report checkpoint failures live instead of // rejecting the caller, and track the task so disposal still drains it. @@ -301,7 +359,7 @@ export class ReactLoopAgent implements Agent { private drainDeferredInjections(): void { const pending = this.deferredInjections.splice(0) for (const accepted of pending) { - this.session.append('context/message', accepted, { surfaceOp: 'append' }) + this.session.append('user/message', accepted, { surfaceOp: 'append' }) } } @@ -325,10 +383,14 @@ export class ReactLoopAgent implements Agent { } } - cancel(cause?: AgentCancelCause): void { + cancel(cause?: AgentCancelCause, options?: CancelOptions): void { const resolvedCause = cause ?? { kind: 'user' } + const keepInbox = options?.keepInbox ?? false const cancellation = this.turnCancellation - const preRun = cancellation === undefined && (this.#inbox.hasQueued || this.#inbox.hasSteering) + // keepInbox preserves pending work, so un-started items must not arm the + // pre-run cancel path that would otherwise drop the next queued turn. + const preRun = !keepInbox && cancellation === undefined + && (this.#inbox.hasQueued || this.#inbox.hasSteering) if (cancellation !== undefined || preRun) { if (preRun) this.preRunCancelled = true // Coordination consumers must update their own state before this call @@ -336,9 +398,24 @@ export class ReactLoopAgent implements Agent { // contained by the fused dispatcher and cannot veto cancellation. agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedCause) } - // Clear work already present before abort observers run. A replacement - // synchronously enqueued by an observer belongs to the next turn. - this.#inbox.clear() + if (!keepInbox) { + // Snapshot before clearing so the discard notification carries the exact + // dropped items; a replacement synchronously enqueued by an + // `agent/cancel-requested` observer belongs to the next turn, not here. + const discarded = this.#inbox.pending() + // Clear work already present before abort observers run. + this.#inbox.clear() + if (discarded.length > 0) { + const items = discarded.map(({ message, steering }) => agentMessage(message, steering)) + agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) + } + // No idle-waiter settle here: a `whenIdle` waiter exists only while the + // agent is `running` or a waking item is queued, and neither is left + // quiescent by clearing the inbox — a lone quiet item takes `whenIdle`'s + // fast path (no waiter), a waking item keeps the woken driver running, + // and a running agent owns its own idle transition (including the + // post-turn flush window). + } cancellation?.request(resolvedCause) } @@ -349,7 +426,9 @@ export class ReactLoopAgent implements Agent { */ whenIdle(): Promise { if (this._status === 'disposed') return this.done - if (this._status !== 'running' && !this.#inbox.hasQueued) return Promise.resolve() + // A lone quiet (`wakeup:false`) queued item leaves the agent quiescent — the + // driver stays parked — so gate on hasWakingQueued, not hasQueued. + if (this._status !== 'running' && !this.#inbox.hasWakingQueued) return Promise.resolve() // Agent-owned waiters survive concurrent fiber disposal. return new Promise((resolve) => { this.idleWaiters.push(() => { @@ -407,8 +486,21 @@ export class ReactLoopAgent implements Agent { */ private [stopDriver](): Promise | void { if (this._status !== 'disposed') { + // Snapshot any still-pending inbox items, then CLEAR and mark disposed + // BEFORE emitting the discard — mirroring cancel()'s snapshot→clear→emit + // order so a re-entrant followup()/cancel() from a discard listener throws + // `disposed` (or finds an empty inbox) instead of leaking or double- + // discarding an id. `followup()` emits enqueue unconditionally, so the discard + // is unconditional too (even on an unpublished rollback) to keep every + // enqueued id matched. + const discarded = this.#inbox.pending() + this.#inbox.clear() this._status = 'disposed' this.resolveDisposed() + if (discarded.length > 0) { + const items = discarded.map(({ message, steering }) => agentMessage(message, steering)) + agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) + } // Release whenIdle waiters BEFORE the (guarded) event emit — they are // internal state that must settle even if a listener throws below. Each // waiter chains `done`, so it resolves only once the loop actually exits. diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index 6c8a20e3d1..0f93884515 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -1,54 +1,91 @@ /** * Per-agent message inbox: queued and steering FIFOs. Purely an in-memory - * mechanism of the loop driver — the public surface is `Agent.send()` and - * `Agent.steer()`. + * mechanism of the loop driver — callers use `Agent`'s intent-named delivery + * methods instead. * * @module dsh-agent-loop/inbox */ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import type { HookContext } from '@deepseek-ai/dsh-agent' +import type { JsonValue } from '@deepseek-ai/dsh-session' +import type { AgentMessage, AgentMessageId, HookContext } from '@deepseek-ai/dsh-agent' -/** One message waiting in an agent's inbox. */ +/** One message waiting in an agent's inbox; `id` is the value its accepting delivery method returned. */ export interface InboxMessage { + id: AgentMessageId content: ContentBlock[] source: MessageSource contexts: HookContext[] + /** Whether the item is marked to wake the driver or force a continuation. */ + wakeup: boolean + /** Opaque durable JSON state retained on the durable message but hidden from the model. */ + meta?: JsonValue +} + +/** + * Build the `agent/inbox/*` event payload for one inbox item. + * @param message - the accepted inbox record. + * @param steering - whether the item is in the steering FIFO (`next-step`). + * @returns the live-event message for enqueue/dequeue/discard. + */ +export function agentMessage(message: InboxMessage, steering: boolean): AgentMessage { + // Frozen: the fused emitter passes this exact object to every listener in + // turn, so one listener must not be able to mutate a field (`id`, `steering`, + // `content`, …) a later listener then observes. `message` is already a frozen + // inbox record, so its nested fields need no re-clone. + return Object.freeze({ + id: message.id, content: message.content, source: message.source, + contexts: message.contexts, steering, wakeup: message.wakeup, + }) } /** * Per-agent inbox: a queued FIFO (dequeued once per turn start) and a steering FIFO * (drained between steps of a running turn). Purely an in-memory mechanism of - * the loop — the public surface is `Agent.send()` / `Agent.steer()`. + * the loop — the public surface is `Agent`'s intent-named delivery methods. */ export class Inbox { private queuedMessages: InboxMessage[] = [] private steeringMessages: InboxMessage[] = [] private wakeup: (() => void) | undefined - /** True while queued messages are pending — read by the idle wait's fast path and the loop's turn-start checks. */ + /** True while any queued message is pending — read by cancellation's discard snapshot and the turn-start dequeue guard. */ get hasQueued(): boolean { return this.queuedMessages.length > 0 } + /** + * True while a queued message wants to wake the driver — the "should the loop + * run" signal read by the idle wait's fast path, the loop's idle-publish + * check, and `whenIdle`. A `wakeup:false` (quiet) item alone leaves this + * false, so the driver stays parked until a waking follow-up (or a waking item + * ahead of it in FIFO order) drives the loop; the quiet item then rides along. + */ + get hasWakingQueued(): boolean { + return this.queuedMessages.some(message => message.wakeup) + } + /** True while steering messages are pending — read by cancellation and the loop's stop-override check. */ get hasSteering(): boolean { return this.steeringMessages.length > 0 } /** - * Add a message to the queued FIFO and wake a parked {@link waitForQueued}. + * Add a message to the queued FIFO, waking a parked {@link waitForQueued} + * unless the item opted out. A non-waking item still runs once any woken + * item or later wakeup drives the parked loop. * @param message - the message to queue for the next turn start. + * @param wake - whether to wake a parked idle wait (default true). */ - enqueue(message: InboxMessage): void { + enqueue(message: InboxMessage, wake = true): void { this.queuedMessages.push(message) - this.wakeup?.() + if (wake) this.wakeup?.() } /** * Add a message to the steering FIFO. Deliberately no wakeup: steering is * drained between steps of a running turn, never by the idle wait — - * `Agent.steer()` on an idle agent falls back to `send()` instead. + * `Agent.steer()` on an idle agent falls back to a waking ordinary turn instead. * @param message - the message to inject between steps of the running turn. */ steer(message: InboxMessage): void { @@ -71,6 +108,18 @@ export class Inbox { return this.steeringMessages.splice(0) } + /** + * Snapshot the pending items (queued then steering, FIFO order) without + * removing them — the discard notification's payload source. + * @returns the pending items paired with whether each is steering. + */ + pending(): { message: InboxMessage; steering: boolean }[] { + return [ + ...this.queuedMessages.map(message => ({ message, steering: false })), + ...this.steeringMessages.map(message => ({ message, steering: true })), + ] + } + /** * Discard all pending messages (queued + steering) without delivering them — * used by `cancel()`, which drops un-started work rather than draining it into @@ -88,7 +137,7 @@ export class Inbox { * loop can exit). */ waitForQueued(cancel: Promise): Promise { - if (this.hasQueued) return Promise.resolve() + if (this.hasWakingQueued) return Promise.resolve() const { promise, resolve } = Promise.withResolvers() this.wakeup = resolve void cancel.then(resolve) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 1cfd913b77..4324deca8d 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -5,11 +5,12 @@ * @module dsh-agent-loop/loop */ +import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm' import { isDeepStrictEqual } from 'node:util' import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm' -import { agentEvents, agentInterruptReasonOf, assembleContextFor } from '@deepseek-ai/dsh-agent' +import { agentEvents, agentInterruptReasonOf, assembleContextFor, AgentMessageId } from '@deepseek-ai/dsh-agent' import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' import { canonicalHeader } from '@deepseek-ai/dsh-session' import type { PromptMessageData, Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' @@ -19,7 +20,7 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import { executeToolCalls } from './tool-calls.ts' -import type { Inbox } from './inbox.ts' +import { agentMessage, type Inbox, type InboxMessage } from './inbox.ts' import type { TurnCancellation } from './cancellation.ts' /** Normalize thrown values while preserving an existing error code. */ @@ -201,9 +202,11 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { while (!handle.isDisposed()) { // An idle listener can enqueue and cancel replacement work before the next // wait is installed. Consume that empty marker before parking the driver. + // A quiet (`wakeup:false`) item alone must not un-park the loop, so gate on + // hasWakingQueued, not hasQueued. if (handle.isPreRunCancelled()) { handle.clearPreRunCancel() - if (!handle.inbox.hasQueued) { + if (!handle.inbox.hasWakingQueued) { handle.settleIdle() handle.setStatus('idle') continue @@ -217,7 +220,7 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { // a replacement prompt still runs before the eventual idle transition. if (handle.isPreRunCancelled()) { handle.clearPreRunCancel() - if (!handle.inbox.hasQueued) { + if (!handle.inbox.hasWakingQueued) { // Settle before publishing idle: the already-idle path has no status // transition, while an idle listener can register waiters for new work. handle.settleIdle() @@ -234,10 +237,11 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { } // A synchronous `running` listener can cancel before `runTurn`; balance the - // status only when no replacement prompt was queued by that listener. + // status only when no waking replacement prompt was queued by that listener + // (a lone quiet item parks at idle rather than driving a turn). if (cancellation.signal.aborted) { handle.clearTurnCancellation(cancellation) - if (!handle.inbox.hasQueued) { + if (!handle.inbox.hasWakingQueued) { handle.setStatus('idle') continue } @@ -260,12 +264,22 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { handle.clearTurnCancellation(cancellation) } - // Late steering becomes queued input unless terminal policy stopped the turn. - for (const message of handle.inbox.drainSteering()) { - if (!terminalStopped) handle.inbox.enqueue(message) + // Late steering (arriving after runTurn returns, e.g. during the post-turn + // flush) becomes queued input — unless terminal policy stopped the turn, in + // which case it is dropped and must publish a discard so its enqueue is + // still matched (the invariant only catches a NEGATIVE count, not a leak). + const lateSteering = handle.inbox.drainSteering() + if (terminalStopped) { + if (lateSteering.length > 0) { + events.emit('agent/inbox/discard', lateSteering.map(message => agentMessage(message, true))) + } + } else { + for (const message of lateSteering) handle.inbox.enqueue(message) } - if (!handle.inbox.hasQueued) handle.setStatus('idle') + // Park at idle unless a waking item still wants the model to run; a lone + // quiet (`wakeup:false`) item stays queued but does not keep the loop busy. + if (!handle.inbox.hasWakingQueued) handle.setStatus('idle') } } @@ -279,10 +293,14 @@ async function runTurn( const drainSteering = (): boolean => { const messages = handle.inbox.drainSteering() for (const message of messages) { + events.emit('agent/inbox/dequeue', agentMessage(message, true)) const prepared = preparePromptMessage(message.content, message.source, message.contexts) - session.append('steering/message', { turn, ...prepared.data }, { surfaceOp: 'append' }) + session.append('steering/message', { + turn, ...prepared.data, + ...message.meta === undefined ? {} : { meta: message.meta }, + }, { surfaceOp: 'append' }) for (const context of prepared.separateContexts) { - session.append('context/message', { + session.append('user/message', { content: context.content, source: context.source, ...context.meta === undefined ? {} : { meta: context.meta }, @@ -296,6 +314,7 @@ async function runTurn( const message = handle.inbox.dequeueQueued() /* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */ if (!message) throw new Error('runTurn invariant violated: no queued message at turn start') + events.emit('agent/inbox/dequeue', agentMessage(message, false)) const trigger: TurnTrigger = { kind: 'message', source: message.source } let reason: TurnEndReason = { kind: 'completed' } @@ -361,7 +380,10 @@ async function runTurn( // `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them. const content = promptDecision.content ?? message.content const prepared = preparePromptMessage(content, message.source, promptDecision.additionalContexts ?? []) - session.append('user/message', prepared.data, { surfaceOp: 'append' }) + session.append('user/message', { + ...prepared.data, + ...message.meta === undefined ? {} : { meta: message.meta }, + }, { surfaceOp: 'append' }) // Separate contexts still enter THIS turn through inject(). Prefix // contexts are already baked into the user/message with their durable // display envelope, so appending them again would duplicate model input. @@ -536,9 +558,21 @@ async function runTurn( break } - // A continuation reason becomes next-step steering. + // A continuation reason becomes next-step steering. Publish the same + // enqueue event a public steer would, so the inbox ledger stays balanced + // (every FIFO entry has a matching enqueue before its dequeue/discard). if (decision.action === 'continue' && decision.reason) { - handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source, contexts: [] }) + // Detach and freeze the listener-owned reason like a public steer, so an + // enqueue listener or the producer cannot mutate the durable/model-visible + // steering message before it drains. + const item: InboxMessage = deepFreeze({ + id: AgentMessageId(randomUUID()), + content: structuredClone(decision.reason.content), + source: structuredClone(decision.reason.source), + contexts: [], wakeup: true, + }) + handle.inbox.steer(item) + events.emit('agent/inbox/enqueue', agentMessage(item, true)) } let shouldContinue = decision.action === 'continue' @@ -562,7 +596,13 @@ async function runTurn( if (terminalStop) { terminalStopped = true // Terminal stop discards steering but preserves ordinary queued prompts. - handle.inbox.drainSteering() + // Publish a discard for every dropped steering item so the enqueue ⇒ + // dequeue-or-discard ledger stays balanced (the outstanding-count + // invariant and correlation consumers must not be left with dangling ids). + const dropped = handle.inbox.drainSteering() + if (dropped.length > 0) { + events.emit('agent/inbox/discard', dropped.map(item => agentMessage(item, true))) + } shouldContinue = false } diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index c6c767d422..a2359562a8 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function send(agent: Agent, text: string): void { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } /** Adapter that holds both drivers at the same awaited continuation. */ diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 2cb3191f83..08b77711f9 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -48,7 +48,7 @@ function waitForStatus(ctx: Context, agent: Agent, expected: Agent['status']): P } function send(agent: Agent, text: string) { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } describe('Agent', () => { @@ -83,7 +83,41 @@ describe('Agent', () => { await ctx.fiber.dispose() }) - it('send() throws after disposal', async () => { + it('send exposes the fully resolved delivery path without applying helper defaults', async () => { + const adapter = new MockAdapter([textResponse('accepted')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const enqueued = Promise.withResolvers<{ id: string; source: unknown; wakeup: boolean }>() + ctx.on('agent/inbox/enqueue', (subject, message) => { + if (subject === agent) enqueued.resolve(message) + }) + + const id = agent.send({ + content: [{ type: 'text', text: 'advanced input' }], + source: { kind: 'plugin', plugin: 'advanced-caller' }, + contexts: [], + meta: { caller: 'advanced' }, + target: 'next-turn', + wakeup: true, + }) + await waitForIdle(ctx, agent) + + expect(await enqueued.promise).toMatchObject({ + id, + source: { kind: 'plugin', plugin: 'advanced-caller' }, + wakeup: true, + }) + expect(agent.session.events.find(event => event.type === 'user/message')) + .toMatchObject({ + data: { + source: { kind: 'plugin', plugin: 'advanced-caller' }, + meta: { caller: 'advanced' }, + }, + }) + await ctx.fiber.dispose() + }) + + it('followup() throws after disposal', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) let agent!: Agent @@ -95,7 +129,28 @@ describe('Agent', () => { await fiber.dispose() await driverDone(agent) - expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') + expect(() => { agent.followup([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') + }) + + it('disposal discards still-pending inbox items so every id gets a terminal event', async () => { + const adapter = new MockAdapter(['hang']) + const ctx = await harness(adapter) + let agent!: Agent + const discarded: string[] = [] + ctx.on('agent/inbox/discard', (subject, messages) => { + if (subject === agent) discarded.push(...messages.map(m => m.id)) + }) + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) + }, { inject: ['agentLoop'] })) + + // A quiet (non-waking) item stays parked in the inbox; disposal must drop it + // WITH a discard so its enqueued id is not left dangling forever. + const id = agent.queue([{ type: 'text', text: 'never runs' }]) + await fiber.dispose() + await driverDone(agent) + + expect(discarded).toEqual([id]) }) it('steer() throws after disposal', async () => { @@ -139,7 +194,7 @@ describe('Agent', () => { agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) agent.inject([{ type: 'text', text: 'mid' }], { source: { kind: 'plugin', plugin: 'p' } }) expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1) - expect(agent.session.events.at(-1)!.type).toBe('context/message') + expect(agent.session.events.at(-1)!.type).toBe('user/message') // Close the turn; now inject must wrap its own one-shot injection turn. agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -151,6 +206,16 @@ describe('Agent', () => { expect(agent.session.events.at(-1)!.type).toBe('turn/end') // turn-enclosed }) + it('inject() defaults its source to an empty plugin, never user', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + agent.inject([{ type: 'text', text: 'no explicit source' }]) + const injected = agent.session.events.at(-1)! + expect(injected.type === 'user/message' && injected.data.source).toEqual({ kind: 'plugin', plugin: '' }) + }) + it('idle inject() contains a failing flush (logs, does not throw into the caller)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) @@ -167,24 +232,54 @@ describe('Agent', () => { warn.mockRestore() }) - it('idle inject() closes its one-shot turn AND still checkpoints even if the append throws', async () => { + it('idle inject() validates its payload BEFORE opening a turn, so invalid input appends nothing', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let flushes = 0 ctx.on('session/flush', () => { flushes += 1 }) - // Non-serializable injected content makes Session.append throw AFTER - // turn/start was recorded. The turn/end must still be appended (finally), - // AND the durability checkpoint must still fire — the balanced turn is in - // memory and a crash before the next turn/dispose would otherwise lose it. + // Non-serializable injected content is rejected by the up-front snapshot + // BEFORE any append (the unified send contract: invalid input throws before + // mutating the log). No one-shot turn opens and no durability checkpoint fires. expect(() => { agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } }) - }).toThrow(/non-JSON-serializable/) - const types = agent.session.events.map(e => e.type) - expect(types).toEqual(['turn/start', 'turn/end']) // balanced, no open turn - await new Promise(r => setTimeout(r, 10)) // let the fire-and-forget flush run - expect(flushes).toBe(1) // checkpoint fired despite the throw + }).toThrow(/losslessly JSON-serializable/) + expect(agent.session.events).toHaveLength(0) + await new Promise(r => setTimeout(r, 10)) // give any (erroneous) flush a chance + expect(flushes).toBe(0) // nothing was appended, so no checkpoint + }) + + it('idle inject() re-entered from a session/event listener is rejected pre-commit and opens no turn', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + let flushes = 0 + ctx.on('session/flush', () => { flushes += 1 }) + // Injecting from inside a session/event listener re-enters Session.append, + // which rejects pre-commit — so turn/start never commits. The finally sees + // no open turn (closes nothing) and no recorded turn (no checkpoint), and + // the reentrant throw is contained by Session's post-commit dispatch. + // Fire on turn/end: at that instant the outer one-shot turn is closed (no + // turn open), so the reentrant inject takes the idle one-shot-turn path and + // its turn/start append re-enters Session and is rejected pre-commit. + let reentered = false + ctx.on('session/event', (_s, event) => { + if (!reentered && event.type === 'turn/end') { + reentered = true + agent.inject([{ type: 'text', text: 'reentrant' }], { source: { kind: 'plugin', plugin: 'p' } }) + } + }) + + agent.inject([{ type: 'text', text: 'outer' }], { source: { kind: 'plugin', plugin: 'p' } }) + // The outer injection's own one-shot turn is balanced; the reentrant one + // opened no turn (its turn/start was rejected pre-commit). + const turnStarts = agent.session.events.filter(e => e.type === 'turn/start') + expect(turnStarts).toHaveLength(1) + const injected = agent.session.events.filter(e => e.type === 'user/message') + expect(injected).toHaveLength(1) // the reentrant user/message never committed + await new Promise(r => setTimeout(r, 10)) + expect(flushes).toBe(1) // only the outer accepted turn checkpointed }) it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => { @@ -202,7 +297,7 @@ describe('Agent', () => { expect(() => { agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } }) }).not.toThrow() const types = agent.session.events.map(e => e.type) - expect(types).toEqual(['turn/start', 'context/message', 'turn/end']) // balanced + expect(types).toEqual(['turn/start', 'user/message', 'turn/end']) // balanced await new Promise(r => setTimeout(r, 10)) expect(flushes).toBe(1) // checkpoint fired despite the throwing turn/end listener }) @@ -234,13 +329,11 @@ describe('Agent', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - // A non-serializable source makes the turn/start append throw BEFORE the - // event is pushed (Session.append validates before push), so NO turn opens. - // The finally's isTurnOpen() guard sees no open turn and appends nothing — - // the log stays empty, not left with a dangling turn/start. + // A non-serializable source is rejected by the up-front snapshot BEFORE any + // append, so NO turn opens and the log stays empty. expect(() => { agent.inject([{ type: 'text', text: 'x' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never }) - }).toThrow(/non-JSON-serializable/) + }).toThrow(/losslessly JSON-serializable/) expect(agent.session.events).toHaveLength(0) }) @@ -397,7 +490,7 @@ describe('Agent', () => { const { agent } = prepared prepared.markPublished() const dispose = prepared.startDriver() - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index cbf2c70564..32418fe112 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -33,7 +33,7 @@ async function harness(adapter: MockAdapter) { } function send(agent: Agent, text: string) { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } /** Resolve on the agent's next idle transition (event-based, not status poll). */ @@ -63,7 +63,7 @@ describe('Agent.cancel()', () => { ctx.on('agent/cancel-requested', (subject, cause) => { if (subject !== agent) return seen.push(`first:${cause.kind}`) - subject.send([{ type: 'text', text: 'queued by cancel observer' }]) + subject.followup([{ type: 'text', text: 'queued by cancel observer' }]) throw new Error('observer failed') }) ctx.on('agent/cancel-requested', (subject, cause) => { @@ -98,6 +98,57 @@ describe('Agent.cancel()', () => { expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true) }) + it('cancel({ keepInbox: true }) preserves queued work and emits no discard', async () => { + const adapter = new MockAdapter([textResponse('reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const discards: unknown[] = [] + ctx.on('agent/inbox/discard', (subject, items) => { if (subject === agent) discards.push(items) }) + + // Queue a turn WITHOUT waking the driver, so it sits in the inbox. + agent.queue([{ type: 'text', text: 'preserved' }]) + // keepInbox cancel: no active turn, work preserved, no discard event. + agent.cancel({ kind: 'user' }, { keepInbox: true }) + expect(discards).toEqual([]) + + // The preserved item still runs once the driver is woken by a later send. + send(agent, 'wake it') + await waitForIdle(ctx, agent) + expect(userTexts(agent)).toEqual(['preserved', 'wake it']) + }) + + it('a lone queued message leaves the agent parked at idle', async () => { + const adapter = new MockAdapter([textResponse('reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + // A quiet item alone must NOT wake the driver: no turn runs and whenIdle + // resolves (the agent is quiescent), leaving the item queued. + agent.queue([{ type: 'text', text: 'quiet' }]) + await agent.whenIdle() + expect(agent.status).toBe('idle') + expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) + + // A later waking send drives the loop, and the quiet item rides along first. + send(agent, 'wake') + await waitForIdle(ctx, agent) + expect(userTexts(agent)).toEqual(['quiet', 'wake']) + }) + + it('cancelling a parked quiet item settles a pending whenIdle() without a later send', async () => { + const adapter = new MockAdapter([textResponse('reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + agent.queue([{ type: 'text', text: 'quiet' }]) + const idle = agent.whenIdle() + // Cancel reaches quiescence with no status transition and no waking send; + // whenIdle must still resolve (previously it hung until the next send). + agent.cancel({ kind: 'user' }) + await idle + expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) + }) + it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 2103dd6831..d144127498 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -98,7 +98,7 @@ describe('config-driven session id', () => { first = ctx.agents.get(SessionId('config-exact-reload')) } expect(first).toBeDefined() - first!.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) + first!.followup([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) await waitForIdle(ctx, first!) await firstLoop.dispose() @@ -110,7 +110,7 @@ describe('config-driven session id', () => { } expect(second).toBeDefined() expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me') - second!.send([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } }) + second!.followup([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } }) await waitForIdle(ctx, second!) await ctx.sessions.flush(second!.session) const loaded = await ctx.sessionPersistence.load(SessionId('config-exact-reload')) @@ -335,7 +335,7 @@ describe('config-driven session id', () => { expect(a1.id).toBe(a1.session.id) expect(a1.session.id).toMatch(idPattern) expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined() - a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -354,7 +354,7 @@ describe('config-driven session id', () => { expect(a2.id).toBe(a2.session.id) expect(a2.session.id).toMatch(idPattern) expect(a2.session.id).not.toBe(a1.session.id) - a2.send([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } }) + a2.followup([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } }) await waitForIdle(ctx2, a2) await ctx2.fiber.dispose() }) @@ -375,7 +375,7 @@ describe('config-driven session id', () => { await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')])) const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent - a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 07cf87f57d..2b85691151 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -50,7 +50,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function send(agent: Agent, text: string) { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } describe('session log records what agent/step-result actually produced', () => { @@ -275,7 +275,9 @@ describe('abort during tool execution ends the turn', () => { order.push(`tool/result:${event.data.callId}:${outcome}`) break } - case 'context/message': order.push('context/message'); break + // Injected context is a plugin-sourced user/message; the direct human + // prompt (user source) is not tracked in this ordering. + case 'user/message': if (event.data.source.kind !== 'user') order.push('context/message'); break case 'steering/message': order.push('steering/message'); break case 'step/end': order.push('step/end'); break case 'turn/end': { @@ -354,13 +356,14 @@ describe('abort during tool execution ends the turn', () => { await waitForIdle(ctx, agent) const events = [...agent.session.events] + const isInjected = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user' expect(events - .filter(event => event.type === 'tool/result' || event.type === 'context/message' + .filter(event => event.type === 'tool/result' || isInjected(event) || event.type === 'step/end' || event.type === 'turn/end') - .map(event => event.type)) + .map(event => isInjected(event) ? 'context/message' : event.type)) .toEqual(['tool/result', 'context/message', 'context/message', 'step/end', 'turn/end']) expect(events - .filter(event => event.type === 'context/message') + .filter(isInjected) .map(event => event.data.content)) .toEqual([ [{ type: 'text', text: 'accepted before abort' }], @@ -410,12 +413,13 @@ describe('abort during tool execution ends the turn', () => { await waitForIdle(ctx, agent) const events = [...agent.session.events] + const isInjected = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user' expect(events - .filter(event => event.type === 'tool/result' || event.type === 'context/message' + .filter(event => event.type === 'tool/result' || isInjected(event) || event.type === 'step/end' || event.type === 'turn/end') - .map(event => event.type)) + .map(event => isInjected(event) ? 'context/message' : event.type)) .toEqual(['tool/result', 'tool/result', 'context/message', 'step/end', 'turn/end']) - expect(events.find(event => event.type === 'context/message')?.data.content) + expect(events.find(isInjected)?.data.content) .toEqual([{ type: 'text', text: 'accepted after first result' }]) }) @@ -456,7 +460,7 @@ describe('abort during tool execution ends the turn', () => { await fiber.dispose() expect(agent.session.events - .filter(event => event.type === 'context/message') + .filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind !== 'user') .map(event => event.data.content)) .toEqual([ [{ type: 'text', text: 'accepted before disposal' }], @@ -507,7 +511,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'start a text-only turn') await waitForIdle(ctx, agent) - expect(agent.session.events.find(event => event.type === 'context/message')?.data.content) + expect(agent.session.events.find((event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind !== 'user')?.data.content) .toEqual([{ type: 'text', text: 'new turn context' }]) expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('new turn context') }) @@ -763,7 +767,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }]) }) - it('agent/queued carries the resolved source; steering/message records its source', async () => { + it('agent/inbox/enqueue carries the resolved source; steering/message records its source', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -778,7 +782,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { })) const queuedSources: { source: MessageSource; contexts: HookContext[]; steering: boolean }[] = [] - ctx.on('agent/queued', (_agent, _content, info) => void queuedSources.push(info)) + ctx.on('agent/inbox/enqueue', (_agent, info) => void queuedSources.push({ source: info.source, contexts: info.contexts, steering: info.steering })) send(agent, 'go') // no explicit source → default {kind:'user'} must be visible await waitForIdle(ctx, agent) @@ -800,11 +804,11 @@ describe('adapter registration, routing, and accepted-input ownership', () => { let notifiedContent: ContentBlock[] | undefined let notifiedSource: MessageSource | undefined let notifiedContexts: HookContext[] | undefined - ctx.on('agent/queued', (subject, acceptedContent, info) => { + ctx.on('agent/inbox/enqueue', (subject, info) => { if (subject !== agent || info.steering) return // Retain the exact notification references: cloning here would test the // listener's copy rather than the event/inbox ownership boundary. - notifiedContent = acceptedContent + notifiedContent = info.content notifiedSource = info.source notifiedContexts = info.contexts }) @@ -814,7 +818,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { source: { kind: 'plugin', plugin: 'context-source' }, meta: { version: 1 }, }] - agent.send(content, { source, contexts }) + agent.followup(content, { source, contexts }) content[0]!.text = 'caller-mutated-send' source.plugin = 'caller-mutated-source' contexts[0]!.content[0] = { type: 'text', text: 'caller-mutated-context' } @@ -863,14 +867,14 @@ describe('adapter registration, routing, and accepted-input ownership', () => { let notifiedContent: ContentBlock[] | undefined let notifiedSource: MessageSource | undefined let notifiedContexts: HookContext[] | undefined - ctx.on('agent/queued', (subject, acceptedContent, info) => { + ctx.on('agent/inbox/enqueue', (subject, info) => { if (subject !== agent || !info.steering) return - notifiedContent = acceptedContent + notifiedContent = info.content notifiedSource = info.source notifiedContexts = info.contexts }) - agent.send([{ type: 'text', text: 'start' }]) + agent.followup([{ type: 'text', text: 'start' }]) await entered.promise expect(agent.status).toBe('running') const content = [{ type: 'text' as const, text: 'accepted-steer' }] @@ -951,7 +955,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { expect(request).not.toContain('caller-mutated-steering-context-without-meta') const steeringIndex = agent.session.events.findIndex(event => event.type === 'steering/message') - const contextIndex = agent.session.events.findIndex(event => event.type === 'context/message' + const contextIndex = agent.session.events.findIndex(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'steering-context') expect(steeringIndex).toBeGreaterThanOrEqual(0) expect(contextIndex).toBe(steeringIndex + 1) @@ -987,7 +991,7 @@ describe('turn numbering continues across seeded sessions', () => { const turns: number[] = [] ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) }) - forked.send([{ type: 'text', text: 'continue' }]) + forked.followup([{ type: 'text', text: 'continue' }]) await new Promise((resolve) => { ctx2.on('agent/status', (subject, status) => { if (subject === forked && status === 'idle') resolve() diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 38ea3d103e..f8ff5a74b9 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -38,7 +38,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function send(agent: Agent, text: string) { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } describe('inbox acceptance', () => { @@ -47,13 +47,13 @@ describe('inbox acceptance', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let queued = 0 - ctx.on('agent/queued', () => { queued += 1 }) + ctx.on('agent/inbox/enqueue', () => { queued += 1 }) expect(() => { - agent.send([{ type: 'text', text: 'first', bad: 1n } as never]) + agent.followup([{ type: 'text', text: 'first', bad: 1n } as never]) }).toThrow(/losslessly JSON-serializable/) expect(() => { - agent.send([{ type: 'text', text: 'first' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never }) + agent.followup([{ type: 'text', text: 'first' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never }) }).toThrow(/losslessly JSON-serializable/) expect(queued).toBe(0) expect(agent.session.events).toHaveLength(0) diff --git a/packages/core/agent-loop/tests/inbox-invariant.spec.ts b/packages/core/agent-loop/tests/inbox-invariant.spec.ts new file mode 100644 index 0000000000..0d907f26d7 --- /dev/null +++ b/packages/core/agent-loop/tests/inbox-invariant.spec.ts @@ -0,0 +1,155 @@ +/** + * Regression: the dsh-agent FIFO-conservation invariant must stay balanced on + * the loop-authored continuation-reason steering path. A continue-with-reason + * decision enters the steering FIFO and later drains (or is discarded by + * cancel); both must be matched by an enqueue event so the invariant's + * outstanding count never goes negative. + * @module dsh-agent-loop/tests/inbox-invariant + */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { MockAdapter, textResponse } from './mock-adapter.ts' + +async function harness(adapter: MockAdapter) { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(InvariantService) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} + +function waitForIdle(ctx: Context, agent: Agent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { dispose(); resolve() } + }) + }) +} + +describe('inbox FIFO-conservation invariant', () => { + it('stays balanced when a continuation reason enters and drains the steering FIFO', async () => { + const adapter = new MockAdapter([textResponse('step 1'), textResponse('step 2')]) + const ctx = await harness(adapter) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + let forced = false + ctx.on('agent/turn-continuation', async (_agent, _turn, _default, _signal, next) => { + if (forced) return next() + forced = true + return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } } + }) + + agent.followup([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(2) + // The continuation reason drained as a steering/message on the second step. + expect(agent.session.events.some(e => e.type === 'steering/message')).toBe(true) + // No invariant violation was logged. + expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) + expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false) + }) + + it('stays balanced when cancel discards a pending continuation reason', async () => { + const adapter = new MockAdapter([textResponse('only step')]) + const ctx = await harness(adapter) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + // Force a continuation reason, then cancel from the same checkpoint so the + // reason sits in the steering FIFO when the inbox is discarded. + ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => { + if (subject !== agent) return next() + queueMicrotask(() => { agent.cancel({ kind: 'user' }) }) + return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } } + }) + + agent.followup([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) + expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false) + }) + + it('stays balanced when a terminal stop discards pending steering', async () => { + const adapter = new MockAdapter([textResponse('done')]) + const ctx = await harness(adapter) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + const discards: number[] = [] + ctx.on('agent/inbox/discard', (subject, messages) => { if (subject === agent) discards.push(messages.length) }) + + // A continuation reason enqueues a steering item; a terminal stop then drops + // it. The drop must emit a discard so the enqueue ⇒ dequeue-or-discard + // ledger stays balanced (no dangling outstanding id). + ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => { + if (subject !== agent) return next() + return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } } + }) + let stopped = false + ctx.on('agent/turn-stop', (subject) => { + if (subject !== agent || stopped) return undefined + stopped = true + return { action: 'stop' as const } + }) + + agent.followup([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + expect(discards).toEqual([1]) // the dropped steering item was reported + expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) + expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false) + }) + + it('stays balanced when late steering lands after a terminal stop (post-turn flush window)', async () => { + const adapter = new MockAdapter([textResponse('done')]) + const ctx = await harness(adapter) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + let enqueues = 0 + const discards: number[] = [] + ctx.on('agent/inbox/enqueue', (subject) => { if (subject === agent) enqueues += 1 }) + ctx.on('agent/inbox/discard', (subject, messages) => { if (subject === agent) discards.push(messages.length) }) + + // Terminal-stop the turn, then steer during the post-turn flush window + // (status is still running). That late steer is drained by runLoop and + // dropped because the turn terminally stopped; it must still be discarded so + // its enqueue is matched (the drain sits on a different code path than the + // in-turn terminal-stop drop). + ctx.on('agent/turn-stop', subject => (subject === agent ? { action: 'stop' as const } : undefined)) + let steered = false + ctx.on('session/flush', (session) => { + if (session !== agent.session || steered) return + steered = true + agent.steer([{ type: 'text', text: 'late' }], { source: { kind: 'plugin', plugin: 'late' } }) + }) + + agent.followup([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + // The prompt plus the late steer both enqueued; both are matched (the prompt + // dequeued, the late steer discarded) so no id is left outstanding. + expect(enqueues).toBe(2) + expect(discards).toEqual([1]) + expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) + expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false) + }) +}) diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 99cae1ae77..289cd2f6d5 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -1,10 +1,20 @@ import { describe, expect, it } from 'vitest' -import { Inbox } from '../src/inbox.ts' +import { AgentMessageId } from '@deepseek-ai/dsh-agent' +import { Inbox, agentMessage } from '../src/inbox.ts' function message(text: string) { - return { content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [] } + return { id: AgentMessageId(text), content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [], wakeup: true } } +describe('agentMessage', () => { + it('returns a frozen payload so a listener cannot mutate it for later listeners', () => { + const payload = agentMessage(message('m'), false) + expect(Object.isFrozen(payload)).toBe(true) + expect(() => { (payload as { id: string }).id = 'mutated' }).toThrow() + expect(payload.id).toBe(AgentMessageId('m')) + }) +}) + function resolverPair() { let r!: () => void const p = new Promise((resolve) => { r = resolve }) @@ -25,6 +35,32 @@ describe('Inbox', () => { expect(inbox.dequeueQueued()).toBeUndefined() }) + it('enqueue(msg, false) queues without waking a parked waiter', async () => { + const inbox = new Inbox() + let woke = false + const waiter = inbox.waitForQueued(new Promise(() => {})).then(() => { woke = true }) + inbox.enqueue(message('quiet'), false) + // The item is queued, but the parked waiter was not resolved by it. + expect(inbox.hasQueued).toBe(true) + await Promise.resolve() + expect(woke).toBe(false) + // A later waking enqueue resolves the same waiter. + inbox.enqueue(message('loud')) + await waiter + expect(woke).toBe(true) + }) + + it('pending() snapshots queued then steering without removing them', () => { + const inbox = new Inbox() + inbox.enqueue(message('q')) + inbox.steer(message('s')) + const pending = inbox.pending() + expect(pending.map(p => p.steering)).toEqual([false, true]) + // Snapshot does not drain the FIFOs. + expect(inbox.hasQueued).toBe(true) + expect(inbox.hasSteering).toBe(true) + }) + it('pushes and drains steering messages separately from queued', () => { const inbox = new Inbox() inbox.steer(message('steer')) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 9e1663dcfe..de721b1653 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function send(agent: Agent, text: string) { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } function events(agent: Agent): SessionEvent[] { @@ -87,7 +87,7 @@ describe('agent/prompt-submit', () => { expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original') }) - it('allow with additionalContexts injects separate context/message events into the turn', async () => { + it('allow with additionalContexts injects separate injected-context user messages into the turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -107,12 +107,12 @@ describe('agent/prompt-submit', () => { await waitForIdle(ctx, agent) const log = events(agent) - const userMsg = log.find(e => e.type === 'user/message') - const ctxMsg = log.find(e => e.type === 'context/message') + const userMsg = log.find(e => e.type === 'user/message' && e.data.source.kind === 'user') + const ctxMsg = log.find(e => e.type === 'user/message' && e.data.source.kind === 'plugin') expect(userMsg).toBeDefined() - expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: 'extra ctx' }]) - expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' }) - expect(ctxMsg?.type === 'context/message' && ctxMsg.data.meta).toEqual(meta) + expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: 'extra ctx' }]) + expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' }) + expect(ctxMsg?.type === 'user/message' && ctxMsg.data.meta).toEqual(meta) const sent = JSON.stringify(adapter.requests[0]!.messages) expect(sent).toContain('extra ctx') }) @@ -128,7 +128,7 @@ describe('agent/prompt-submit', () => { ? downstream : { ...downstream, content: [{ type: 'text', text: 'rewritten request' }] } }) - agent.send([{ type: 'text', text: 'original request' }], { + agent.followup([{ type: 'text', text: 'original request' }], { contexts: [{ content: [{ type: 'text', text: 'untrusted prefix' }], source: { kind: 'plugin', plugin: 'prefix' }, @@ -155,7 +155,7 @@ describe('agent/prompt-submit', () => { }], }, }) - expect(log.some(event => event.type === 'context/message')).toBe(false) + expect(log.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false) expect(adapter.requests[0]?.messages.at(-1)).toEqual({ role: 'user', content: [ @@ -203,7 +203,7 @@ describe('agent/prompt-submit', () => { const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) - agent.send([{ type: 'text', text: 'do something' }], { + agent.followup([{ type: 'text', text: 'do something' }], { contexts: [{ content: [{ type: 'text', text: 'must be dropped' }], source: { kind: 'plugin', plugin: 'test' } }], }) await waitForIdle(ctx, agent) @@ -215,7 +215,6 @@ describe('agent/prompt-submit', () => { expect(log.some(e => e.type === 'turn/start')).toBe(true) expect(log.some(e => e.type === 'turn/end')).toBe(true) expect(log.some(e => e.type === 'user/message')).toBe(false) - expect(log.some(e => e.type === 'context/message')).toBe(false) expect(log.some(e => e.type === 'step/start')).toBe(false) // the veto is recorded durably as a prompt/blocked in the open turn const blocked = log.find(e => e.type === 'prompt/blocked') @@ -340,8 +339,8 @@ describe('agent/session-start', () => { // the injected context reached the model on the first (only) request expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble') // and is recorded with the plugin source, never mislabeled as a user prompt - const ctxMsg = events(agent).find(e => e.type === 'context/message') - expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' }) + const ctxMsg = events(agent).find(e => e.type === 'user/message' && e.data.source.kind === 'plugin') + expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' }) }) it('a throwing session-start listener does not abort agent construction', async () => { @@ -624,23 +623,22 @@ describe('tool additionalContexts buffering across a step', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // Event order in the log: both tool/results, THEN both context/messages — + // Event order in the log: both tool/results, THEN both injected contexts — // never interleaved (which would break tool-call/result adjacency). - const types = events(agent).map(e => e.type) - const firstResult = types.indexOf('tool/result') - const lastResult = types.lastIndexOf('tool/result') - const firstCtx = types.indexOf('context/message') + const injected = events(agent).filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin') + const seqs = events(agent) + const firstResult = seqs.findIndex(e => e.type === 'tool/result') + const lastResult = seqs.map(e => e.type).lastIndexOf('tool/result') + const firstCtx = seqs.findIndex(e => e === injected[0]) expect(firstResult).toBeGreaterThanOrEqual(0) expect(lastResult).toBeGreaterThan(firstResult) // two results expect(firstCtx).toBeGreaterThan(lastResult) // context only after ALL results // both contexts present - const ctxTexts = events(agent) - .filter(e => e.type === 'context/message') - .flatMap(e => (e.type === 'context/message' ? e.data.content : [])) + const ctxTexts = injected + .flatMap(e => (e.type === 'user/message' ? e.data.content : [])) .map(b => (b.type === 'text' ? b.text : '')) expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2']) - const contextEvents = events(agent).filter(e => e.type === 'context/message') - expect(contextEvents.map(e => e.type === 'context/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }]) + expect(injected.map(e => e.type === 'user/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }]) }) it('appends multiple contexts deferred by one composite tool after its outer result', async () => { @@ -661,14 +659,14 @@ describe('tool additionalContexts buffering across a step', () => { const log = events(agent) const resultIndex = log.findIndex(event => event.type === 'tool/result') - const contextEvents = log.filter(event => event.type === 'context/message') + const contextEvents = log.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin') expect(resultIndex).toBeGreaterThanOrEqual(0) expect(log.findIndex(event => event === contextEvents[0])).toBeGreaterThan(resultIndex) - expect(contextEvents.map(event => event.type === 'context/message' && event.data.source)).toEqual([ + expect(contextEvents.map(event => event.type === 'user/message' && event.data.source)).toEqual([ { kind: 'plugin', plugin: 'a' }, { kind: 'plugin', plugin: 'b' }, ]) - expect(contextEvents.map(event => event.type === 'context/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }]) + expect(contextEvents.map(event => event.type === 'user/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }]) }) }) @@ -750,13 +748,13 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se const log = events(agent) // session-start preamble injected - expect(log.some(e => e.type === 'context/message' + expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin' && e.data.content.some(b => b.type === 'text' && b.text.includes('policy active (started: startup)')))).toBe(true) - // prompt allowed → user/message recorded - expect(log.some(e => e.type === 'user/message')).toBe(true) + // prompt allowed → user-sourced user/message recorded + expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'user')).toBe(true) // tool ran (echo allowed) and post-execute attached "audited" context expect(log.some(e => e.type === 'tool/result' && !e.data.isError)).toBe(true) - expect(log.some(e => e.type === 'context/message' + expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin' && e.data.content.some(b => b.type === 'text' && b.text === 'audited'))).toBe(true) // NO hook/* events — a native plugin needs none expect(log.some(e => e.type.startsWith('hook/'))).toBe(false) diff --git a/packages/core/agent-loop/tests/invariant.spec.ts b/packages/core/agent-loop/tests/invariant.spec.ts index aa8bd5d6d5..cb0dcd2384 100644 --- a/packages/core/agent-loop/tests/invariant.spec.ts +++ b/packages/core/agent-loop/tests/invariant.spec.ts @@ -42,7 +42,7 @@ describe('request-reconstruction invariant', () => { it('uses the step boundary rather than content appended afterward', async () => { const { ctx, session, boundary } = await requestSetup() - session.append('context/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' }) + session.append('user/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' }) const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id }) expect(() => { dispatch(ctx, options) }).not.toThrow() }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index f8f1901a20..8875b51d67 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function send(agent: Agent, text: string) { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } describe('agent loop', () => { @@ -391,7 +391,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } }) - // The idle inject records a self-contained turn (turn/start → context/message + // The idle inject records a self-contained turn (turn/start → user/message // → turn/end) so the event stays turn-enclosed, but does NOT run the model. await new Promise(r => setTimeout(r, 20)) expect(agent.status).toBe('idle') @@ -427,8 +427,8 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const contextEvent = agent.session.events.find(event => event.type === 'context/message') - expect(contextEvent?.type === 'context/message' && contextEvent.data).toMatchObject({ meta }) + const contextEvent = agent.session.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') + expect(contextEvent?.type === 'user/message' && contextEvent.data).toMatchObject({ meta }) const requestText = JSON.stringify(adapter.requests[0]!.messages) expect(requestText).toContain('Additional instructions from: pkg/AGENTS.md') expect(requestText).not.toContain(' { }) first.text = 'mutated after inject' agent.inject([{ type: 'text', text: 'second notice' }], { source: { kind: 'plugin', plugin: 'x' } }) - visibleDuringTool = agent.session.events.some(e => e.type === 'context/message') + visibleDuringTool = agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin') return [{ type: 'text', text: 'ok' }] }, })) @@ -473,13 +473,13 @@ describe('agent loop', () => { const ts0 = turnStarts[0]! expect(ts0.type === 'turn/start' && ts0.data.trigger.kind).toBe('message') const result = agent.session.events.find(e => e.type === 'tool/result')! - const contexts = agent.session.events.filter(e => e.type === 'context/message') + const contexts = agent.session.events.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin') expect(contexts).toHaveLength(2) expect(result.seq).toBeLessThan(contexts[0]!.seq) - expect(contexts[0]?.type === 'context/message' && contexts[0].data).toMatchObject({ + expect(contexts[0]?.type === 'user/message' && contexts[0].data).toMatchObject({ meta, }) - expect(contexts.flatMap(event => event.type === 'context/message' ? event.data.content : [])) + expect(contexts.flatMap(event => event.type === 'user/message' ? event.data.content : [])) .toEqual([ { type: 'text', text: 'mid-turn notice' }, { type: 'text', text: 'second notice' }, @@ -523,7 +523,29 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - expect(agent.session.events.some(event => event.type === 'context/message')).toBe(false) + expect(agent.session.events.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false) + }) + + it('preserves SendOptions.meta on the durable user/message and steering/message', async () => { + const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')]) + const ctx = await harness(adapter) + ctx.tools.register(defineContentToolFixture({ + name: 'noop', description: '', parameters: {}, + async execute() { + // Running steer carries its own meta onto the durable steering/message. + agent.steer([{ type: 'text', text: 's' }], { source: { kind: 'plugin', plugin: 'p' }, meta: { steer: 1 } }) + return [] + }, + })) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + agent.followup([{ type: 'text', text: 'go' }], { meta: { prompt: 1 } }) + await waitForIdle(ctx, agent) + + const user = agent.session.events.find(e => e.type === 'user/message') + expect(user?.type === 'user/message' && user.data.meta).toEqual({ prompt: 1 }) + const steering = agent.session.events.find(e => e.type === 'steering/message') + expect(steering?.type === 'steering/message' && steering.data.meta).toEqual({ steer: 1 }) }) it('agent/turn-continuation can force-continue (/loop pattern) and force-stop', async () => { @@ -632,7 +654,7 @@ describe('agent loop', () => { ctx.on('agent/pre-step', (subject) => { if (subject === agent && !injected) { injected = true - subject.session.append('context/message', { + subject.session.append('user/message', { content: [{ type: 'text', text: 'INJECTED-IN-PRE-STEP' }], source: { kind: 'plugin', plugin: 'test' }, }, { surfaceOp: 'append' }) @@ -650,7 +672,7 @@ describe('agent loop', () => { // And the injected event sits BEFORE the first step/start in the log — // the seam fired outside the step. const events = agent.session.events - const injectedSeq = events.find(e => e.type === 'context/message')!.seq + const injectedSeq = events.find(e => e.type === 'user/message' && e.data.source.kind === 'plugin')!.seq const firstStepStartSeq = events.find(e => e.type === 'step/start')!.seq expect(injectedSeq).toBeLessThan(firstStepStartSeq) }) @@ -1028,13 +1050,13 @@ describe('agent loop', () => { expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('turn-end listener message') }) - it('keeps a reentrant agent/queued send as the next independent turn', async () => { + it('keeps a reentrant agent/inbox/enqueue send as the next independent turn', async () => { const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let nested = false - ctx.on('agent/queued', (subject) => { + ctx.on('agent/inbox/enqueue', (subject) => { if (subject !== agent || nested) return nested = true send(agent, 'queued listener message') @@ -1061,9 +1083,9 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const idle = waitForIdle(ctx, agent) - agent.send([{ type: 'text', text: 'user message' }]) + agent.followup([{ type: 'text', text: 'user message' }]) await Promise.resolve() - agent.send( + agent.followup( [{ type: 'text', text: 'plugin message' }], { source: { kind: 'plugin', plugin: 'test' } }, ) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 85efda0e4e..593b32ab38 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -115,7 +115,7 @@ describe('agent loop scheduling properties', () => { const { seen: trace } = recordStatus(ctx, agent) const idle = nextIdle(ctx, agent) // Send all in one synchronous tick: they queue before the loop wakes. - for (const text of texts) agent.send([{ type: 'text', text }]) + for (const text of texts) agent.followup([{ type: 'text', text }]) await idle // No message lost: every send appears as a user/message, in order. @@ -142,7 +142,7 @@ describe('agent loop scheduling properties', () => { const agent = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) for (const text of texts) { const idle = nextIdle(ctx, agent) - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) await idle } // Each send was drained at a separate turn start: N turns, 1..N. @@ -171,7 +171,7 @@ describe('agent loop scheduling properties', () => { for (const step of steps) { const idle = nextIdle(ctx, agent) lastIdle = idle - agent.send([{ type: 'text', text: step.text }]) + agent.followup([{ type: 'text', text: step.text }]) if (step.settle) await idle } await lastIdle diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index f1e1a1a367..37079b8565 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -73,10 +73,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits ( const agent = ctx.agentLoop.create(SessionId('cache-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) // Turn 1: forces a tool call → at least two steps (two model requests). - agent.send([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }]) + agent.followup([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }]) await waitForIdle(ctx, agent) // Turn 2: a follow-up over the same (longer) prefix. - agent.send([{ type: 'text', text: 'Thanks. Repeat that value one more time.' }]) + agent.followup([{ type: 'text', text: 'Thanks. Repeat that value one more time.' }]) await waitForIdle(ctx, agent) const usages = [...agent.session.events] diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 63c59618e3..a73218f345 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function send(agent: Agent, text: string) { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } /** Assert `previous` is a strict value-prefix of `current`. */ @@ -118,7 +118,7 @@ describe('request stability across the loop', () => { preStep() const session = agent.session const nodes = session.surface.nodes - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: '[summary of turn 1]' }], source: { kind: 'plugin', plugin: 'test-compact' }, }, { @@ -180,7 +180,7 @@ describe('request stability across the loop', () => { const first = adapter.requests[0]! // The inject landed in the log after the boundary: not in THIS request… expect(first.messages.some(m => m.content.some(b => b.type === 'text' && b.text.includes('[late context]')))).toBe(false) - expect(agent.session.events.some(e => e.type === 'context/message')).toBe(true) + expect(agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')).toBe(true) send(agent, 'second') await waitForIdle(ctx, agent) diff --git a/packages/core/agent-loop/tests/request-recovery.spec.ts b/packages/core/agent-loop/tests/request-recovery.spec.ts index 3bbfeba47f..1fc44e3431 100644 --- a/packages/core/agent-loop/tests/request-recovery.spec.ts +++ b/packages/core/agent-loop/tests/request-recovery.spec.ts @@ -114,7 +114,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function send(agent: Agent): void { - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) } function contextError(message = 'context too large'): LlmError { @@ -154,12 +154,15 @@ describe('agent post-step and request-error lifecycle', () => { const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' }) const order: string[] = [] ctx.on('session/event', (_session, event) => { + // Injected context is a plugin-sourced user/message; the direct human + // prompt (user source) stays untracked as before. + const isInjected = event.type === 'user/message' && event.data.source.kind !== 'user' if ( event.type === 'assistant/message' || event.type === 'tool/call' - || event.type === 'tool/result' || event.type === 'context/message' + || event.type === 'tool/result' || isInjected || event.type === 'steering/message' || event.type === 'step/end' ) { - if (!('step' in event.data) || event.data.step === 1) order.push(event.type) + if (!('step' in event.data) || event.data.step === 1) order.push(isInjected ? 'context/message' : event.type) } }) ctx.on('agent/post-step', (subject, turn, step, signal) => { @@ -271,7 +274,7 @@ describe('agent post-step and request-error lifecycle', () => { expect({ turn, step, code: error.code }).toEqual({ turn: 1, step: 1, code: CONTEXT_WINDOW_EXCEEDED_CODE }) expect(facts.code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE) attempts.push(history.length) - subject.session.append('context/message', { + subject.session.append('user/message', { content: [{ type: 'text', text: 'RECOVERY SURFACE MUTATION' }], source: { kind: 'plugin', plugin: 'test-recovery' }, }, { surfaceOp: 'append' }) @@ -288,7 +291,7 @@ describe('agent post-step and request-error lifecycle', () => { const ends = agent.session.events.filter(event => event.type === 'step/end') expect(starts.map(event => event.data.step)).toEqual([1, 2]) expect(ends.map(event => event.data.step)).toEqual([1, 2]) - const recovery = agent.session.events.find(event => event.type === 'context/message')! + const recovery = agent.session.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')! expect(ends[0]!.seq).toBeLessThan(recovery.seq) expect(recovery.seq).toBeLessThan(starts[1]!.seq) }) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index fdf5c39514..a9bd0a0355 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -146,7 +146,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const adapter1 = new MockAdapter([textResponse('a')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent - a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -174,7 +174,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source)) const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent expect(sources1).toEqual(['startup']) - a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -480,7 +480,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent - a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) // Let inject()'s fire-and-forget flush settle (NO explicit flush/dispose). @@ -503,7 +503,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent - a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) await ctx1.sessions.flush(a1.session) @@ -531,7 +531,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const adapter1 = new MockAdapter([textResponse('first answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent - a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) const events1 = [...a1.session.events] const seqs1 = events1.map(e => e.seq) @@ -558,7 +558,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages()) // …and a new turn continues numbering (turn 2) with contiguous seqs. - a2.send([{ type: 'text', text: 'second question' }], { source: { kind: 'user' } }) + a2.followup([{ type: 'text', text: 'second question' }], { source: { kind: 'user' } }) await waitForIdle(ctx2, a2) const allSeqs = a2.session.events.map(e => e.seq) expect(allSeqs).toEqual(allSeqs.map((_, i) => i)) // 0..N contiguous, no duplicates diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 469a815d8b..8844e5c1a3 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -203,11 +203,11 @@ describe('agent scope lifecycle', () => { if (event.type === 'user/message') heard.push('a-sees:user-message') }) - b.send(text('for b')) + b.followup(text('for b')) await waitForIdle(ctx, b) expect(heard).toEqual([]) // nothing of b's leaked into a's scope - a.send(text('for a')) + a.followup(text('for a')) await waitForIdle(ctx, a) expect(heard).toContain('a-sees:a:running') expect(heard).toContain('a-sees:user-message') @@ -934,7 +934,7 @@ describe('agent scope lifecycle', () => { if (event.type === 'turn/start') { off(); resolve() } }) }) - agent.send(text('work')) + agent.followup(text('work')) await turnOpen await owner.dispose() expect(order).toEqual(['turn-end', 'disposed(listed=false)', 'session-still-stored=true']) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 93378142b3..0e7f1313cb 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -105,7 +105,7 @@ describe('tool-call scheduler: grouping and barriers', () => { ctx.tools.register(gated.tool) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 3) expect(gated.started).toEqual(['1', '2', '3']) gated.release('1'); gated.release('2'); gated.release('3') @@ -133,7 +133,7 @@ describe('tool-call scheduler: grouping and barriers', () => { async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] }, })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(order).toEqual(['r-start-A1', 'r-end-A1', 'w-A2', 'r-start-A3', 'r-end-A3']) @@ -169,7 +169,7 @@ describe('tool-call scheduler: grouping and barriers', () => { })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => replacement.started.length === 1) await new Promise(r => setTimeout(r, 5)) expect(replacement.started).toEqual(['1']) @@ -200,7 +200,7 @@ describe('tool-call scheduler: grouping and barriers', () => { }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => initial.started.length === 2) initial.release('1') await until(() => events(agent).some(event => @@ -226,7 +226,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme ctx.tools.register(gated.tool) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) gated.release('2') await new Promise(r => setTimeout(r, 5)) @@ -248,7 +248,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) gated.release('2'); gated.release('1') await waitForIdle(ctx, agent) @@ -294,7 +294,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => ctx.tools.register(gated.tool) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) await new Promise(r => setTimeout(r, 5)) expect(gated.started).toEqual(['1', '2']) @@ -323,7 +323,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 1) await new Promise(r => setTimeout(r, 5)) expect(gated.started).toEqual(['1']) @@ -349,7 +349,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 1) await new Promise(r => setTimeout(r, 5)) expect(gated.started).toEqual(['1']) @@ -376,7 +376,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () = ctx.on('tools/post-execute', async (exec, _result, next): Promise => { post.push(String(exec.callId)); return next() }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 3) gated.release('3'); gated.release('2'); gated.release('1') await waitForIdle(ctx, agent) @@ -397,17 +397,17 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () = ({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) gated.release('2'); gated.release('1') await waitForIdle(ctx, agent) const log = events(agent) - const contextTexts = log.filter(e => e.type === 'context/message') - .map(e => (e.data.content[0] as { text: string }).text) + const contextTexts = log.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin') + .map(e => ((e.data as { content: { text: string }[] }).content[0]!).text) expect(contextTexts).toEqual(['ctx-c1', 'ctx-c2']) const lastResult = log.findLastIndex(e => e.type === 'tool/result') - const firstContext = log.findIndex(e => e.type === 'context/message') + const firstContext = log.findIndex(e => e.type === 'user/message' && e.data.source.kind === 'plugin') expect(lastResult).toBeLessThan(firstContext) }) @@ -435,7 +435,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () = }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 1) gated.release('1') await waitForIdle(ctx, agent) @@ -465,7 +465,7 @@ describe('tool-call scheduler: abort handling', () => { } }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(gated.started).toEqual([]) @@ -497,7 +497,7 @@ describe('tool-call scheduler: abort handling', () => { return next() }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(gated.started).toEqual([]) @@ -527,7 +527,7 @@ describe('tool-call scheduler: abort handling', () => { })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) agent.cancel({ kind: 'user' }) gated.release('1') @@ -548,10 +548,11 @@ describe('tool-call scheduler: abort handling', () => { { callId: CallId('c3'), isError: true, errorInfo: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }, { callId: CallId('c4'), isError: true, errorInfo: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }, ]) - const settled = events(agent).filter(e => e.type === 'tool/result' || e.type === 'context/message') + const settled = events(agent).filter(e => e.type === 'tool/result' + || (e.type === 'user/message' && e.data.source.kind === 'plugin')) expect(settled.map(e => e.type)) - .toEqual(['tool/result', 'tool/result', 'tool/result', 'tool/result', 'context/message', 'context/message']) - expect(settled.filter(e => e.type === 'context/message') + .toEqual(['tool/result', 'tool/result', 'tool/result', 'tool/result', 'user/message', 'user/message']) + expect(settled.filter(e => e.type === 'user/message') .map(e => (e.data.content[0] as { text: string }).text)) .toEqual(['ctx-c1', 'ctx-c2']) }) @@ -577,7 +578,7 @@ describe('tool-call scheduler: abort handling', () => { })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) agent.cancel({ kind: 'user' }) gated.release('1') diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 76b14e92c2..a39961bfbf 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -58,7 +58,7 @@ async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConf const ctx = await harness(adapter, toolOrder) for (const name of registrationOrder) registerNamed(ctx, name) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) return { ctx, agent, adapter } } @@ -100,7 +100,7 @@ describe('loop-level canonical tool order', () => { const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; known tools: alpha']) diff --git a/packages/core/agent-loop/tests/turn-stop.spec.ts b/packages/core/agent-loop/tests/turn-stop.spec.ts index eb6e6b4da5..44e1fd8a7b 100644 --- a/packages/core/agent-loop/tests/turn-stop.spec.ts +++ b/packages/core/agent-loop/tests/turn-stop.spec.ts @@ -34,7 +34,7 @@ async function harness(adapter: MockAdapter): Promise { } function send(agent: Agent, text = 'go'): Promise { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) return agent.whenIdle() } @@ -116,7 +116,7 @@ describe('agent/turn-stop', () => { ctx.on('session/flush', (session) => { if (session !== agent.session || queued) return queued = true - agent.send([{ type: 'text', text: 'ordinary queued follow-up' }]) + agent.followup([{ type: 'text', text: 'ordinary queued follow-up' }]) }) await send(agent) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index b040d8cbdc..511ce32235 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -48,18 +48,20 @@ The lifecycle edges have two important local caveats. `agent/created` runs after Most interception points are cooperative waterfalls returning seam-specific decisions. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. The signal remains authoritative through terminal policy and is retired immediately before `turn/end` publication, so terminal observers and the following durability flush cannot cancel completed turn work. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. -`PromptDecision.additionalContexts` is an array so every context keeps its own source, metadata, and placement. `SendOptions.contexts` binds the same shape to one queued message before prompt interception: the default allow decision carries it forward, while a blocked prompt records no context. Absent or `separate` placement writes an independent `context/message`; `prompt-prefix` writes the context, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`, whose model-hidden envelope retains the direct prompt and context descriptors for human replay. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative. A `ContinuationDecision` reason is narrower: it becomes a `steering/message` without attached context metadata. +`PromptDecision.additionalContexts` is an array so every context keeps its own source, metadata, and placement. `SendOptions.contexts` binds the same shape to one queued message before prompt interception: the default allow decision carries it forward, while a blocked prompt records no context. Absent or `separate` placement writes an independent injected `user/message` (plugin/goal source); `prompt-prefix` writes the context, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`, whose model-hidden envelope retains the direct prompt and context descriptors for human replay. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative. A `ContinuationDecision` reason is narrower: it becomes a `steering/message` without attached context metadata. Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md). ### Agent interface (`types.ts`) -The handle every plugin programs against: +`Agent` is a structural interface. `followup()`, `queue()`, `steer()`, and `inject()` name common caller intents; `send(ResolvedAgentInput)` exposes the same acceptance path when a caller already has exact routing facts ([decision](../../../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). Every `ResolvedAgentInput` field is mandatory, and its discriminated union excludes attached contexts from non-waking next-step injection. FIFO acceptance returns an opaque `AgentMessageId` carried by that item's `agent/inbox/enqueue`/`dequeue`/`discard` events. The driver snapshots content, resolved source, attached contexts, and model-hidden metadata as one detached, deeply frozen lossless-JSON record before notification and enqueue; invalid data throws synchronously. The helpers apply defaults: omitting `options.source` on `followup()`, `queue()`, or `steer()` attests direct human input as `{ kind: 'user' }`, so every non-human producer labels its content. -- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Omitting `options.source` attests direct human input as `{ kind: 'user' }` and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content, resolved source, and `options.contexts` become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input. After admission, separate contexts become `context/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. -- `agent.steer(content, options?)` — while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; when idle, delegate to `send()`. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that steering event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. -- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message` with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). -- `agent.cancel(cause?)` — cancel ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; observers may synchronize state but cannot veto cancellation. The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. +- `agent.followup(content, options?)` — queue one independent FIFO message as its own turn and wake the driver. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. +- `agent.queue(content, options?)` — queue the same ordinary message without waking an idle driver. A lone queued item leaves `whenIdle()` resolved and rides along before the next waking message. +- `agent.steer(content, options?)` — while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; while idle, create a waking ordinary turn. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. +- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `user/message` (default plugin source) with `content` rendered verbatim as a user-role message. `InjectOptions` deliberately has no attached contexts. `options.meta` persists opaque JSON state without rendering it. While a turn is open the injection joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event. +- `agent.send(input)` — accept a fully specified route without helper defaults. `next-turn` targets the ordinary FIFO; `next-step` with wakeup targets steering and falls back to a waking ordinary turn while idle; `next-step` without wakeup is injection and requires `contexts: []`. Callers provide `meta: undefined` explicitly when they have no metadata. +- `agent.cancel(cause?, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` @@ -77,7 +79,7 @@ The handle every plugin programs against: #### What the model sees -`send`, `steer`, and `inject` feed the owning session. `agent/prompt-submit`, `agent/session-prefix`, and other declared events let plugins block a prompt or add request material; this interface contributes no fixed prose itself. +The four intent helpers and fully resolved `send` path feed the owning session. `agent/prompt-submit`, `agent/session-prefix`, and other declared events let plugins block a prompt or add request material; this interface contributes no fixed prose itself. #### Token effect @@ -107,6 +109,6 @@ Prefix-stable while an agent's scoped registrations are unchanged. Setup or relo - **Ambient identity may outlive liveness** — consumers still check `agent.status`, cancellation, and the owning capability contract before lifecycle-sensitive work. - **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam. - **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead. -- **No public step-only abort** — `cancel()` clears ALL pending work (queued + steering + in-flight); an abort that preserves queued prompts returns only with a named consumer ([stop-surface Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md)). +- **`cancel()` clears the inbox by default** — it aborts the in-flight turn plus queued and steering work; `cancel(cause, { keepInbox: true })` aborts only the turn and preserves pending items. There is still no step-only abort that keeps the in-flight turn running ([stop-surface Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md)). - **`HookContext` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable. - **`SessionStartSource` reserves `'clear'`/`'compact'` with no emitter yet** — only `'startup'`/`'resume'` occur until the driving subsystems land (`TODO(compaction)`). diff --git a/packages/core/agent/src/invariant.ts b/packages/core/agent/src/invariant.ts index 1902f3e746..a5a7725707 100644 --- a/packages/core/agent/src/invariant.ts +++ b/packages/core/agent/src/invariant.ts @@ -24,6 +24,27 @@ const install: InvariantInstaller = (ctx, fail) => { } lastStatus.set(agent, status) }, { global: true }) + + // Inbox FIFO conservation: an item leaves the inbox (dequeue) or is dropped + // (discard) only after it entered (enqueue), so the live outstanding count + // per agent can never go negative. Injection bypasses the FIFOs entirely and + // never appears on these events. + const outstanding = new WeakMap() + ctx.on('agent/inbox/enqueue', (agent) => { + outstanding.set(agent, (outstanding.get(agent) ?? 0) + 1) + }, { global: true }) + ctx.on('agent/inbox/dequeue', (agent) => { + const count = outstanding.get(agent) ?? 0 + if (count <= 0) fail('agent/inbox/dequeue without a matching prior enqueue') + outstanding.set(agent, count - 1) + }, { global: true }) + ctx.on('agent/inbox/discard', (agent, items) => { + const count = outstanding.get(agent) ?? 0 + if (items.length > count) { + fail(`agent/inbox/discard dropped ${items.length} items but only ${count} were outstanding`) + } + outstanding.set(agent, count - items.length) + }, { global: true }) } /** diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index dc78d76ef5..0f442e3f88 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -6,6 +6,7 @@ */ import type { Context } from 'cordis' +import type { Branded } from '@deepseek-ai/dsh-brand' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { ContentBlock, LlmCallConfig, LlmFailure, Message, MessageSource } from '@deepseek-ai/dsh-llm' import type { JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session' @@ -26,8 +27,9 @@ export interface AgentOptions { } /** - * Message options. An omitted source attests direct human input as `{ kind: 'user' }` - * and may authorize policy consumers, so non-human producers must label their content. + * Options for {@link Agent.followup}, {@link Agent.queue}, and {@link Agent.steer}. + * An omitted source attests direct human input as `{ kind: 'user' }` and may + * authorize policy consumers, so non-human producers must label their content. */ export interface SendOptions { source?: MessageSource @@ -37,19 +39,71 @@ export interface SendOptions { * records them directly at its next checkpoint. */ contexts?: HookContext[] + /** Opaque JSON state retained on the durable message but hidden from the model. */ + meta?: JsonValue } /** Options specific to durable synthetic context injection. */ -export interface InjectOptions extends Omit { - /** Opaque JSON state retained in the session event but hidden from the model. */ +export interface InjectOptions { + /** Defaults to `{ kind: 'plugin', plugin: '' }`; non-human producers should identify themselves. */ + source?: MessageSource + /** Opaque JSON state retained on the durable message but hidden from the model. */ meta?: JsonValue } +/** + * Opaque id assigned to one accepted agent input. FIFO inputs carry the same id + * on their `agent/inbox/*` events; injection bypasses those events. + */ +export type AgentMessageId = Branded<'AgentMessageId'> + +/** + * Brand a string as an {@link AgentMessageId}. + * @param id - the generated message id. + * @returns the same string, branded; no validation is performed. + */ +export function AgentMessageId(id: string): AgentMessageId { + return id as AgentMessageId +} + +/** + * One accepted FIFO message, carried by the `agent/inbox/*` live events. `id` + * is the value returned by the accepting helper or {@link Agent.send}, + * stable across this message's enqueue, dequeue, and discard events. Source + * defaults, when applicable, are already applied, so these are the exact values + * the item was accepted with. + * `steering` is true for an item drained between steps; otherwise it is claimed + * at a turn boundary. `SendOptions.meta` is intentionally omitted: it is durable + * model-hidden state that lands on the eventual `user/message`/ + * `steering/message`, not live-event routing data. + */ +export interface AgentMessage { + /** The id returned by the accepting helper or {@link Agent.send}. */ + id: AgentMessageId + content: ContentBlock[] + source: MessageSource + contexts: HookContext[] + /** Whether the item joined the steering FIFO rather than the queued FIFO. */ + steering: boolean + /** Whether the item wakes the driver or requests another step. */ + wakeup: boolean +} + +/** Options for {@link Agent.cancel}. */ +export interface CancelOptions { + /** + * Preserve queued and steering inbox items instead of discarding them. The + * active turn is still aborted, but un-started and pending work survives for a + * later turn and no `agent/inbox/discard` fires. + */ + keepInbox?: boolean +} + /** * An agent's lifecycle state, emitted on every transition as `agent/status`: * `idle` (parked, waiting for queued work), `running` (the driver is draining * work and may be closing or checkpointing a turn), `disposed` (terminal — no - * transition leaves it, and `send`/`steer`/`inject` throw). + * transition leaves it, and every delivery method throws). */ export type AgentStatus = 'idle' | 'running' | 'disposed' @@ -58,8 +112,8 @@ export interface HookContext { content: ContentBlock[] source: MessageSource /** - * Model placement. Absent or `separate` records an independent - * `context/message`; `prompt-prefix` prepends this context and a stable + * Model placement. Absent or `separate` records an independent injected + * `user/message`; `prompt-prefix` prepends this context and a stable * request delimiter to the same user-role message as its attached prompt. */ placement?: 'separate' | 'prompt-prefix' @@ -67,6 +121,22 @@ export interface HookContext { meta?: JsonValue } +/** + * Fully specified input for {@link Agent.send}. Unlike the intent-named + * helpers, this form applies no defaults: callers provide content, source, + * contexts, metadata (including explicit `undefined`), target, and wakeup. + * The union excludes attached contexts from non-waking next-step injection. + */ +export type ResolvedAgentInput = { + content: ContentBlock[] + source: MessageSource + meta: JsonValue | undefined +} & ( + | { target: 'next-turn'; wakeup: boolean; contexts: HookContext[] } + | { target: 'next-step'; wakeup: true; contexts: HookContext[] } + | { target: 'next-step'; wakeup: false; contexts: [] } +) + /** * Prompt interception result. `allow.content` replaces the prompt. Each * `additionalContexts` entry follows its declared placement: separate context @@ -113,54 +183,90 @@ export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed export interface Agent { /** The single identity shared with {@link session}. */ readonly id: SessionId + /** The provider route and model this agent's requests use. */ readonly options: AgentOptions + /** The live session this agent drives; its log is the durable source of truth. */ readonly session: Session + /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context /** - * Queue one detached, frozen lossless-JSON item. If claimed, it is the sole - * ordinary message in its FIFO-ordered turn; the next claimed item waits for - * that turn's checkpoint. - * Attached contexts share the same snapshot and ownership boundary. Invalid - * input throws synchronously before notification or enqueue. + * Queue an ordinary message as its own FIFO-ordered turn and wake the driver. + * Content, resolved source, and attached contexts are detached, validated, + * and frozen together; invalid input throws synchronously before notification + * or enqueue. + * @param content - the prompt content blocks. + * @param options - source, attached contexts, and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - send(content: ContentBlock[], options?: SendOptions): void + followup(content: ContentBlock[], options?: SendOptions): AgentMessageId /** - * Submit steering while the agent is `running`. An open turn records it at - * the next steering checkpoint before a request or continuation decision; - * policy may stop before another step. After turn close and its checkpoint, - * any remainder is queued for a later turn; terminal `agent/turn-stop`, - * cancellation, or disposal may discard it. Uses the same synchronous - * snapshot-and-validation boundary as {@link send}; when idle, delegates to it. + * Queue an ordinary message without waking an idle driver. The item retains + * FIFO order and is claimed only after another input wakes the driver. A lone + * queued item leaves `whenIdle()` resolved. + * @param content - the prompt content blocks. + * @param options - source, attached contexts, and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - steer(content: ContentBlock[], options?: SendOptions): void + queue(content: ContentBlock[], options?: SendOptions): AgentMessageId + + /** + * Submit steering into the running turn and request another step. An open turn + * records it at the next steering checkpoint before a request or continuation + * decision; policy may stop before another step. After turn close and its + * checkpoint, any remainder is queued for a later turn; terminal + * `agent/turn-stop`, cancellation, or disposal may discard it. Idle steering + * becomes a waking ordinary turn. + * @param content - the steering content blocks. + * @param options - source, attached contexts, and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. + */ + steer(content: ContentBlock[], options?: SendOptions): AgentMessageId /** * Append detached model-facing context without running the model. An open-turn * injection joins at the current log position unless the current tool batch is - * executing; then it waits FIFO until that batch settles and drains before turn - * close even when interrupted. Idle injection uses a one-shot turn and durability - * checkpoint. Disposal awaits idle checkpoints; flush failures report through `agent/error`. + * executing; then it waits FIFO until that batch settles and drains before + * turn close even when interrupted. Idle injection uses a one-shot turn and + * durability checkpoint. Disposal awaits idle checkpoints; flush failures + * report through `agent/error`. An omitted source defaults to + * `{ kind: 'plugin', plugin: '' }`. + * @param content - the injected context content blocks. + * @param options - source and durable model-hidden meta. + * @returns the accepted injection's {@link AgentMessageId}; injection emits no `agent/inbox/*` events. */ - inject(content: ContentBlock[], options?: InjectOptions): void + inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId /** - * Clear all queued and steering work, including items waiting to start, and - * abort the active turn. An effective call first emits - * `agent/cancel-requested` with the resolved typed cause. The first cause wins - * for the active turn, and `whenIdle()` resolves after cancellation reaches - * quiescence. Omission means `{ kind: 'user' }`. Idle cancellation is a no-op - * and does not arm later work. The active turn snapshots and freezes the cause. - * @param cause - the stable caller intent carried by the current turn signal. + * Accept one fully specified input through the same snapshot and routing path + * as the four intent-named helpers. `next-turn` targets the ordinary FIFO; + * `next-step`/wakeup targets steering (falling back to an ordinary waking turn + * while idle); and `next-step` without wakeup injects durable context without + * running the model. Every field is mandatory and no source or routing default + * is applied. Invalid input throws synchronously before notification, enqueue, + * or append. + * @param input - the resolved content, attribution, context, metadata, and routing facts. + * @returns the accepted input's {@link AgentMessageId}, carried by FIFO lifecycle events when applicable. */ - cancel(cause?: AgentCancelCause): void + send(input: ResolvedAgentInput): AgentMessageId + + /** + * Clear queued and steering work — unless `keepInbox` — and abort the active + * turn. An effective call first emits `agent/cancel-requested` with the + * resolved typed cause. The first cause wins for the active turn, and + * `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause + * means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm + * later work. The active turn snapshots and freezes the cause. + * @param cause - the stable caller intent carried by the current turn signal. + * @param options - cancellation options; `keepInbox` preserves pending work. + */ + cancel(cause?: AgentCancelCause, options?: CancelOptions): void /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ whenIdle(): Promise - } declare module 'cordis' { @@ -187,8 +293,8 @@ declare module 'cordis' { */ 'agent/disposed'(this: Scoped, agent: Agent): void /** - * Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does - * not enter `running` synchronously; drive lifecycle from this event. + * Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking + * delivery does not enter `running` synchronously; drive lifecycle from this event. * @param agent - the agent whose status flipped. * @param status - the status just entered (the transition's destination). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. @@ -196,15 +302,42 @@ declare module 'cordis' { */ 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void /** - * Detached, frozen content entered the agent's inbox. Source defaults have - * already been applied, so these are the exact values retained for the log. - * @param agent - the agent whose inbox received the message. - * @param content - the accepted content blocks retained by the inbox. - * @param info - the accepted source, contexts, and whether it entered as steering. + * A detached, frozen item entered the agent's inbox (queued or steering + * FIFO). Source defaults are already applied, so `message` holds the exact + * accepted values. This is the enqueue-time live signal; the durable record + * is the eventual `user/message`/`steering/message`. Injection through + * `agent.inject()` or equivalent `send()` routing bypasses the FIFOs + * and does not emit this. + * @param agent - the agent whose inbox received the item. + * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void + 'agent/inbox/enqueue'(this: Scoped, agent: Agent, message: AgentMessage): void + /** + * The driver claimed one item out of the inbox: a queued item at a turn + * boundary, or steering drained between steps. Fires after the item leaves + * its FIFO and before it becomes a durable message. + * @param agent - the agent whose inbox item was claimed. + * @param message - the claimed message (matching the `id` from its `agent/inbox/enqueue`). + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ + 'agent/inbox/dequeue'(this: Scoped, agent: Agent, message: AgentMessage): void + /** + * Pending inbox items were dropped without delivering them, so every + * enqueued id receives exactly one terminal `agent/inbox/dequeue` OR + * `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after + * `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop` + * dropping pending steering (in-turn and on the post-turn late-steering + * drain); and disposal of any still-pending items (before + * `agent/status('disposed')`). Fires once per drop with every dropped item. + * @param agent - the agent whose inbox items were dropped. + * @param messages - the discarded messages in FIFO order (queued then steering); never empty. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ + 'agent/inbox/discard'(this: Scoped, agent: Agent, messages: AgentMessage[]): void /** * Effective broad cancellation was requested, before queued/steering work * is cleared or the active turn is aborted. This observe-only notification diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 509ad14a22..b5b0424957 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -3,13 +3,24 @@ import { Context, Service, symbols } from 'cordis' import type { Events } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { + AgentMessageId, agentEvents, agentInterruptReasonOf, } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentCancelCause, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' +import type { + Agent, + AgentCancelCause, + AgentFactory, + ContinuationStop, + CreateAgentOptions, + InjectOptions, + ResolvedAgentInput, + ResumeAgentOptions, + SendOptions, +} from '@deepseek-ai/dsh-agent' -function stubAgent(rawId: string): Agent { +function stubAgent(rawId: string, overrides: Partial = {}): Agent { const id = SessionId(rawId) return { id, @@ -17,15 +28,34 @@ function stubAgent(rawId: string): Agent { session: new Session(id), status: 'idle', ctx: new Context(), - send() {}, - steer() {}, - inject() {}, + followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), + inject: () => AgentMessageId('stub'), + send: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, + ...overrides, } } describe('AgentRegistry', () => { + it('keeps helper options semantic and makes advanced input fully specified', () => { + type OptionalInputKey = { + [Key in keyof ResolvedAgentInput]-?: Record extends Pick + ? Key + : never + }[keyof ResolvedAgentInput] + + expectTypeOf<'target' extends keyof SendOptions ? true : false>().toEqualTypeOf() + expectTypeOf<'wakeup' extends keyof SendOptions ? true : false>().toEqualTypeOf() + expectTypeOf<'contexts' extends keyof InjectOptions ? true : false>().toEqualTypeOf() + expectTypeOf[0]>().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf['contexts']>() + .toEqualTypeOf<[]>() + }) + it('allows terminal stop policy to cooperate asynchronously with turn cancellation', () => { type TurnStopListener = Events['agent/turn-stop'] type AsyncTurnStopListener = () => Promise @@ -56,7 +86,7 @@ describe('AgentRegistry', () => { it('rejects an agent whose registry and session identities differ', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - const agent = { ...stubAgent('agent-id'), session: new Session(SessionId('session-id')) } + const agent = stubAgent('agent-id', { session: new Session(SessionId('session-id')) }) expect(() => ctx.agents.enter(agent, undefined)) .toThrow('agent id "agent-id" does not match session id "session-id"') diff --git a/packages/core/agent/tests/invariant.spec.ts b/packages/core/agent/tests/invariant.spec.ts index 3c0d147b9a..453752c402 100644 --- a/packages/core/agent/tests/invariant.spec.ts +++ b/packages/core/agent/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import { scopeTarget } from '@deepseek-ai/dsh-scope' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -56,3 +56,41 @@ describe('agent status invariants', () => { expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow() }) }) + +describe('agent inbox invariants', () => { + const info = (steering: boolean) => ({ id: AgentMessageId('m'), content: [], source: { kind: 'user' as const }, contexts: [], steering, wakeup: true }) + + it('accepts a dequeue and a discard covered by prior enqueues', async () => { + const ctx = await setup() + const agent = mockAgent('i1') + const at = scopeTarget(agent, agent) + expect(() => { + ctx.emit(at, 'agent/inbox/enqueue', agent, info(false)) + ctx.emit(at, 'agent/inbox/enqueue', agent, info(true)) + ctx.emit(at, 'agent/inbox/dequeue', agent, info(false)) + ctx.emit(at, 'agent/inbox/discard', agent, [info(true)]) + }).not.toThrow() + }) + + it('rejects a dequeue with no outstanding item', async () => { + const ctx = await setup() + const agent = mockAgent('i2') + expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/dequeue', agent, info(false)) }) + .toThrow(/without a matching prior enqueue/) + }) + + it('rejects a discard larger than the outstanding count', async () => { + const ctx = await setup() + const agent = mockAgent('i3') + const at = scopeTarget(agent, agent) + ctx.emit(at, 'agent/inbox/enqueue', agent, info(false)) + expect(() => { ctx.emit(at, 'agent/inbox/discard', agent, [info(false), info(true)]) }) + .toThrow(/dropped 2 items but only 1 were outstanding/) + }) + + it('accepts an empty discard against a fresh agent', async () => { + const ctx = await setup() + const agent = mockAgent('i4') + expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/discard', agent, []) }).not.toThrow() + }) +}) diff --git a/packages/core/scope/src/scoped-events.generated.ts b/packages/core/scope/src/scoped-events.generated.ts index 5988b58145..a12b0a513e 100644 --- a/packages/core/scope/src/scoped-events.generated.ts +++ b/packages/core/scope/src/scoped-events.generated.ts @@ -12,10 +12,12 @@ const scopedSubjectResolvers: Readonly args[0], 'agent/disposed': args => args[0], 'agent/error': args => args[0], + 'agent/inbox/dequeue': args => args[0], + 'agent/inbox/discard': args => args[0], + 'agent/inbox/enqueue': args => args[0], 'agent/post-step': args => args[0], 'agent/pre-step': args => args[0], 'agent/prompt-submit': args => args[0], - 'agent/queued': args => args[0], 'agent/request': args => args[0], 'agent/request-error': args => args[0], 'agent/session-prefix': args => args[0], diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index 2d93bcddc5..ca0841165b 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { Events } from 'cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' import { scopeTarget } from '@deepseek-ai/dsh-scope' import * as ScopeInvariant from '@deepseek-ai/dsh-scope/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -42,7 +42,9 @@ describe('scoped-dispatch invariants', () => { 'agent/created': [agent], 'agent/disposed': [agent], 'agent/status': [agent, 'idle'], - 'agent/queued': [agent, [], { source: { kind: 'user' }, contexts: [], steering: false }], + 'agent/inbox/enqueue': [agent, { id: AgentMessageId('m'), content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }], + 'agent/inbox/dequeue': [agent, { id: AgentMessageId('m'), content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }], + 'agent/inbox/discard': [agent, []], 'agent/cancel-requested': [agent, { kind: 'user' }], 'agent/session-start': [agent, 'startup'], 'agent/pre-step': [agent, 1, 1, signal], diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 0bd0265f78..a01bb21cc0 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -64,7 +64,7 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/ `request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. `messagePrefix` remains separate from derived history. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). -`context/message` renders its `content` verbatim as a user-role message, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`. A `user/message` or `steering/message` with prompt-prefix context keeps the exact combined model bytes in `content` and stores a model-hidden `envelope` containing the direct `displayContent` and prefix context source/metadata descriptors. `displayPromptContent()` selects the human-facing prompt without changing derived history. +A `user/message` renders its `content` verbatim as a user-role message whether it is a direct human prompt (`user` source), a synthetic injection (`plugin`/`goal` source), or an admitted goal round — `source` is the only channel that tells them apart. It may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`. A `user/message` or `steering/message` with prompt-prefix context keeps the exact combined model bytes in `content` and stores a model-hidden `envelope` containing the direct `displayContent` and prefix context source/metadata descriptors. `displayPromptContent()` selects the human-facing prompt without changing derived history. `tool/result` persists the model-facing content, optional internal failure identity, and optional presentation metadata. A tool's successful canonical `value` and human-readable canonical failure message remain execution-local; rendered error content is the replay-authoritative message. This preserves the existing event shape and does not change `SESSION_FORMAT_VERSION`. @@ -99,7 +99,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) #### What the model sees -The model receives projections of `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` surface entries verbatim: each is a user- or assistant-role message carrying its content blocks unchanged. A prompt envelope changes only human presentation; its prefix context and request delimiter are already present in the event content. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message. +The model receives projections of `user/message`, `assistant/message`, `tool/result`, and `steering/message` surface entries verbatim: each is a user- or assistant-role message carrying its content blocks unchanged. A prompt envelope changes only human presentation; its prefix context and request delimiter are already present in the event content. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message. #### Token effect diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 9c5831bb18..5e4320f046 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -532,10 +532,10 @@ export class Session { // trace/replay data. switch (event.type) { - // Injected context, ordinary prompts, and mid-turn steering project + // Ordinary prompts, injected context, and mid-turn steering project // identically in user role: the event's model-facing content stays // verbatim. A prompt envelope is model-hidden display metadata; its - // prefix bytes are already present in content. context's `source`/`meta` + // prefix bytes are already present in content. The message's `source`/`meta` // and steering's `turn` are also log-only. Do NOT // re-add per-type framing (e.g. ``/``) here: framing is // caller-owned — a producer bakes it into `content`, as workspace-context @@ -544,7 +544,6 @@ export class Session { // verbatim pass-through. See the deferred design note in // ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md case 'user/message': - case 'context/message': case 'steering/message': { return { role: 'user', content: event.data.content } } diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 9095c8388b..fc275129f9 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -15,14 +15,13 @@ const SURFACE_EVENT_TYPES = new Set([ 'user/message', 'assistant/message', 'tool/result', - 'context/message', 'steering/message', ]) /** * Whether an event type can join the model-visible surface. * @param type - event type to test. - * @returns true for one of the five message-producing event types. + * @returns true for one of the four message-producing event types. */ export function isSurfaceEligibleType(type: string): boolean { return SURFACE_EVENT_TYPES.has(type) diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 8b3a1e8cb6..017245aed2 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -84,11 +84,12 @@ export interface TurnTriggerMap { message: { kind: 'message'; source: MessageSource } /** * An out-of-band context injection (`agent.inject()`) made while the agent - * was idle. The loop wraps the injected `context/message` in a one-shot turn - * (`turn/start` → `context/message` → `turn/end`) so every event in the log - * stays turn-enclosed — the durability/replay boundary is the turn, and a - * bare event between turns would otherwise be indistinguishable from a crash - * tail on reload. + * was idle. The loop wraps the injected `user/message` (a non-`user` source, + * plugin by default) in a one-shot turn (`turn/start` → `user/message` → + * `turn/end`) so every event in the log stays turn-enclosed — the + * durability/replay boundary is the turn, and a bare event between turns would + * otherwise be indistinguishable from a crash tail on reload. The trigger's + * `source` mirrors that message's producer. */ injection: { kind: 'injection'; source: MessageSource } } @@ -201,7 +202,13 @@ export interface PromptMessageEnvelope { prefixContexts: PromptPrefixContext[] } -/** Shared payload for ordinary and steering prompt messages. */ +/** + * Shared payload for user, injected-context, and steering prompt messages. A + * direct human prompt, a synthetic `agent.inject()` context, and mid-turn + * steering all project into the model transcript as verbatim user-role content; + * they are told apart by `source` (a non-`user` kind marks injected context), + * not by event type. `meta` carries durable model-hidden producer state. + */ export interface PromptMessageData { /** Exact model-facing blocks, including any baked prompt-prefix contexts. */ content: ContentBlock[] @@ -209,6 +216,15 @@ export interface PromptMessageData { source: MessageSource /** Present only when prompt-prefix contexts were baked into `content`. */ envelope?: PromptMessageEnvelope + /** + * Opaque durable JSON state retained on the event but hidden from the model + * projection. It is the intended channel for a future framing directive (a + * producer declares the frame, a dedicated renderer applies it — see the + * deferred note in + * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), + * so the surface keeps projecting `content` verbatim rather than wrapping it. + */ + meta?: JsonValue } /** @@ -236,29 +252,21 @@ export interface SessionEventMap { 'step/start': { turn: number; step: number } /** Closes step `step` of turn `turn`. */ 'step/end': { turn: number; step: number } - /** A user-visible prompt (the queued message claimed for this turn). */ + /** + * A user-role message on the model-visible surface: a direct human prompt + * (the queued message claimed for this turn), a synthetic `agent.inject()` + * context (file-change notices, subdir AGENTS.md, skill content, cron + * notifications, …), or an admitted goal continuation round. All three + * project their `content` verbatim; `source` (with a non-`user` kind marking + * injected context) is the only channel that tells them apart. An idle + * injection wraps this event in a one-shot turn so the log stays turn-enclosed. + */ 'user/message': PromptMessageData /** * 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. */ 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } - /** - * In-session context injection (file-change notices, subdir AGENTS.md, - * skill content, cron notifications, …). Rendered into the derived history - * as a synthetic user-role message carrying `content` verbatim — NOT a - * user prompt. `meta` is durable JSON state omitted from the model - * projection; it is also the intended channel for any future framing - * directive (a producer declares the frame, a dedicated renderer applies it — - * see the deferred note in - * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), - * so the surface keeps projecting `content` verbatim rather than wrapping it. - */ - 'context/message': { - content: ContentBlock[] - source: MessageSource - meta?: JsonValue - } /** Raw stream chunk — token-level replay fidelity. */ 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } /** @@ -331,7 +339,6 @@ export type SurfaceEventType = | 'user/message' | 'assistant/message' | 'tool/result' - | 'context/message' | 'steering/message' /** @@ -349,7 +356,7 @@ export type SurfaceEvent = SessionEvent & { surfaceOp: Surface * How a session event entered the ordered surface. Only valid on * {@link SurfaceEventType} events. * - * - `'append'`: added to the tail — normal path for user/assistant/tool/context + * - `'append'`: added to the tail — normal path for user/assistant/tool/steering * messages. * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` * (inclusive) through `end` (inclusive) with this node. Both must exist as @@ -384,7 +391,7 @@ export interface SurfaceIntent { * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, - * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * `assistant/message`, `tool/result`, `steering/message`). * Non-surface events (boundary markers, chunks, usage, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` * call sites. diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts index 96d1f7048c..c2ff24936b 100644 --- a/packages/core/session/tests/derived-cache.spec.ts +++ b/packages/core/session/tests/derived-cache.spec.ts @@ -38,7 +38,7 @@ describe('derived-message cache', () => { expect(beforeReplace).toHaveLength(2) const nodes = session.surface.nodes - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' }, }, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] }) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index d880153dd3..132a250a97 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -62,7 +62,7 @@ describe('Session', () => { turn: 1, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'before' } }, }) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'before' }], source: { kind: 'plugin', plugin: 'before' }, }, { surfaceOp: 'append' }) @@ -82,7 +82,7 @@ describe('Session', () => { turn: 3, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'after' } }, }) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'after' }], source: { kind: 'plugin', plugin: 'after' }, }, { surfaceOp: 'append' }) @@ -117,9 +117,9 @@ describe('Session', () => { .toThrow('seed turn/end at index 1 uses unsupported reason-bearing aborted format') }) - it('renders context and steering messages as plain user content', () => { + it('renders injected-context and steering messages as plain user content', () => { const session = new Session(SessionId('s2')) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'file changed: a.ts' }], source: { kind: 'plugin', plugin: 'watcher' }, }, { surfaceOp: 'append' }) @@ -172,7 +172,7 @@ describe('Session', () => { version: 1, changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md', digest: 'abc123' }], } - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'Additional instructions from: pkg/AGENTS.md' }], source: { kind: 'plugin', plugin: 'workspace-context' }, meta, @@ -183,7 +183,7 @@ describe('Session', () => { content: [{ type: 'text', text: 'Additional instructions from: pkg/AGENTS.md' }], }]) const event = session.events[0] - expect(event?.type === 'context/message' && event.data.meta).toEqual(meta) + expect(event?.type === 'user/message' && event.data.meta).toEqual(meta) }) it('replays identically from a seeded event log', () => { diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index b7cbe11b11..cfb84f4e18 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -444,9 +444,9 @@ describe('deriveMessages with surface', () => { expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'compacted' }) }) - it('context/message and steering/message appear on surface', () => { + it('injected-context and steering/message appear on surface', () => { const s = new Session(SessionId('ctx')) - s.append('context/message', { content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' } }, { surfaceOp: 'append' }) + s.append('user/message', { content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' } }, { surfaceOp: 'append' }) s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'focus' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const messages = s.deriveMessages() expect(messages).toHaveLength(2) @@ -524,7 +524,6 @@ describe('surface type guards', () => { expect(isSurfaceEligibleType('user/message')).toBe(true) expect(isSurfaceEligibleType('assistant/message')).toBe(true) expect(isSurfaceEligibleType('tool/result')).toBe(true) - expect(isSurfaceEligibleType('context/message')).toBe(true) expect(isSurfaceEligibleType('steering/message')).toBe(true) expect(isSurfaceEligibleType('turn/start')).toBe(false) expect(isSurfaceEligibleType('assistant/chunk')).toBe(false) @@ -568,7 +567,7 @@ describe('SurfaceManager.replaceGeneration', () => { expect(s.surface.replaceGeneration).toBe(0) const nodes = s.surface.nodes - s.append('context/message', { + s.append('user/message', { content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' }, }, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] }) expect(s.surface.replaceGeneration).toBe(1) diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 1ac506a098..e00bf2016d 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -235,7 +235,7 @@ describe('dsh-agent-spine-demo bundle', () => { agentOptions: { provider: 'mock', model: 'mock' }, }) - handle.agent.send([{ type: 'text', text: 'recover' }]) + handle.agent.followup([{ type: 'text', text: 'recover' }]) await waitForIdle(ctx, handle.agent) expect(adapter.requests).toBe(2) @@ -335,7 +335,7 @@ describe('dsh-agent-spine-demo bundle', () => { }) const agent = handle.agent - agent.send([{ type: 'text', text: 'hi' }]) + agent.followup([{ type: 'text', text: 'hi' }]) await waitForIdle(ctx, agent) const sentText = adapter.requests[0]?.messages.map(messageText).join('\n') @@ -364,7 +364,7 @@ describe('dsh-agent-spine-demo bundle', () => { agentOptions: { provider: 'mock', model: 'mock' }, }) - handle.agent.send([{ type: 'text', text: 'hi' }]) + handle.agent.followup([{ type: 'text', text: 'hi' }]) await waitForIdle(ctx, handle.agent) expect(adapter.requests[0]?.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]) @@ -454,7 +454,7 @@ describe('dsh-agent-spine-demo bundle', () => { agentOptions: { provider: 'mock', model: 'mock' }, }) - handle.agent.send([{ type: 'text', text: 'hi' }]) + handle.agent.followup([{ type: 'text', text: 'hi' }]) await waitForIdle(ctx, handle.agent) expect(messageText(adapter.requests[0]?.messages[0])).toContain('workspace rule before skills') diff --git a/packages/examples/cli-demo/src/cli.ts b/packages/examples/cli-demo/src/cli.ts index 66ce345520..e6672f91ad 100644 --- a/packages/examples/cli-demo/src/cli.ts +++ b/packages/examples/cli-demo/src/cli.ts @@ -290,7 +290,7 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise try { /* v8 ignore next -- skips send only when cancellation wins the listener-registration race above */ if (!settled) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition - agent.send([{ type: 'text', text: options.task }]) + agent.followup([{ type: 'text', text: options.task }]) } await turnEnded } finally { diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index 65eafff1cc..c57dda5b00 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -369,7 +369,7 @@ describe('runOneShot and executeCli', () => { const { ctx, agent } = await harness([textResponse('streamed')]) const other = ctx.sessions.create(SessionId('unrelated')) let injected = false - ctx.on('agent/queued', (subject) => { + ctx.on('agent/inbox/enqueue', (subject) => { if (subject !== agent || injected) return injected = true agent.inject([{ type: 'text', text: 'startup injection' }], { source: { kind: 'plugin', plugin: 'test' } }) @@ -383,7 +383,7 @@ describe('runOneShot and executeCli', () => { expect(events[0]).toMatchObject({ type: 'turn/start', data: { turn: 2, trigger: { kind: 'message' } } }) expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 2 } }) expect(lines.slice(0, -1).every(line => line['sessionId'] === agent.session.id)).toBe(true) - expect(events.some(event => event.type === 'context/message')).toBe(false) + expect(events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false) }) it('emits partial data and a diagnostic for non-completed turns', async () => { @@ -471,7 +471,7 @@ describe('runOneShot and executeCli', () => { startup.ctx.on('session/event', (session, event) => { if (session === startup.agent.session && event.type === 'assistant/chunk') started() }) - startup.agent.send([{ type: 'text', text: 'first' }]) + startup.agent.followup([{ type: 'text', text: 'first' }]) await running const startupAbort = new AbortController() const waiting = runOneShot(startup.ctx, { task: 'second', signal: startupAbort.signal }) @@ -481,7 +481,7 @@ describe('runOneShot and executeCli', () => { const queued = await harness([textResponse('unused')]) const queuedAbort = new AbortController() - queued.ctx.on('agent/queued', (agent) => { + queued.ctx.on('agent/inbox/enqueue', (agent) => { if (agent === queued.agent) queuedAbort.abort('cancel queued') }) await expect(runOneShot(queued.ctx, { task: 'task', signal: queuedAbort.signal })).rejects.toThrow('cancel queued') diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts index 472b32f47a..b768813b22 100644 --- a/packages/fs/tool-fs/tests/fs-tools.e2e.ts +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -36,7 +36,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => // (config.cwd = workdir) is the workspace. const agent = ctx.agentLoop.create(SessionId('fs-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ type: 'text', text: + agent.followup([{ type: 'text', text: 'Create a file named note.txt containing exactly the line: status: draft. ' + 'Then read it back, then edit it to replace the literal word draft with final. ' + 'Tell me when done.' }]) @@ -68,7 +68,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => meta: { cwd: sessionDir }, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, }) - handle.agent.send([{ type: 'text', text: + handle.agent.followup([{ type: 'text', text: 'Use the write tool to create a file named where.txt containing exactly the line: here. Tell me when done.' }]) await waitForIdle(ctx, handle.agent) diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 35994ecb71..a958659eba 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' import GoalService from '@deepseek-ai/dsh-goal' @@ -27,10 +27,10 @@ function nextTurn(session: Session): number { /** Append one idle injection using the public Agent contract's balanced shape. */ function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void { - const source: MessageSource = options?.source ?? { kind: 'user' } + const source: MessageSource = options?.source ?? { kind: 'plugin', plugin: '' } const turn = nextTurn(session) session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - session.append('context/message', { + session.append('user/message', { content, source, ...options?.meta === undefined ? {} : { meta: options.meta }, @@ -48,9 +48,11 @@ function stubAgent(id: string): { agent: Agent; session: Session } { session, ctx: new Context(), get status() { return status }, - send() {}, - steer() {}, - inject(content, options) { appendInjection(session, content, options) }, + followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), + inject(content, options) { appendInjection(session, content, options); return AgentMessageId('stub') }, + send: () => AgentMessageId('stub'), cancel() { status = 'idle' }, whenIdle() { return Promise.resolve() }, } @@ -125,7 +127,7 @@ describe('/goal human command', () => { expect(created.text).toContain('Rounds: 0/256') expect(created.text).toContain('Activation: armed') expect(test.ctx.goals.get(test.agent)?.objective).toBe('finish the release') - expect(test.session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end']) + expect(test.session.events.map(event => event.type)).toEqual(['turn/start', 'user/message', 'turn/end']) const count = test.session.events.length await expect(run(test, ' replacement')).resolves.toEqual({ diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index bcb3fc2a75..67cbd89d05 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -219,7 +219,7 @@ export function apply(ctx: Context): void { } state.attempt = reservation try { - agent.send(content, { + agent.followup(content, { source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round }, }) } catch (error: unknown) { @@ -306,10 +306,10 @@ export function apply(ctx: Context): void { requestDrive(state) } }) - ctx.on('agent/queued', (agent, content, info) => { + ctx.on('agent/inbox/enqueue', (agent, info) => { const state = stateFor(agent) const attempt = state.attempt - if (attempt !== undefined && sameQueued(content, info.source, attempt)) return + if (attempt !== undefined && sameQueued(info.content, info.source, attempt)) return state.competingQueued = true if (attempt?.phase === 'queued') attempt.stale = true }) diff --git a/packages/goal/goal-session/src/prompt.ts b/packages/goal/goal-session/src/prompt.ts index 9a2f69fcd8..d98f0bb83a 100644 --- a/packages/goal/goal-session/src/prompt.ts +++ b/packages/goal/goal-session/src/prompt.ts @@ -7,7 +7,7 @@ import type { GoalView } from '@deepseek-ai/dsh-goal' * Render the complete goal-round instruction retained in session history. * @param goal - exact active goal revision being admitted. * @param round - next positive round number. - * @returns a fresh one-block prompt for `Agent.send()`. + * @returns a fresh one-block prompt for `Agent.followup()`. */ export function renderGoalRoundPrompt(goal: GoalView, round: number): ContentBlock[] { return [{ diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index f204893578..dc3d6723a7 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -207,7 +207,9 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(2) const rounds: number[] = [] for (const event of test.agent.session.events) { - if (event.type === 'user/message' && event.data.source.kind === 'goal') { + // Round zero is a durable goal state change; positive rounds are the + // admitted continuation prompts this test counts. + if (event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round > 0) { rounds.push(event.data.source.round) } } @@ -274,7 +276,7 @@ describe('same-session goal driving', () => { ? Promise.resolve({ kind: 'block', reason: 'stop this round' }) : next()) test.ctx.on('goal/changed', (agent, change) => { - if (change.operation === 'block') agent.send([{ type: 'text', text: 'inspect the blocker' }]) + if (change.operation === 'block') agent.followup([{ type: 'text', text: 'inspect the blocker' }]) }) test.ctx.goals.create(test.agent, { objective: 'stop and inspect' }) @@ -287,7 +289,7 @@ describe('same-session goal driving', () => { it('pauses and drops a reserved round when cancellation lands before admission', async () => { const test = await harness([]) - const cancel = test.ctx.on('agent/queued', (agent, _content, info) => { + const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent === test.agent && info.source.kind === 'goal') { cancel() agent.cancel({ kind: 'user' }) @@ -299,8 +301,10 @@ describe('same-session goal driving', () => { expect(goal).toMatchObject({ roundsStarted: 0, activation: 'disarmed' }) expect(test.adapter.requests).toHaveLength(0) + // No admitted continuation round (positive round); goal state changes + // (round zero) are expected in the log. expect(test.agent.session.events.some(event => event.type === 'user/message' - && event.data.source.kind === 'goal')).toBe(false) + && event.data.source.kind === 'goal' && event.data.source.round > 0)).toBe(false) }) it('pauses an admitted round when cancellation aborts an active step', async () => { @@ -319,7 +323,7 @@ describe('same-session goal driving', () => { it('lets already-queued human work finish before reserving the next round', async () => { const test = await harness([textResponse('human answer'), textResponse('goal answer')]) test.ctx.goals.create(test.agent, { objective: 'continue after the human', maxGoalRounds: 1 }) - test.agent.send([{ type: 'text', text: 'human goes first' }]) + test.agent.followup([{ type: 'text', text: 'human goes first' }]) await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked') @@ -334,7 +338,7 @@ describe('same-session goal driving', () => { const warnings: string[] = [] test.ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof test.ctx.logger.warn let inserted = false - test.ctx.on('agent/queued', (agent, _content, info) => { + test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return inserted = true const lastStart = agent.session.events.findLast(event => event.type === 'turn/start') @@ -357,10 +361,10 @@ describe('same-session goal driving', () => { it('makes a reserved round stale when a listener queues human work behind it', async () => { const test = await harness([textResponse('human batch'), textResponse('later goal')]) let inserted = false - test.ctx.on('agent/queued', (agent, _content, info) => { + test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return inserted = true - agent.send([{ type: 'text', text: 'human joined the pending batch' }]) + agent.followup([{ type: 'text', text: 'human joined the pending batch' }]) }) test.ctx.goals.create(test.agent, { objective: 'yield to nested human input', maxGoalRounds: 1 }) @@ -375,7 +379,7 @@ describe('same-session goal driving', () => { it('blocks a queued reservation made stale by a goal edit and continues the new revision', async () => { const test = await harness([textResponse('new revision')]) let edited = false - test.ctx.on('agent/queued', (agent, _content, info) => { + test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent !== test.agent || info.source.kind !== 'goal' || edited) return edited = true const current = test.ctx.goals.get(agent) @@ -391,7 +395,7 @@ describe('same-session goal driving', () => { expect(blocked?.type === 'prompt/blocked' ? blocked.data.reason : undefined) .toBe('stale goal-round reservation') const admitted = test.agent.session.events.find(event => event.type === 'user/message' - && event.data.source.kind === 'goal') + && event.data.source.kind === 'goal' && event.data.source.round > 0) expect(admitted?.type === 'user/message' && admitted.data.source.kind === 'goal' ? admitted.data.source.revision : undefined).toBe(2) @@ -475,10 +479,16 @@ describe('same-session goal driving', () => { expect(injectedTurn).toBeGreaterThan(goalTurn) }) - it('blocks the goal when a custom agent rejects the otherwise valid send', async () => { + it('blocks the goal when a custom agent rejects the otherwise valid follow-up', async () => { const test = await harness([]) - vi.spyOn(test.agent, 'send').mockImplementationOnce(() => { - throw new Error('queue rejected') + // Reject only the goal-sourced round follow-up, not the state-change injection + // that precedes it. + const realFollowup = test.agent.followup.bind(test.agent) + vi.spyOn(test.agent, 'followup').mockImplementation((content, options) => { + if (options?.source?.kind === 'goal') { + throw new Error('queue rejected') + } + return realFollowup(content, options) }) test.ctx.goals.create(test.agent, { objective: 'handle queue failure' }) @@ -492,11 +502,15 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(0) }) - it('preserves a custom agent side effect when send disarms before throwing', async () => { + it('preserves a custom agent side effect when followup disarms before throwing', async () => { const test = await harness([]) - vi.spyOn(test.agent, 'send').mockImplementationOnce(() => { - test.ctx.goals.disarm(test.agent) - throw new Error('queue rejected after disarm') + const realFollowup = test.agent.followup.bind(test.agent) + vi.spyOn(test.agent, 'followup').mockImplementation((content, options) => { + if (options?.source?.kind === 'goal') { + test.ctx.goals.disarm(test.agent) + throw new Error('queue rejected after disarm') + } + return realFollowup(content, options) }) test.ctx.goals.create(test.agent, { objective: 'preserve the newer activation state' }) @@ -554,7 +568,7 @@ describe('same-session goal driving', () => { it('fails a pre-admission read closed even when the first disarm attempt throws', async () => { const test = await harness([textResponse('retry after containment')]) let armed = true - test.ctx.on('agent/queued', (agent, _content, info) => { + test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent !== test.agent || info.source.kind !== 'goal' || !armed) return armed = false vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { @@ -595,7 +609,7 @@ describe('same-session goal driving', () => { it('blocks forged goal attribution without touching an absent reservation', async () => { const test = await harness([]) - test.agent.send([{ type: 'text', text: 'forged automatic work' }], { + test.agent.followup([{ type: 'text', text: 'forged automatic work' }], { source: { kind: 'goal', goalId: GoalId('forged-goal'), revision: 1, round: 1 }, }) await test.agent.whenIdle() @@ -607,7 +621,7 @@ describe('same-session goal driving', () => { it('does not invent goal state when ordinary queued work is cancelled', async () => { const test = await harness([]) - test.agent.send([{ type: 'text', text: 'cancel ordinary work' }]) + test.agent.followup([{ type: 'text', text: 'cancel ordinary work' }]) test.agent.cancel({ kind: 'user' }) await test.agent.whenIdle() @@ -617,7 +631,7 @@ describe('same-session goal driving', () => { it('disarms without durably pausing when cancellation belongs to unrelated human work', async () => { const test = await harness(['hang']) - test.agent.send([{ type: 'text', text: 'inspect something first' }]) + test.agent.followup([{ type: 'text', text: 'inspect something first' }]) await waitForRequests(test.adapter, 1) const created = test.ctx.goals.create(test.agent, { objective: 'continue after inspection' }) @@ -635,7 +649,7 @@ describe('same-session goal driving', () => { it('falls back to disarming when a cancelled reservation cannot be paused', async () => { const test = await harness([]) - const cancel = test.ctx.on('agent/queued', (agent, _content, info) => { + const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent !== test.agent || info.source.kind !== 'goal') return cancel() vi.spyOn(test.ctx.goals, 'pause').mockImplementationOnce(() => { @@ -689,7 +703,7 @@ describe('same-session goal driving', () => { it('cancels an accepted queued round and awaits its driver task during teardown', async () => { const test = await harness([]) let unloading: Promise | undefined - test.ctx.on('agent/queued', (agent, _content, info) => { + test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent === test.agent && info.source.kind === 'goal' && unloading === undefined) { unloading = Promise.resolve(test.driver.dispose()) } diff --git a/packages/goal/goal-session/tests/invariant.spec.ts b/packages/goal/goal-session/tests/invariant.spec.ts index 19200747e8..0427a41333 100644 --- a/packages/goal/goal-session/tests/invariant.spec.ts +++ b/packages/goal/goal-session/tests/invariant.spec.ts @@ -40,7 +40,7 @@ function view(roundsStarted: number): GoalView { function appendChange(session: Session): void { session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) - session.append('context/message', { + session.append('user/message', { content: renderGoalChange(change), source: changeSource, meta: change as never, @@ -126,7 +126,7 @@ describe('goal-session prompt invariants', () => { it('attributes an invalid durable prefix during late loading', async () => { const { ctx, session } = await mount(true) session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'counterfeit goal state' }], source: changeSource, meta: change as never, diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md index e50044d135..1df6d08f5c 100644 --- a/packages/goal/goal/README.md +++ b/packages/goal/goal/README.md @@ -19,7 +19,7 @@ Event-sourced same-session goal state. The service retains one current completio At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase, blocker reason, and activation. Pause, completion, blocking, and clear disarm activation. A block records a policy-owned lower-kebab-case code plus a normalized free-form explanation; provider limits, configured budgets, execution errors, and requests for human input all use this one durable phase rather than multiplying lifecycle states. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; it clears any former blocker reason. An active armed goal rejects the redundant operation. -Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The `context/message` content projected verbatim to the model, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward. +Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The round-zero `user/message` content projected verbatim to the model, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward. Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. Reentrant append observers see each accepted mutation exactly once, and incremental replay retains its cursor at the first corrupt event. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained. diff --git a/packages/goal/goal/src/fold.ts b/packages/goal/goal/src/fold.ts index fe88ebcdba..2ff756249f 100644 --- a/packages/goal/goal/src/fold.ts +++ b/packages/goal/goal/src/fold.ts @@ -17,7 +17,7 @@ import type { GoalSnapshotChangeMeta, } from './types.ts' -type ContextMessageEvent = Extract +type UserMessageEvent = Extract const SNAPSHOT_OPERATIONS: ReadonlySet> = new Set([ 'create', @@ -310,17 +310,18 @@ export function applyGoalChange(state: GoalFoldState, change: GoalChangeMeta): v } /** - * Decode and verify one model-visible goal context event without folding it. - * @param event - context event whose metadata and rendered content must agree. - * @returns validated change or `undefined` for an unrelated context event. + * Decode and verify one model-visible goal state change without folding it. A + * goal state change is a round-zero goal-sourced `user/message` carrying + * `goal/change` metadata; any other user message returns `undefined`. Goal + * metadata on a non-goal source, or a mismatched attribution or rendered body, + * fails replay loudly. + * @param event - user message whose metadata and rendered content must agree. + * @returns validated change, or `undefined` when the message is not a goal state change. */ -export function decodeGoalEvent(event: ContextMessageEvent): GoalChangeMeta | undefined { +export function decodeGoalEvent(event: UserMessageEvent): GoalChangeMeta | undefined { const change = decodeGoalChange(event.data.meta) + if (change === undefined) return undefined const source = goalSource(event.data.source) - if (change === undefined) { - if (source !== undefined) throw new Error(`goal source at session event ${event.seq} lacks goal change metadata`) - return undefined - } const ref = goalChangeRef(change) if (source === undefined || source.goalId !== ref.id || source.revision !== ref.revision || source.round !== 0) { throw new Error(`goal change at session event ${event.seq} has mismatched source attribution`) @@ -338,23 +339,27 @@ export function decodeGoalEvent(event: ContextMessageEvent): GoalChangeMeta | un * @returns decoded change for pending-overlay reconciliation. */ export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): GoalChangeMeta | undefined { - if (event.type === 'context/message') { - const change = decodeGoalEvent(event) - if (change === undefined) return undefined - applyGoalChange(state, change) - return change - } if (event.type === 'user/message') { - const source = goalSource(event.data.source) - if (source !== undefined) { - const current = state.goal - if (current === undefined || current.phase !== 'active' || source.goalId !== current.id - || source.revision !== current.revision || source.round !== state.roundsStarted + 1 - || source.round > current.maxGoalRounds) { - throw new Error(`goal round at session event ${event.seq} is not the next admitted round of the active goal`) - } - state.roundsStarted = source.round + // A goal state change carries `goal/change` metadata (round zero). + const change = decodeGoalEvent(event) + if (change !== undefined) { + applyGoalChange(state, change) + return change } + const source = goalSource(event.data.source) + if (source === undefined) return undefined + // A goal-sourced message without change metadata must be a positive-round + // admitted continuation prompt; round zero owes durable change metadata. + if (source.round === 0) { + throw new Error(`goal source at session event ${event.seq} lacks goal change metadata`) + } + const current = state.goal + if (current === undefined || current.phase !== 'active' || source.goalId !== current.id + || source.revision !== current.revision || source.round !== state.roundsStarted + 1 + || source.round > current.maxGoalRounds) { + throw new Error(`goal round at session event ${event.seq} is not the next admitted round of the active goal`) + } + state.roundsStarted = source.round } return undefined } diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 7391c69e93..e4700452fa 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -370,7 +370,9 @@ export class GoalService extends Service { /** Incrementally observe durable events without losing deferred mutations. */ private sync(session: Session, cache: GoalCache): void { for (const event of session.events.slice(cache.observedSeq)) { - if (event.type === 'context/message') { + // A goal state change is a round-zero goal-sourced user message; a + // positive round is a continuation prompt handled by applyGoalEvent. + if (event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round === 0) { const change = decodeGoalEvent(event) if (change !== undefined) { const pending = cache.pending[0] diff --git a/packages/goal/goal/src/runtime.ts b/packages/goal/goal/src/runtime.ts index 49184faa8c..5a97cceae0 100644 --- a/packages/goal/goal/src/runtime.ts +++ b/packages/goal/goal/src/runtime.ts @@ -3,7 +3,7 @@ import { HarnessError } from '@deepseek-ai/dsh-llm' import type { GoalErrorCode, GoalId as GoalIdType } from './types.ts' -/** Version of the goal change metadata embedded in `context/message`. */ +/** Version of the goal change metadata embedded in a round-zero `user/message`. */ export const GOAL_CHANGE_VERSION = 1 /** diff --git a/packages/goal/goal/src/types.ts b/packages/goal/goal/src/types.ts index 2c6798718d..7da3c525d7 100644 --- a/packages/goal/goal/src/types.ts +++ b/packages/goal/goal/src/types.ts @@ -89,7 +89,7 @@ export interface GoalClearChangeMeta { readonly clearedAt: number } -/** Durable metadata union carried by a goal-owned `context/message`. */ +/** Durable metadata union carried by a goal-owned round-zero `user/message`. */ export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta /** Message attribution for durable goal state and continuation rounds. */ diff --git a/packages/goal/goal/tests/goal.e2e.ts b/packages/goal/goal/tests/goal.e2e.ts index 0c582645bc..756073083a 100644 --- a/packages/goal/goal/tests/goal.e2e.ts +++ b/packages/goal/goal/tests/goal.e2e.ts @@ -50,11 +50,11 @@ describe('goal domain through a real cordis.yml and headless process', () => { expect(result['result']).toContain('CLI tool round trip complete') expect(events.filter(event => event.type === 'turn/end')).toHaveLength(1) - const contexts = events.filter(event => event.type === 'context/message' + const contexts = events.filter(event => event.type === 'user/message' && event.data.source.kind === 'goal') expect(contexts).toHaveLength(1) const context = contexts[0] - if (context?.type !== 'context/message') throw new Error('expected goal context event') + if (context?.type !== 'user/message') throw new Error('expected goal context event') const change = decodeGoalChange(context.data.meta) if (change === undefined) throw new Error('expected durable goal change') expect(change).toMatchObject({ @@ -69,7 +69,9 @@ describe('goal domain through a real cordis.yml and headless process', () => { }) expect(context.data.content).toEqual(renderGoalChange(change)) expect(JSON.stringify(context)).not.toContain('activation') + // No admitted continuation round ran (the snapshot mounts without starting + // a round); the round-zero state change from create is expected above. expect(events.filter(event => event.type === 'user/message' - && event.data.source.kind === 'goal')).toHaveLength(0) + && event.data.source.kind === 'goal' && event.data.source.round > 0)).toHaveLength(0) }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index ad2011fc62..264261d94b 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' import { HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -34,7 +34,7 @@ function nextTurn(session: Session): number { /** Mirror the public Agent.inject idle/open-turn contract for domain tests. */ function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void { - const source: MessageSource = options?.source ?? { kind: 'user' } + const source: MessageSource = options?.source ?? { kind: 'plugin', plugin: '' } const context = { content, source, @@ -43,12 +43,12 @@ function appendInjection(session: Session, content: ContentBlock[], options?: In const last = session.events.at(-1) const open = last !== undefined && last.type !== 'turn/end' if (open) { - session.append('context/message', context, { surfaceOp: 'append' }) + session.append('user/message', context, { surfaceOp: 'append' }) return } const turn = nextTurn(session) session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - session.append('context/message', context, { surfaceOp: 'append' }) + session.append('user/message', context, { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } @@ -64,12 +64,15 @@ function stubAgentForSession(session: Session): StubAgent { session, ctx: new Context(), get status() { return status }, - send() {}, - steer() {}, + followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), inject(content, options) { if (shouldDefer) deferred.push({ content, options }) else appendInjection(session, content, options) + return AgentMessageId('stub') }, + send: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, } @@ -131,10 +134,10 @@ describe('GoalService creation and replay', () => { }) expect(goal.id).toMatch(/^goal-/) expect(seen).toEqual(['create']) - expect(session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end']) + expect(session.events.map(event => event.type)).toEqual(['turn/start', 'user/message', 'turn/end']) const context = session.events[1] - expect(context?.type).toBe('context/message') - if (context?.type !== 'context/message') throw new Error('expected goal context') + expect(context?.type).toBe('user/message') + if (context?.type !== 'user/message') throw new Error('expected goal context') expect(context.data.source).toEqual({ kind: 'goal', goalId: goal.id, revision: 1, round: 0 }) const change = decodeGoalChange(context.data.meta) if (change === undefined) throw new Error('expected decoded goal change') @@ -266,7 +269,9 @@ describe('GoalService creation and replay', () => { it('requires the exact live registry instance for reads and mutations', async () => { const { ctx, agent } = await harness() - const impostor = { ...agent, session: new Session(agent.id) } + // A same-id agent backed by a different session object — the live-instance + // check must reject it even though the ids match. + const impostor = stubAgentForSession(new Session(agent.id)).agent expect(() => ctx.goals.get(impostor)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' })) expect(() => ctx.goals.create(impostor, { objective: 'no' })).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE', @@ -407,8 +412,8 @@ describe('GoalService mutations', () => { vi.setSystemTime(80) ctx.goals.clear(agent, goal) const clear = session.events - .filter(event => event.type === 'context/message') - .map(event => decodeGoalChange(event.data.meta)) + .filter(event => event.type === 'user/message' && event.data.source.kind === 'goal') + .map(event => event.type === 'user/message' ? decodeGoalChange(event.data.meta) : undefined) .at(-1) expect(clear).toMatchObject({ operation: 'clear', clearedAt: 100 }) expect(() => foldGoal(session.events)).not.toThrow() @@ -454,7 +459,7 @@ describe('GoalService mutations', () => { ctx.agents.register(stub.agent) let observed: ReturnType ctx.on('session/event', (session, event) => { - if (session === stub.session && event.type === 'context/message') observed = ctx.goals.get(stub.agent) + if (session === stub.session && event.type === 'user/message' && event.data.source.kind === 'goal') observed = ctx.goals.get(stub.agent) }) const created = ctx.goals.create(stub.agent, { objective: 'publish once' }) @@ -473,7 +478,7 @@ describe('GoalService mutations', () => { let reject = true stub.agent.inject = (content, options) => { if (reject) throw new Error('injection rejected') - append(content, options) + return append(content, options) } ctx.agents.register(stub.agent) @@ -517,7 +522,7 @@ describe('GoalService mutations', () => { const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0 } as const const turn = nextTurn(session) session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - session.append('context/message', { + session.append('user/message', { content: renderGoalChange(change), source, meta: change as never, }, { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) @@ -594,7 +599,7 @@ describe('goal replay validation', () => { } const turn = nextTurn(session) session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - session.append('context/message', { + session.append('user/message', { content: overrides.content ?? renderGoalChange(change), source, meta: change as never, @@ -791,7 +796,7 @@ describe('goal replay validation', () => { const source = { kind: 'goal', goalId: GoalId('goal-missing-meta'), revision: 1, round: 0 } as const const turn = nextTurn(session) session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'missing' }], source, }, { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) @@ -853,7 +858,7 @@ describe('goal replay validation', () => { const source = { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0 } as const const turn = nextTurn(session) session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - session.append('context/message', { + session.append('user/message', { content: renderGoalChange(clear), source, meta: clear as never, }, { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) diff --git a/packages/goal/goal/tests/invariant.spec.ts b/packages/goal/goal/tests/invariant.spec.ts index 85f0b839d1..996743ceca 100644 --- a/packages/goal/goal/tests/invariant.spec.ts +++ b/packages/goal/goal/tests/invariant.spec.ts @@ -45,7 +45,7 @@ describe('goal stream invariants', () => { const ctx = await setup() const session = ctx.sessions.create(SessionId('goal-invariant-valid')) session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) - session.append('context/message', { + session.append('user/message', { content: renderGoalChange(change), source: changeSource, meta: change as never, @@ -71,7 +71,7 @@ describe('goal stream invariants', () => { const session = ctx.sessions.create(SessionId('goal-invariant-invalid')) session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) expect(() => { - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'counterfeit' }], source: changeSource, meta: change as never, @@ -82,7 +82,7 @@ describe('goal stream invariants', () => { })) expect(session.seq).toBe(1) expect(() => { - session.append('context/message', { + session.append('user/message', { content: renderGoalChange(change), source: changeSource, meta: change as never, @@ -95,7 +95,7 @@ describe('goal stream invariants', () => { await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('goal-invariant-late-load')) session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) - session.append('context/message', { + session.append('user/message', { content: renderGoalChange(change), source: changeSource, meta: change as never, diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 3f286f8ec3..346ac07dcc 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -6,9 +6,9 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal - `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, any blocker reason, and current process-local activation. - `create_goal(objective, max_goal_rounds?)` creates one goal from a direct top-level human turn. The model may infer long-running goal intent without an exact command phrase; non-human turns and subagents are rejected at execution. -- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`. +- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`. Strict-schema empty-string and zero fillers count as omitted, while meaningful values remain limited to their action. -All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. +All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. Mutation cards select the first meaningful action value and otherwise show the goal id, so accepted fillers never produce blank input. All three canonical values match the compact JSON already rendered to Native callers: `{ goal: null }` or `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`. Programmatic consumers therefore receive the same domain structure without parsing the rendered JSON. @@ -18,7 +18,7 @@ An autonomous goal round that successfully reports `complete` or `blocked` contr Execution requires the exact live `exec.agent`, its inherited `AgentRegistry` initiator, running status, and an open turn. Create, edit, pause, and resume additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. Durable fork lineage does not demote a resumed root; live subagent ownership does. -`{ kind: 'user' }` is a host attestation. `Agent.send()` and `steer()` assign it when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority. +`{ kind: 'user' }` is a host attestation. `Agent.followup()` and `steer()` assign it when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority. Complete and blocked also accept the exact current goal round: a goal-sourced `user/message` whose id, revision, and round equal the folded current goal. A goal-round blocked call is mechanically rejected until `blockedAfterConsecutiveRounds`; the model judges whether the same condition actually persisted and must describe it in `blocked_reason`. Direct human authority may stop a goal immediately. diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index 41fe713dc6..f48c9cd098 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -64,7 +64,7 @@ export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolE /** * Whether host-attested human input appears in the current root-agent turn. - * An omitted `Agent.send()` / `steer()` source resolves to `user`, so non-human + * An omitted `Agent.followup()` / `steer()` source resolves to `user`, so non-human * producers must supply their own source rather than inheriting this authority. */ function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean { diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 009a00376f..09953041fa 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -132,6 +132,16 @@ function resolveConfig(config: Config): ResolvedConfig { return { blockedAfterConsecutiveRounds: blockedAfter } } +/** Whether optional text is meaningful rather than a strict-schema empty filler. */ +function hasText(value: string | undefined): value is string { + return value !== undefined && value !== '' +} + +/** Whether an optional round cap is meaningful rather than a strict-schema zero filler. */ +function hasRoundCap(value: number | undefined): value is number { + return value !== undefined && value !== 0 +} + /** Build the exact compare-and-set ref from model arguments. */ function goalRef(goalId: string, revision: number): GoalRef { if (goalId.length === 0 || goalId !== goalId.trim() @@ -271,12 +281,12 @@ export function apply(ctx: Context, config: Config): void { const execution = goalToolExecution(ctx, exec) const ref = goalRef(args.goal_id, args.revision) const replacements = { - ...args.objective === undefined ? {} : { objective: args.objective }, - ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds }, + ...hasText(args.objective) ? { objective: args.objective } : {}, + ...hasRoundCap(args.max_goal_rounds) ? { maxGoalRounds: args.max_goal_rounds } : {}, } if (args.action === 'edit') { requireDirectHuman(ctx, execution) - if (args.blocked_reason !== undefined) { + if (hasText(args.blocked_reason)) { throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') } const goal = ctx.goals.edit(execution.agent, ref, replacements) @@ -285,7 +295,7 @@ export function apply(ctx: Context, config: Config): void { } if (args.action === 'pause' || args.action === 'resume') { requireDirectHuman(ctx, execution) - if (args.objective !== undefined || args.max_goal_rounds !== undefined || args.blocked_reason !== undefined) { + if (hasText(args.objective) || hasRoundCap(args.max_goal_rounds) || hasText(args.blocked_reason)) { throw new HarnessError( 'objective and max_goal_rounds are valid only with action edit; blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE', @@ -298,13 +308,13 @@ export function apply(ctx: Context, config: Config): void { return Promise.resolve(goalValue(goal)) } const authority = completionAuthority(ctx, execution) - if (args.objective !== undefined || args.max_goal_rounds !== undefined) { + if (hasText(args.objective) || hasRoundCap(args.max_goal_rounds)) { throw new HarnessError( 'objective and max_goal_rounds are valid only with action edit', 'GOAL_TOOL_INVALID_UPDATE', ) } - if (args.action === 'complete' && args.blocked_reason !== undefined) { + if (args.action === 'complete' && hasText(args.blocked_reason)) { throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') } if (args.action === 'blocked' @@ -331,7 +341,11 @@ export function apply(ctx: Context, config: Config): void { presentCall: args => present( `${args.action === 'blocked' ? 'Mark' : args.action.charAt(0).toUpperCase() + args.action.slice(1)} goal`, 'other', - args.blocked_reason ?? args.objective ?? args.goal_id, + hasText(args.blocked_reason) + ? args.blocked_reason + : hasText(args.objective) + ? args.objective + : hasRoundCap(args.max_goal_rounds) ? args.max_goal_rounds : args.goal_id, ), })) } diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 7b2ccdc347..eaab21e202 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' @@ -31,16 +31,19 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { session, get status() { return status }, ctx: new Context(), - send() {}, - steer() {}, + followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), inject(content: ContentBlock[], options?: InjectOptions) { - const source = options?.source ?? { kind: 'user' } - session.append('context/message', { + const source = options?.source ?? { kind: 'plugin', plugin: '' } + session.append('user/message', { content, source, ...options?.meta === undefined ? {} : { meta: options.meta }, }, { surfaceOp: 'append' }) + return AgentMessageId('stub') }, + send: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, } @@ -142,8 +145,17 @@ describe('goal tool registration and presentation', () => { expect(ctx.tools.get('update_goal')?.presentCall?.({ goal_id: 'goal-1', revision: 2, action: 'blocked', blocked_reason: 'Waiting for a human choice.', })).toEqual({ card: 'generic', title: 'Mark goal', kind: 'other', rawInput: 'Waiting for a human choice.' }) + expect(ctx.tools.get('update_goal')?.presentCall?.({ + goal_id: 'goal-1', revision: 2, action: 'edit', + objective: 'ship', max_goal_rounds: 0, blocked_reason: '', + })).toEqual({ card: 'generic', title: 'Edit goal', kind: 'other', rawInput: 'ship' }) + expect(ctx.tools.get('update_goal')?.presentCall?.({ + goal_id: 'goal-1', revision: 2, action: 'edit', + objective: '', max_goal_rounds: 8, blocked_reason: '', + })).toEqual({ card: 'generic', title: 'Edit goal', kind: 'other', rawInput: 8 }) expect(ctx.tools.get('update_goal')?.presentCall?.({ goal_id: 'goal-1', revision: 2, action: 'resume', + objective: '', max_goal_rounds: 0, blocked_reason: '', })).toEqual({ card: 'generic', title: 'Resume goal', kind: 'other', rawInput: 'goal-1' }) expect(ctx.tools.get('update_goal')?.presentCall?.({ wrong: true })).toBeUndefined() }) @@ -228,7 +240,9 @@ describe('goal tool execution authority', () => { it('rejects stale agent objects and agents outside running status through the executor', async () => { const { ctx, root } = await harness() openTurn(root, { kind: 'user' }) - const stale = { ...root.agent } + // A distinct agent object over root's exact session: same id, not the live + // registered instance, so the executor must reject it. + const stale = stubAgent('goal-tool-stale', root.agent.session).agent const staleResult = await execute(ctx, 'get_goal', {}, stale, stale) expect(staleResult.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') @@ -423,6 +437,77 @@ describe('goal tool state transitions', () => { expect(malformedRef.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') }) + it('accepts only empty fillers in fields unused by the selected action', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + let goal = ctx.goals.create(root.agent, { objective: 'valid' }) + + const edited = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'edit', + objective: 'edited', + max_goal_rounds: 0, + blocked_reason: '', + }, root.agent) + expect(resultGoal(edited)).toMatchObject({ objective: 'edited' }) + goal = ctx.goals.get(root.agent)! + + const capped = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'edit', + objective: '', + max_goal_rounds: 8, + blocked_reason: '', + }, root.agent) + expect(resultGoal(capped)).toMatchObject({ objective: 'edited', maxGoalRounds: 8 }) + goal = ctx.goals.get(root.agent)! + + const paused = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'pause', + objective: '', + max_goal_rounds: 0, + blocked_reason: '', + }, root.agent) + expect(resultGoal(paused)).toMatchObject({ phase: 'paused', objective: 'edited' }) + goal = ctx.goals.get(root.agent)! + + const resumed = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'resume', + objective: '', + max_goal_rounds: 0, + blocked_reason: '', + }, root.agent) + expect(resultGoal(resumed)).toMatchObject({ phase: 'active', objective: 'edited' }) + goal = ctx.goals.get(root.agent)! + + const blocked = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'blocked', + objective: '', + max_goal_rounds: 0, + blocked_reason: 'actual blocker', + }, root.agent) + expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked' }) + goal = ctx.goals.resume(root.agent, { id: goal.id, revision: goal.revision + 1 }) + + const complete = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'complete', + objective: '', + max_goal_rounds: 0, + blocked_reason: '', + }, root.agent) + expect(resultGoal(complete)).toMatchObject({ phase: 'complete', objective: 'edited' }) + }) + it('allows exact goal rounds to complete but not edit or pause', async () => { const { ctx, root } = await harness() const humanTurn = openTurn(root, { kind: 'user' }) diff --git a/packages/guard/README.md b/packages/guard/README.md index 05c9625cb0..e7066ba434 100644 --- a/packages/guard/README.md +++ b/packages/guard/README.md @@ -6,4 +6,4 @@ Behavioral guard plugins that watch the agent loop for unproductive patterns and |---|---|---| | `repeat-tool-guard/` | Advisory reminders when an agent loops on identical tool calls | (listens on `ctx.tools`' waterfalls) | -Reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged `context/message` events after the step's tool results (see [the tools package](../core/tools)), so everything a guard says to the model is reconstructable from the session log. +Reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged plugin-sourced `user/message` events after the step's tool results (see [the tools package](../core/tools)), so everything a guard says to the model is reconstructable from the session log. diff --git a/packages/guard/repeat-tool-guard/README.md b/packages/guard/repeat-tool-guard/README.md index ef5e4e5846..e7bd79732e 100644 --- a/packages/guard/repeat-tool-guard/README.md +++ b/packages/guard/repeat-tool-guard/README.md @@ -30,11 +30,11 @@ The chain key is `(tool name, canonical arguments)` — canonicalization is a de ## Reminder delivery -Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as a `context/message` after the step's tool results, which the session renders as a plain synthetic user message — so the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array (both variants — a blocked call still gets the nudge); every entry retains its own source and metadata. +Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as an injected `user/message` after the step's tool results, which the session renders as a plain synthetic user message — so the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array (both variants — a blocked call still gets the nudge); every entry retains its own source and metadata. ## Testing -Unit suites drive a real agent loop against a mock adapter (no network) and cover the chain semantics above to per-file 100%. The snapshot tier owns the transcript surface: a scripted-replay scenario repeats a call five times and pins both reminder tiers (gentle at 3, detailed at 5) as `context/message`s in the ACP transcript. +Unit suites drive a real agent loop against a mock adapter (no network) and cover the chain semantics above to per-file 100%. The snapshot tier owns the transcript surface: a scripted-replay scenario repeats a call five times and pins both reminder tiers (gentle at 3, detailed at 5) as injected `user/message`s in the ACP transcript. ## Model Experience diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index 2049d416fb..d0a3214518 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -35,10 +35,10 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) } -/** Every `context/message` in the agent's log, flattened to joined text + source for terse assertions. */ +/** Every injected-context user message in the agent's log, flattened to joined text + source for terse assertions. */ function reminders(agent: Agent): { text: string; source: unknown }[] { return [...agent.session.events] - .filter((e): e is SessionEvent<'context/message'> => e.type === 'context/message') + .filter((e): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user') .map(e => ({ text: e.data.content.map(block => block.type === 'text' ? block.text : '').join('|'), source: e.data.source, @@ -56,7 +56,7 @@ describe('threshold escalation', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const found = reminders(agent) @@ -77,7 +77,7 @@ describe('threshold escalation', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const found = reminders(agent) @@ -99,7 +99,7 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const found = reminders(agent) @@ -123,7 +123,7 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(reminders(agent)).toHaveLength(1) @@ -141,7 +141,7 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const found = reminders(agent) @@ -162,7 +162,7 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const found = reminders(agent) @@ -178,7 +178,7 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(reminders(agent)).toHaveLength(1) // probe was NOT excluded @@ -194,7 +194,7 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(reminders(agent)).toHaveLength(1) // all three canonicalize identically @@ -215,8 +215,8 @@ describe('chain semantics', () => { ])) const agentA = ctx.agentLoop.create(SessionId('a'), { provider: 'mock-a', model: 'model-a' }) const agentB = ctx.agentLoop.create(SessionId('b'), { provider: 'mock-b', model: 'model-b' }) - agentA.send([{ type: 'text', text: 'go' }]) - agentB.send([{ type: 'text', text: 'go' }]) + agentA.followup([{ type: 'text', text: 'go' }]) + agentB.followup([{ type: 'text', text: 'go' }]) await Promise.all([waitForIdle(ctx, agentA), waitForIdle(ctx, agentB)]) expect(reminders(agentA)).toHaveLength(0) // 2 repeats < 3, despite B's 3 in the same registry @@ -234,9 +234,9 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) - agent.send([{ type: 'text', text: 'again' }]) + agent.followup([{ type: 'text', text: 'again' }]) await waitForIdle(ctx, agent) expect(reminders(agent)).toHaveLength(0) @@ -256,13 +256,13 @@ describe('chain semantics', () => { const fiber = await ctx.plugin(Object.assign((inner: Context) => { first = inner.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) - first.send([{ type: 'text', text: 'go' }]) + first.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, first) await fiber.dispose() await first.whenIdle() const second = ctx.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' }) - second.send([{ type: 'text', text: 'go' }]) + second.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, second) expect(reminders(second)).toHaveLength(0) @@ -278,7 +278,7 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(reminders(agent)).toHaveLength(1) @@ -294,7 +294,7 @@ describe('chain semantics', () => { textResponse('done'), ])) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(reminders(agent)).toHaveLength(0) @@ -316,7 +316,7 @@ describe('fold onto the downstream decision', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const found = reminders(agent) @@ -347,7 +347,7 @@ describe('fold onto the downstream decision', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const found = reminders(agent) diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 42a642caf0..3a84807285 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -27,7 +27,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`, with `appendHookResult` owning the decision rule). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog.md); `stderrSummary` is truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty). -Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `context/message` is the durable evidence) — see the hooks Agent Note. +Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `user/message` is the durable evidence) — see the hooks Agent Note. ## Model Experience diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index e59ae24e31..f5fd5702d5 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -97,7 +97,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(dir, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'do something' }]) + agent.followup([{ type: 'text', text: 'do something' }]) await waitForIdle(ctx, agent) // The prompt was blocked: model never called, turn ended rejected. @@ -120,13 +120,13 @@ describe('hooks-claude bridge — UserPromptSubmit', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // The injected context reached the model and is recorded with the plugin source. expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('remember: be brief') - const ctxMsg = events(agent).find(e => e.type === 'context/message') - expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'hooks-claude' }) + const ctxMsg = events(agent).find(e => e.type === 'user/message' && e.data.source.kind !== 'user') + expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'hooks-claude' }) }) }) @@ -145,7 +145,7 @@ describe('hooks-claude bridge — PreToolUse', () => { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'use danger' }]) + agent.followup([{ type: 'text', text: 'use danger' }]) await waitForIdle(ctx, agent) expect(ran).toBe(false) @@ -168,7 +168,7 @@ describe('hooks-claude bridge — PreToolUse', () => { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'use safe' }]) + agent.followup([{ type: 'text', text: 'use safe' }]) await waitForIdle(ctx, agent) expect(ran).toBe(true) @@ -190,7 +190,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const ctx = await harness(dir, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -211,15 +211,15 @@ describe('hooks-claude bridge — PostToolUse', () => { const ctx = await harness(dir, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const log = events(agent) const resultIdx = log.findIndex(e => e.type === 'tool/result') - const ctxIdx = log.findIndex(e => e.type === 'context/message') + const ctxIdx = log.findIndex(e => e.type === 'user/message' && e.data.source.kind !== 'user') expect(ctxIdx).toBeGreaterThan(resultIdx) // context appended AFTER the tool result const ctxMsg = log[ctxIdx] - expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content.some(b => b.type === 'text' && b.text.includes('tool was slow'))).toBe(true) + expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content.some(b => b.type === 'text' && b.text.includes('tool was slow'))).toBe(true) }) it('a PreToolUse permissionDecision:ask degrades to ask (the tool is gated, not run)', async () => { @@ -235,7 +235,7 @@ describe('hooks-claude bridge — PostToolUse', () => { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // `ask` degrades to deny today (FIXME permissions): the tool does not run and the result is isError. @@ -262,9 +262,9 @@ describe('hooks-claude bridge — SessionStart', () => { // session-start fires async (detached .then → agent.inject); wait for the // injected context/message to actually land before sending, rather than a // fixed sleep that flakes under load. - await waitFor(() => events(agent).some(e => e.type === 'context/message' + await waitFor(() => events(agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('project uses tabs')))) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('project uses tabs') @@ -357,7 +357,7 @@ describe('hooks-claude bridge — load resilience', () => { await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' }) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // The turn ran normally — no hooks, no crash. expect(adapter.requests).toHaveLength(1) @@ -379,7 +379,7 @@ describe('hooks-claude bridge — load resilience', () => { await fiber.dispose() ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index 4a894e6c2f..ab770479ed 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -74,7 +74,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) return { payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string }, @@ -104,7 +104,7 @@ export function defineCoverageCases(group: CoverageGroup): void { ctx.logger.warn = warn as never ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) // substituted command ran }) @@ -120,7 +120,7 @@ export function defineCoverageCases(group: CoverageGroup): void { let sawArgs: unknown ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // updatedInput is NOT honored — the tool ran with the ORIGINAL args. expect((sawArgs as { command?: string }).command).toBe('original') @@ -136,11 +136,11 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([textResponse('ran')]) const ctx = await harness(path, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) - // The prompt proceeded unchanged; no context/message injected. + // The prompt proceeded unchanged; no injected context. expect(adapter.requests).toHaveLength(1) - expect(events(agent).some(e => e.type === 'context/message')).toBe(false) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false) }) it('a PreToolUse hook fires for a no-agent direct tool call (no session/turn to record into)', async () => { @@ -166,7 +166,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) @@ -191,7 +191,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 }) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') @@ -207,7 +207,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(path, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('continue please') @@ -223,7 +223,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(path, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // A second model request ran → the empty-reason block forced continuation. expect(adapter.requests).toHaveLength(2) @@ -271,7 +271,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true) @@ -285,7 +285,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true) @@ -314,7 +314,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([textResponse('no')]) const ctx = await harness(path, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const turnEnd = events(agent).findLast(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'rejected' && turnEnd.data.reason.reason).toContain('blocked by UserPromptSubmit hook') @@ -329,7 +329,7 @@ export function defineCoverageCases(group: CoverageGroup): void { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // ask (no reason) → degrades to deny with the registry's generic message. expect(ran).toBe(false) @@ -344,7 +344,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0) @@ -369,7 +369,7 @@ export function defineCoverageCases(group: CoverageGroup): void { HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) }) @@ -384,7 +384,7 @@ export function defineCoverageCases(group: CoverageGroup): void { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(ran).toBe(true) const res = events(agent).find(e => e.type === 'hook/result') @@ -399,7 +399,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.isError).toBe(true) @@ -418,7 +418,7 @@ export function defineCoverageCases(group: CoverageGroup): void { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded @@ -435,13 +435,13 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.isError).toBe(true) expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true) // additionalContext also injected (the block + context arm). - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('context too')))).toBe(true) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('context too')))).toBe(true) }) it('a PreToolUse hook whose hookSpecificOutput names a DIFFERENT event does NOT deny the tool', async () => { @@ -455,7 +455,7 @@ export function defineCoverageCases(group: CoverageGroup): void { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran }) @@ -473,9 +473,9 @@ export function defineCoverageCases(group: CoverageGroup): void { // The factory create() path honors meta.cwd (the plain agentLoop.create() does not). const { SessionId } = await import('@deepseek-ai/dsh-session') const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { provider: 'mock', model: 'mock' } }) - handle.agent.send([{ type: 'text', text: 'go' }]) + handle.agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, handle.agent) - expect(events(handle.agent).some(e => e.type === 'context/message' + expect(events(handle.agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true) await handle.dispose() }) @@ -491,12 +491,12 @@ export function defineCoverageCases(group: CoverageGroup): void { // A later listener that blocks every prompt (registered AFTER the bridge). ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // the downstream block won: the model was never called, no user/message was // recorded, and the (sole, fully-blocked) prompt closed the turn `rejected` expect(adapter.requests).toHaveLength(0) - expect(events(agent).some(e => e.type === 'user/message')).toBe(false) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false) const turnEnd = events(agent).findLast(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' }) }) @@ -519,7 +519,7 @@ export function defineCoverageCases(group: CoverageGroup): void { }], })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const req = JSON.stringify(adapter.requests[0]!.messages) expect(req).toContain('from-bridge') @@ -528,12 +528,12 @@ export function defineCoverageCases(group: CoverageGroup): void { // the original prompt was replaced by the downstream rewrite const userMsg = events(agent).find(e => e.type === 'user/message') expect(userMsg?.type === 'user/message' && userMsg.data.content.some(b => b.type === 'text' && b.text === 'rewritten-prompt')).toBe(true) - const contexts = events(agent).filter(event => event.type === 'context/message') - expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([ + const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') + expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([ { kind: 'plugin', plugin: 'hooks-claude' }, { kind: 'plugin', plugin: 'policy' }, ]) - expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) + expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) }) it('folds the bridge PostToolUse context onto a downstream canonical value replacement', async () => { @@ -547,11 +547,11 @@ export function defineCoverageCases(group: CoverageGroup): void { ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) }) it('keeps bridge and downstream PostToolUse contexts as separate sourced events', async () => { @@ -570,15 +570,15 @@ export function defineCoverageCases(group: CoverageGroup): void { }], })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) - const contexts = events(agent).filter(event => event.type === 'context/message') - expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([ + const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') + expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([ { kind: 'plugin', plugin: 'hooks-claude' }, { kind: 'plugin', plugin: 'policy' }, ]) - expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) + expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) }) it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => { @@ -593,13 +593,13 @@ export function defineCoverageCases(group: CoverageGroup): void { ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.isError).toBe(true) expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true) // the bridge's context still landed (folded onto the block) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) }) }) @@ -617,7 +617,7 @@ export function defineCoverageCases(group: CoverageGroup): void { bash.run = (() => Promise.reject(new Error('executor down'))) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false) @@ -640,7 +640,7 @@ export function defineCoverageCases(group: CoverageGroup): void { await waitFor(() => threw) expect(threw).toBe(true) agent.inject = original - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // loop survived the thrown inject }) @@ -667,7 +667,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const { SessionId } = await import('@deepseek-ai/dsh-session') const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } }) - handle.agent.send([{ type: 'text', text: 'go' }]) + handle.agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, handle.agent) expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir @@ -717,7 +717,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) const warn = vi.fn(); ctx.logger.warn = warn as never const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) // Not surfaced: the systemMessage text never reaches the model request. @@ -736,7 +736,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Send immediately — do NOT wait for the session-start inject. - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing }) diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 00c0e6c14d..923a4bf8b5 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -77,7 +77,7 @@ describe('hooks-codex bridge', () => { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'run ls' }]) + agent.followup([{ type: 'text', text: 'run ls' }]) await waitForIdle(ctx, agent) expect(ran).toBe(false) @@ -98,7 +98,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')]) const ctx = await harness(dir, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) @@ -115,7 +115,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('must not run')]) const ctx = await harness(dir, adapter) const agent = ctx.agentLoop.create(SessionId('cancel-prompt-hook'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'cancel the hook' }]) + agent.followup([{ type: 'text', text: 'cancel the hook' }]) await waitFor(() => existsSync(marker)) const pid = Number(readFileSync(pidFile, 'utf8').trim()) @@ -139,7 +139,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('fine')]) const ctx = await harness(dir, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) }) @@ -149,7 +149,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) }) @@ -169,7 +169,7 @@ describe('hooks-codex bridge', () => { await fiber.dispose() ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 0c46ddea7a..ebb2164902 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -65,7 +65,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) return { payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string | null }, @@ -84,7 +84,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('no')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) const te = events(agent).findLast(e => e.type === 'turn/end') expect(te?.type === 'turn/end' && te.data.reason.kind).toBe('rejected') @@ -96,7 +96,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x') }) @@ -109,7 +109,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) expect(events(agent).some(e => e.type === 'user/message')).toBe(false) const te = events(agent).findLast(e => e.type === 'turn/end') @@ -131,17 +131,17 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro }], })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const req = JSON.stringify(adapter.requests[0]!.messages) expect(req).toContain('from-bridge') expect(req).toContain('from-downstream') expect(req).toContain('rewritten-prompt') - const contexts = events(agent).filter(event => event.type === 'context/message') - expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([ + const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') + expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([ { kind: 'plugin', plugin: 'hooks-codex' }, { kind: 'plugin', plugin: 'policy' }, ]) - expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) + expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) }) }) @@ -154,10 +154,10 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) }) it('keeps bridge and downstream PostToolUse contexts as separate sourced events', async () => { @@ -175,14 +175,14 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro }], })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const contexts = events(agent).filter(event => event.type === 'context/message') - expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([ + const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') + expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([ { kind: 'plugin', plugin: 'hooks-codex' }, { kind: 'plugin', plugin: 'policy' }, ]) - expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) + expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) }) it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => { @@ -193,11 +193,11 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.isError).toBe(true) expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) }) it('SessionStart additionalContext is injected for the first request', async () => { @@ -206,9 +206,9 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - await waitFor(() => events(agent).some(e => e.type === 'context/message' + await waitFor(() => events(agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx')))) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx') }) @@ -219,7 +219,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.isError).toBe(true) expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true) @@ -232,8 +232,8 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true) }) }) @@ -246,7 +246,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) // clean-exit hook allows; commandOf returned '' }) @@ -257,7 +257,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0) expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false) @@ -270,7 +270,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis @@ -293,7 +293,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 }) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') }) @@ -316,7 +316,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook')) }) @@ -329,7 +329,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) }) @@ -344,8 +344,8 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => existsSync(marker)) // the clean no-output hook has finished - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(events(agent).some(e => e.type === 'context/message')).toBe(false) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false) }) it('a throwing SessionStart inject is contained (logged)', async () => { @@ -370,7 +370,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) }) @@ -383,7 +383,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) }) @@ -399,7 +399,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred) @@ -412,7 +412,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true) }) @@ -424,11 +424,11 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.isError).toBe(true) expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('ctx too')))).toBe(true) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('ctx too')))).toBe(true) }) it('commandOf reads a non-string command arg as an empty command', async () => { @@ -441,7 +441,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } } expect(payload.tool_input.command).toBe('') }) @@ -477,7 +477,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro ctx.bash.run = (() => Promise.reject(new Error('executor down'))) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false) }) @@ -493,7 +493,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) // empty-reason block forced continuation expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook') }) @@ -506,7 +506,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('extra guidance from a plain hook') }) @@ -521,7 +521,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => existsSync(marker)) // the exit-2 hook has finished - expect(events(agent).some(e => e.type === 'context/message' + expect(events(agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('stale')))).toBe(false) }) @@ -534,7 +534,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // exit 1 is non-blocking → the turn ran expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('stale') }) @@ -545,9 +545,9 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - await waitFor(() => events(agent).some(e => e.type === 'context/message' + await waitFor(() => events(agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble')))) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble') }) @@ -559,7 +559,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated') }) @@ -574,7 +574,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } } expect(payload.tool_name).toBe('shell') expect(payload.tool_input.command).toBe('ls') @@ -590,7 +590,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro let ran = false ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(false) // the matcher fired → the hook denied the tool expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'PreToolUse')).toBe(true) }) @@ -602,7 +602,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) const warn = vi.fn(); ctx.logger.warn = warn as never const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up') }) @@ -625,7 +625,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { SessionId } = await import('@deepseek-ai/dsh-session') const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } }) - handle.agent.send([{ type: 'text', text: 'go' }]) + handle.agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, handle.agent) expect(existsSync(marker)).toBe(true) expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true) diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 3792d17da3..acf77751af 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -447,7 +447,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const source: MessageSource = { kind: 'user', rpcId: request.rpcId } try { if (mode === 'steer') agent.steer(content, { source }) - else agent.send(content, { source }) + else agent.followup(content, { source }) } catch (error: unknown) { // A synchronous throw from send/steer means disposed or invalid input; surface as agent-busy with the reason attached. return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } }) diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index 4058fdfe2e..7dea7589a9 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -395,7 +395,7 @@ describe('sessions.prompt / cancel', () => { const { api, ctx } = running const { sessionId } = expectOk(await api.sessions.create(request({}))) const agent = ctx.agents.get(sessionId) as Agent - agent.send([{ type: 'text', text: 'run forever' }]) + agent.followup([{ type: 'text', text: 'run forever' }]) expectOk(await api.sessions.cancel(request({ sessionId }))) const missing = await api.sessions.cancel(request({ sessionId: 'session-none' as SessionId })) @@ -414,7 +414,7 @@ describe('sessions.history', () => { const { sessionId } = expectOk(await first.api.sessions.create(request({}))) const agent = first.ctx.agents.get(sessionId) as Agent const idle = waitForIdle(first.ctx, agent) - agent.send([{ type: 'text', text: 'save me' }]) + agent.followup([{ type: 'text', text: 'save me' }]) await idle const titleEvent = await appendTitle(first.ctx, agent, 'Persisted title') await first.dispose() @@ -463,7 +463,7 @@ describe('sessions.history', () => { const agent = ctx.agents.get(sessionId) as Agent for (const text of ['q1', 'q2', 'q3']) { const idle = waitForIdle(ctx, agent) - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) await idle } @@ -534,7 +534,7 @@ describe('events streams', () => { const agent = ctx.agents.get(sessionId) as Agent const idle = waitForIdle(ctx, agent) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await idle const live = await stream.next() expect((live.value as RpcRequest).payload.type).toBe('session/event') @@ -597,7 +597,7 @@ describe('events streams', () => { const agent = ctx.agents.get(sessionId) as Agent const idle = waitForIdle(ctx, agent) - agent.send([{ type: 'text', text: 'run' }]) + agent.followup([{ type: 'text', text: 'run' }]) await idle const runningFrame = await stream.next() expect((runningFrame.value as RpcRequest).payload).toMatchObject({ type: 'host/session-status', running: true }) diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts index 1aafc91d92..bb08577d74 100644 --- a/packages/llm/llm-retry/tests/loader-composition.spec.ts +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -114,7 +114,7 @@ describe('real Loader composition', () => { loaded.llm.registerAdapter(['mock'], adapter) const agent = loaded.agentLoop.create(SessionId('loader-retry'), { provider: 'mock', model: 'mock' }) const idle = waitForIdle(loaded, agent) - agent.send([{ type: 'text', text: 'recover' }]) + agent.followup([{ type: 'text', text: 'recover' }]) await idle expect(adapter.requests).toBe(2) diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 4dc1c06bd6..20e7862ebc 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -129,7 +129,7 @@ describe('bounded transient retry policy', () => { }) }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) const event = await scheduled expect(event.data).toEqual({ @@ -178,7 +178,7 @@ describe('bounded transient retry policy', () => { const agent = context.agentLoop.create(SessionId('retry-partial'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await scheduled const idle = waitForIdle(context, agent) await vi.advanceTimersByTimeAsync(500) @@ -213,7 +213,7 @@ describe('bounded transient retry policy', () => { const agent = context.agentLoop.create(SessionId('retry-exhausted'), { provider: 'mock', model: 'mock' }) const first = waitForRetry(context, agent, 1) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) expect((await first).data.delayMs).toBe(450) const second = waitForRetry(context, agent, 2) @@ -246,7 +246,7 @@ describe('bounded transient retry policy', () => { const agent = context.agentLoop.create(SessionId('retry-zero-delay'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) expect((await scheduled).data.delayMs).toBe(0) const idle = waitForIdle(context, agent) @@ -264,7 +264,7 @@ describe('bounded transient retry policy', () => { ;({ ctx: context } = await harness(accepted, { jitterRatio: 1 })) const acceptedAgent = context.agentLoop.create(SessionId('retry-after-accepted'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, acceptedAgent, 1) - acceptedAgent.send([{ type: 'text', text: 'go' }]) + acceptedAgent.followup([{ type: 'text', text: 'go' }]) expect((await scheduled).data.delayMs).toBe(2_000) const acceptedIdle = waitForIdle(context, acceptedAgent) await vi.advanceTimersByTimeAsync(2_000) @@ -278,7 +278,7 @@ describe('bounded transient retry policy', () => { ;({ ctx: context } = await harness(rejected)) const rejectedAgent = context.agentLoop.create(SessionId('retry-after-rejected'), { provider: 'mock', model: 'mock' }) const rejectedIdle = waitForIdle(context, rejectedAgent) - rejectedAgent.send([{ type: 'text', text: 'go' }]) + rejectedAgent.followup([{ type: 'text', text: 'go' }]) await rejectedIdle expect(rejected.requests).toHaveLength(1) expect(rejectedAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false) @@ -290,7 +290,7 @@ describe('bounded transient retry policy', () => { ;({ ctx: context } = await harness(adapter)) const agent = context.agentLoop.create(SessionId('retry-auth'), { provider: 'mock', model: 'mock' }) const idle = waitForIdle(context, agent) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await idle expect(adapter.requests).toHaveLength(1) expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) @@ -307,7 +307,7 @@ describe('bounded transient retry policy', () => { context = mounted.ctx const agent = context.agentLoop.create(SessionId('retry-hmr'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await scheduled const idle = waitForIdle(context, agent) @@ -335,7 +335,7 @@ describe('bounded transient retry policy', () => { model: 'mock', }) const idle = waitForIdle(context, agent) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await entered.promise const disposing = mounted.retryFiber.dispose() @@ -376,7 +376,7 @@ describe('bounded transient retry policy', () => { model: 'mock', }) const idle = waitForIdle(context, agent) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await captured.promise await mounted.retryFiber.dispose() @@ -397,7 +397,7 @@ describe('bounded transient retry policy', () => { ;({ ctx: context } = await harness(adapter)) const agent = context.agentLoop.create(SessionId('retry-cancel'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await scheduled const idle = waitForIdle(context, agent) agent.cancel({ kind: 'user' }) @@ -426,7 +426,7 @@ describe('bounded transient retry policy', () => { const agent = context.agentLoop.create(SessionId('retry-pre-cancel'), { provider: 'mock', model: 'mock' }) const idle = waitForIdle(context, agent) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await idle expect(adapter.requests).toHaveLength(1) @@ -450,7 +450,7 @@ describe('bounded transient retry policy', () => { }) const idle = waitForIdle(context, agent) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await idle expect(adapter.requests).toHaveLength(1) diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 2725954185..0f24508d14 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -6,7 +6,7 @@ Logged, per-agent plan collaboration state with deployment-owned guidance, direc `plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. `foldPlanMode(events)` returns the last logged value or `false`, so resume, fork, and compaction recover plan state directly from the session log. UIs observe committed flips through `session/event`. -`ctx.planMode.set(agent, active)` records a pending selection and flushes it inside the next turn boundary. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's optimistic selection. Prompt submission, ordinary continuation, and request-recovery retry are all covered; a changed user selection contributes one `context/message` notice when the last logged request header described the other state. +`ctx.planMode.set(agent, active)` records a pending selection and flushes it inside the next turn boundary. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's optimistic selection. Prompt submission, ordinary continuation, and request-recovery retry are all covered; a changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state. ## Model and human surfaces diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index c1ece17958..26ad904d30 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -357,7 +357,7 @@ export class PlanModeService extends Service { const text = target ? 'The user switched this session to plan mode.' : 'The user switched this session back to the default mode.' - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'plan-mode' }, }, { surfaceOp: 'append' }) diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 940b78870a..e195ed54b2 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -75,7 +75,7 @@ describe('plan mode through the agent loop', () => { // the first prompt-submit, BEFORE the first assembly. ctx.planMode.set(agent, true) - agent.send([{ type: 'text', text: 'explore the repo' }]) + agent.followup([{ type: 'text', text: 'explore the repo' }]) await waitForIdle(ctx, agent) const log = agent.session.events @@ -92,7 +92,7 @@ describe('plan mode through the agent loop', () => { const result = findEvent(log, 'tool/result') expect(result.data.isError).toBe(false) expect(foldPlanMode(log)).toBe(true) - expect(log.some(event => event.type === 'context/message')).toBe(false) + expect(log.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false) }) it('a user flip between turns lands at the boundary: one notice and a changed header with stable tool schemas', async () => { @@ -103,21 +103,21 @@ describe('plan mode through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-plan-flip'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'hello' }]) + agent.followup([{ type: 'text', text: 'hello' }]) await waitForIdle(ctx, agent) expect(foldPlanMode(agent.session.events)).toBe(false) const first = findEvent(agent.session.events, 'request/header') expect(first.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write']) ctx.planMode.set(agent, true) - agent.send([{ type: 'text', text: 'now plan' }]) + agent.followup([{ type: 'text', text: 'now plan' }]) await waitForIdle(ctx, agent) const log = agent.session.events expect(foldPlanMode(log)).toBe(true) - const notices = log.filter(event => event.type === 'context/message') + const notices = log.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin') expect(notices).toHaveLength(1) - expect(findEvent(log, 'context/message').data.content).toEqual([ + expect(notices[0]?.type === 'user/message' && notices[0].data.content).toEqual([ { type: 'text', text: 'The user switched this session to plan mode.' }, ]) // The changed request is logged as a complete snapshot. @@ -146,7 +146,7 @@ describe('plan mode through the agent loop', () => { }) const idle = waitForIdle(ctx, agent) - agent.send([{ type: 'text', text: 'plan after the transient failure' }]) + agent.followup([{ type: 'text', text: 'plan after the transient failure' }]) await recoveryEntered.promise ctx.planMode.set(agent, true) releaseRecovery.resolve(true) @@ -163,7 +163,8 @@ describe('plan mode through the agent loop', () => { expect(firstEnd?.seq).toBeLessThan(planMode.seq) expect(planMode.seq).toBeLessThan(retryStart?.seq ?? 0) expect(findEvent(log, 'request/header', 'last').data.header.system).toContain(PLAN_CONFIG.section) - expect(findEvent(log, 'context/message').data.content).toEqual([ + const notice = log.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') + expect(notice?.type === 'user/message' && notice.data.content).toEqual([ { type: 'text', text: 'The user switched this session to plan mode.' }, ]) }) diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index f45ada4db0..a7f7743497 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -95,7 +95,7 @@ function header(session: Session): void { function noticeTexts(session: Session): string[] { return session.events - .filter(event => event.type === 'context/message') + .filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin') .map(event => (event.data as { content: { type: string; text?: string }[] }).content.map(block => block.text ?? '').join('')) } diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index 31a8d99184..26f4ddcc11 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -3,8 +3,7 @@ import type { IPty, IPtyForkOptions } from 'node-pty' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' @@ -43,7 +42,7 @@ function agent(ctx: Context): Agent { const id = SessionId('agent') return { id, options: {}, session: new Session(id), status: 'idle', ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } } @@ -245,7 +244,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) @@ -288,7 +287,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const gate = Promise.withResolvers() diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 1b04dfea8c..763ab5c871 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import PtyService from '@deepseek-ai/dsh-pty' import type { PtySendOperation } from '@deepseek-ai/dsh-pty' @@ -35,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) return { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index cab879b1a9..42622f2712 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import PtyService, { PtyBackendCleanupError, PtyError, PtySessionId } from '@deepseek-ai/dsh-pty' import type { @@ -27,9 +27,11 @@ function stubAgent(ctx: Context, rawId: string): Agent { session: new Session(id), status: 'idle', ctx: scopeFiber.ctx, - send() {}, - steer() {}, - inject() {}, + followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), + inject: () => AgentMessageId('stub'), + send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index 38a5cb4ed6..40477aeb7a 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -8,7 +8,7 @@ import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -40,7 +40,7 @@ function agent(ctx: Context): Agent { const id = SessionId('pty-loader-agent') const value: Agent = { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(value) return value diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index 9235e93226..e0b854ee43 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { renderToolsSdk } from '@deepseek-ai/dsh-tools' @@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const agent: Agent = { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(agent) return agent diff --git a/packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts b/packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts index 89cd254c8e..412ff8203d 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts @@ -56,5 +56,5 @@ const handle = await ctx.agents.create({ sessionId: SessionId('semantic-checkpoint-crash'), agentOptions: { provider: 'crash', model: 'crash' }, }) -handle.agent.send([{ type: 'text', text: 'exercise the crash boundary' }]) +handle.agent.followup([{ type: 'text', text: 'exercise the crash boundary' }]) await waitForCrash() diff --git a/packages/session-query/session-query/src/extraction.ts b/packages/session-query/session-query/src/extraction.ts index 5bf132a15e..4cbe850352 100644 --- a/packages/session-query/session-query/src/extraction.ts +++ b/packages/session-query/session-query/src/extraction.ts @@ -14,7 +14,6 @@ export function extractSessionEventText(event: SessionEvent): string { switch (event.type) { case 'user/message': case 'assistant/message': - case 'context/message': case 'steering/message': return contentText(event.data.content) case 'prompt/blocked': diff --git a/packages/session-query/session-query/tests/search-helpers.spec.ts b/packages/session-query/session-query/tests/search-helpers.spec.ts index 327048b8c1..fef9e25ad0 100644 --- a/packages/session-query/session-query/tests/search-helpers.spec.ts +++ b/packages/session-query/session-query/tests/search-helpers.spec.ts @@ -44,7 +44,7 @@ describe('session-query semantic extraction', () => { const events: SessionEvent[] = [ { type: 'user/message', seq: 0, time: 1, data: { content: messageContent, source: { kind: 'user' } }, surfaceOp: 'append' }, { type: 'assistant/message', seq: 1, time: 2, data: { turn: 1, step: 1, content: messageContent, provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: 'append' }, - { type: 'context/message', seq: 2, time: 3, data: { content: messageContent, source: { kind: 'plugin', plugin: 'test' } }, surfaceOp: 'append' }, + { type: 'user/message', seq: 2, time: 3, data: { content: messageContent, source: { kind: 'plugin', plugin: 'test' } }, surfaceOp: 'append' }, { type: 'steering/message', seq: 3, time: 4, data: { turn: 1, content: messageContent, source: { kind: 'user' } }, surfaceOp: 'append' }, { type: 'prompt/blocked', seq: 4, time: 5, data: { content: [{ type: 'text', text: 'unsafe' }], source: { kind: 'user' }, reason: 'policy' } }, { type: 'tool/call', seq: 5, time: 6, data: { turn: 1, step: 1, callId, name: 'bash', arguments: '{"cmd":"pwd"}' } }, diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index 2f115d2dc9..b23292bc3d 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -116,7 +116,7 @@ function appendTraceEvents(session: Session): void { { surfaceOp: { op: 'replace', start: 3, end: 3 }, sourceEventSeqs: [3, 2] }, ) session.append( - 'context/message', + 'user/message', { content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } }, { surfaceOp: 'append' }, ) @@ -318,14 +318,14 @@ describe('session event tracing', () => { const live = ctx.sessions.create(durable.id, { meta: { createdAt: 1, cwd: '/same' } }) live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) live.append( - 'context/message', + 'user/message', { content: [{ type: 'text', text: 'live' }], source: { kind: 'plugin', plugin: 'test' } }, { surfaceOp: 'append' }, ) TracePersistence.listFailure = new Error('list unavailable') TracePersistence.inspectFailure = new Error('inspect unavailable') await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 1 })) - .resolves.toMatchObject({ target: { type: 'context/message' } }) + .resolves.toMatchObject({ target: { type: 'user/message' } }) expect([TracePersistence.listCalls, TracePersistence.inspectCalls]).toEqual([1, 1]) TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }]) diff --git a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts index 6f091c2bf6..6882d324fc 100644 --- a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts @@ -64,7 +64,7 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => { ]) // Parent does one real turn first, so the fork has a completed turn to seed. - parent.send([{ type: 'text', text: 'parent q1' }]) + parent.followup([{ type: 'text', text: 'parent q1' }]) await parent.whenIdle() const parentPrefixLen = parent.session.events.length @@ -93,7 +93,7 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => { await forkRun.dispose() // The parent is unaffected and keeps working after both delegations. - parent.send([{ type: 'text', text: 'parent q2' }]) + parent.followup([{ type: 'text', text: 'parent q2' }]) await parent.whenIdle() const lastParentMessage = parent.session.events.findLast(e => e.type === 'assistant/message') expect(lastParentMessage?.type === 'assistant/message' && text(lastParentMessage.data.content)).toBe('parent turn two') diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 046c6ee8fe..cf5848e625 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -89,9 +89,9 @@ describe('dsh-subagent-fork', () => { it('seeds every completed parent turn through the last turn/end', async () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('second'), textResponse('child')]) - parent.send([{ type: 'text', text: 'q1' }]) + parent.followup([{ type: 'text', text: 'q1' }]) await parent.whenIdle() - parent.send([{ type: 'text', text: 'q2' }]) + parent.followup([{ type: 'text', text: 'q2' }]) await parent.whenIdle() const parentPrefixLen = parent.session.events.length @@ -108,7 +108,7 @@ describe('dsh-subagent-fork', () => { // Parent runs one turn, then we fork. The child's seeded log should contain // the parent's first turn, and the child should run its own new turn on top. const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) - parent.send([{ type: 'text', text: 'parent question' }]) + parent.followup([{ type: 'text', text: 'parent question' }]) await parent.whenIdle() const parentPrefixLen = parent.session.events.length @@ -137,10 +137,10 @@ describe('dsh-subagent-fork', () => { // open (a hanging model call), and fork while it's in flight. The seed must stop after the // balanced first turn; including the open turn would fail invariant replay during start. const { ctx, parent } = await setup([textResponse('done'), 'hang', textResponse('child')]) - parent.send([{ type: 'text', text: 'q1' }]) + parent.followup([{ type: 'text', text: 'q1' }]) await parent.whenIdle() // Start a second turn that hangs (open turn/start + open step, never ends). - parent.send([{ type: 'text', text: 'q2' }]) + parent.followup([{ type: 'text', text: 'q2' }]) await new Promise(r => setTimeout(r, 20)) // let the hanging turn open // Forking now must NOT throw (the open second turn is excluded from the seed). @@ -164,7 +164,7 @@ describe('dsh-subagent-fork', () => { textResponse('parent turn'), toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }), ]) - parent.send([{ type: 'text', text: 'warm up' }]) + parent.followup([{ type: 'text', text: 'warm up' }]) await parent.whenIdle() const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'report structured' }], @@ -183,7 +183,7 @@ describe('dsh-subagent-fork', () => { // `readResult` must scan only child-owned events after the seed. The child emits no assistant // message, so scanning the whole log would incorrectly return the parent's distinctive text. const { ctx, parent } = await setup([textResponse('parent stale'), emptyStop]) - parent.send([{ type: 'text', text: 'parent question' }]) + parent.followup([{ type: 'text', text: 'parent question' }]) await parent.whenIdle() const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child question' }], parent }) diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 7776b2af3f..48c72e9cf0 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -11,7 +11,7 @@ The driver follows this sequence: 1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header. 2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. 3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime. -4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.send(prompt)` followed by `child.whenIdle()`. +4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. 5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned zero-step turns. The child gets the parent's working-directory/session lineage and inherits the parent model unless `request.agentOptions` overrides it. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index e83b397bb5..71bc7e42a6 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -141,7 +141,7 @@ export async function startInProcessRun( const result: Promise = (async () => { try { - child.send(request.prompt) + child.followup(request.prompt) await child.whenIdle() return readResult( child, diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 1c08488fc2..bb4cf32544 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -522,7 +522,7 @@ describe('in-process structured output', () => { textResponse('parent answer'), toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), ]) - parent.send([{ type: 'text', text: 'hello' }]) + parent.followup([{ type: 'text', text: 'hello' }]) await parent.whenIdle() expect(adapter.requests[0]!.system ?? '').not.toContain(STRUCTURED_OUTPUT_INSTRUCTION) const run = await ctx.subagents.start('spawn', structuredRequest(parent)) @@ -538,7 +538,7 @@ describe('in-process structured output', () => { describe('scoped registration (each child owns its capture tool)', () => { it('a plain agent never sees the tool: nothing is registered globally at all', async () => { const { ctx, parent, adapter } = await setup([textResponse('parent answer')]) - parent.send([{ type: 'text', text: 'hello' }]) + parent.followup([{ type: 'text', text: 'hello' }]) await parent.whenIdle() // Scoped registration: the global view has no capture tool, ever. expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() @@ -552,7 +552,7 @@ describe('in-process structured output', () => { // Child turn: must see it, with the run's schema. toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }), ]) - parent.send([{ type: 'text', text: 'hello' }]) + parent.followup([{ type: 'text', text: 'hello' }]) await parent.whenIdle() expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL) @@ -630,7 +630,7 @@ describe('in-process structured output', () => { it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => { const { parent, adapter } = await setup([textResponse('plain')]) - parent.send([{ type: 'text', text: 'q' }]) + parent.followup([{ type: 'text', text: 'q' }]) await parent.whenIdle() const request = adapter.requests[0]! expect(request.tools).toBeUndefined() diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 1cfb809601..ce91480ea8 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -68,7 +68,7 @@ describe('startInProcessRun', () => { turn, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } }, }) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'late metadata' }], source: { kind: 'plugin', plugin: 'late-metadata' }, }, { surfaceOp: 'append' }) @@ -87,7 +87,7 @@ describe('startInProcessRun', () => { it('seeds a forked child but reads only the child-owned output', async () => { const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) - parent.send([{ type: 'text', text: 'parent question' }]) + parent.followup([{ type: 'text', text: 'parent question' }]) await parent.whenIdle() const seed = parent.session.events.slice() const run = await startInProcessRun(request(parent), { seed }) diff --git a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts index 89efb2b815..8fbecdc976 100644 --- a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts +++ b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts @@ -31,7 +31,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', ( ctx = await spawnHarness(workdir) const parent = ctx.agentLoop.create(SessionId('e2e-parent'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - parent.send([{ type: 'text', text: + parent.followup([{ type: 'text', text: 'Use the subagent tool to delegate this exact task: "Use the bash tool to write the text ' + 'SUBAGENT_WAS_HERE into a file named proof.txt in the current directory." ' + 'After the subagent finishes, tell me it is done.' }]) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 8952c29be7..c83eca75ab 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -95,7 +95,7 @@ describe('dsh-subagent-spawn', () => { it('a fresh child does NOT inherit the parent conversation (its log starts empty before the prompt)', async () => { // Drive the parent through one real turn so it has history, THEN spawn. const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child sees nothing')]) - parent.send([{ type: 'text', text: 'parent prompt' }]) + parent.followup([{ type: 'text', text: 'parent prompt' }]) await parent.whenIdle() const parentEventCount = parent.session.events.length expect(parentEventCount).toBeGreaterThan(0) @@ -188,10 +188,10 @@ describe('dsh-subagent-spawn', () => { expect(published).toEqual([]) }) - it('a cancel from agent/queued maps a no-turn child log to aborted', async () => { + it('a cancel from agent/inbox/enqueue maps a no-turn child log to aborted', async () => { const { ctx, parent } = await setup([]) const controller = new AbortController() - ctx.on('agent/queued', () => { controller.abort('queued-window') }) + ctx.on('agent/inbox/enqueue', () => { controller.abort('queued-window') }) const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal }) const result = await run.result expect(result).toMatchObject({ stopReason: 'aborted', output: [] }) @@ -372,7 +372,7 @@ describe('dsh-subagent-spawn', () => { textResponse('parent answer'), textResponse('child answer'), ]) - parent.send([{ type: 'text', text: 'hi' }]) + parent.followup([{ type: 'text', text: 'hi' }]) await parent.whenIdle() const run = await start(ctx, 'spawn', { diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index e4866212b6..2dd0cde142 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -34,7 +34,7 @@ The current executable companions protect these relationships: | Companion | Checks | |---|---| -| `dsh-session`, `dsh-agent`, `dsh-scope`, `dsh-agent-loop` | Session enclosure and call/result trace, agent-status transitions, scoped subjects, and model-request reconstruction. | +| `dsh-session`, `dsh-agent`, `dsh-scope`, `dsh-agent-loop` | Session enclosure and call/result trace, agent-status transitions, inbox FIFO conservation, scoped subjects, and model-request reconstruction. | | `dsh-llm`, `dsh-llm-retry`, `dsh-tools`, `dsh-system-prompt` | Stream grammar, durable retry position and bounds, tool-pipeline stages and frozen results, and authoritative prompt-assembly data. | | `dsh-compact`, `dsh-hook-protocol`, `dsh-sandbox-policy` | Durable compaction and hook pairing, compaction metadata, and sandbox-mode vocabulary. | | `dsh-fs`, `dsh-subagent`, `dsh-workflow` | Filesystem event identity, provider/child pairing, and workflow/agent lifecycle identity. | diff --git a/packages/tasks/tasks/tests/tasks.spec.ts b/packages/tasks/tasks/tests/tasks.spec.ts index 34506b3a47..015f1c4f2b 100644 --- a/packages/tasks/tasks/tests/tasks.spec.ts +++ b/packages/tasks/tasks/tests/tasks.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks' import type { TaskHooks, TaskKind, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks' @@ -23,9 +23,11 @@ function stubAgent(ctx: Context, rawId: string): Agent { session: new Session(id), status: 'idle' as const, ctx: scopeFiber.ctx, - send() {}, - steer() {}, - inject() {}, + followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), + inject: () => AgentMessageId('stub'), + send: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index 869c1183f2..fe09af9730 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -59,7 +59,7 @@ describe('todo_write tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-todo'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'plan a two-step task' }]) + agent.followup([{ type: 'text', text: 'plan a two-step task' }]) await waitForIdle(ctx, agent) const log = agent.session.events @@ -87,7 +87,7 @@ describe('todo_write tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-todo-2'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'plan then update' }]) + agent.followup([{ type: 'text', text: 'plan then update' }]) await waitForIdle(ctx, agent) const todoEvents = agent.session.events.filter(e => e.type === 'todo/write') diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 3a8576df74..f135e42553 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -29,7 +29,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name: | `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; advertises the effective command snapshot; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected | | `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, replays user, assistant, tool, and title events, and re-advertises commands | | `session/list` | `ctx.sessionQuery` | returns live-preferred newest-first sessions with absolute cwd and optional folded title; supports exact normalized cwd filtering, returns no cursor, and rejects supplied cursors | -| `session/prompt` | `ctx.commands.execute()` or `agent.send()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; `dsh-session:` links and inline mentions are snapshotted through optional `ctx.sessionReferences` before enqueue; unsupported content, unavailable reference capability, failed snapshots, and empty prompts are rejected; one request is in flight per session and settles on the owning turn's end, with an error turn rejecting the RPC | +| `session/prompt` | `ctx.commands.execute()` or `agent.followup()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; `dsh-session:` links and inline mentions are snapshotted through optional `ctx.sessionReferences` before enqueue; unsupported content, unavailable reference capability, failed snapshots, and empty prompts are rejected; one request is in flight per session and settles on the owning turn's end, with an error turn rejecting the RPC | | `session/cancel` | command `AbortSignal` or `agent.cancel()` | aborts the exact direct command, or applies the queue-aware agent cancel and settles its prompt `cancelled`; one session never cancels another | | `session/update` | `session/event` | streams user replay, assistant text/reasoning, retry/failure attempt markers, tool render intents, and `session_info_update` title revisions | | `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice | diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md index 55d44613c5..3292ee17c4 100644 --- a/packages/ui/acp/acp-feature-support.md +++ b/packages/ui/acp/acp-feature-support.md @@ -23,7 +23,7 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i | `session/load` | S | ✅ | ✅ | ✅ | Maps to `agents.resume` + full event-log replay; validates persisted `cwd` before constructing the agent. | | `session/resume` | S | ❌ | ✅ | ✅ | Reconnect WITHOUT replay; gated by `sessionCapabilities.resume`. Not advertised. | | `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. | -| `session/prompt` | S | ✅ | ✅ | ✅ | A flattened prompt beginning with `/` dispatches through `ctx.commands` without a model request; ordinary input maps to `agent.send`. One request is in flight per session. | +| `session/prompt` | S | ✅ | ✅ | ✅ | A flattened prompt beginning with `/` dispatches through `ctx.commands` without a model request; ordinary input maps to `agent.followup`. One request is in flight per session. | | `session/cancel` | S | ✅ | ✅ | ✅ | Aborts the exact direct command, or applies queue-aware `agent.cancel` and settles its prompt `cancelled`, scoped to one session. | | `session/set_mode` | S | ✅ | ✅ | ✅ | Composed opportunistically: with `@deepseek-ai/dsh-plan-mode` mounted, `session/new`/`session/load` advertise the fixed `default` / `plan` projection and `session/set_mode` records the boolean pending intent (optimistic `current_mode_update`; logged `plan/mode` lands at the turn boundary). Without the plugin: no `modes` advertised, `set_mode` rejected (see [§6 Modes](#6-session-modes--config-options--models)). | | `session/set_config_option` | S | ✅ | ✅ | ✅ | A provider/model select is present for a complete registered target; one `permission` select is added when `ctx.permission` is composed. Every response carries the complete refreshed state. | diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 8a731a1bf6..dcc32f1c6f 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -1056,7 +1056,7 @@ export function apply(ctx: Context, config: AcpConfig): void { } const { text } = referencedPrompt let preparedContent: ContentBlock[] = [{ type: 'text', text }] - let preparedContexts: NonNullable[1]>['contexts'] = [] + let preparedContexts: NonNullable[1]>['contexts'] = [] if (referencedPrompt.references.length > 0) { const sessionReferences = ctx.get('sessionReferences') if (sessionReferences === undefined) { @@ -1081,14 +1081,14 @@ export function apply(ctx: Context, config: AcpConfig): void { } assertOpen() } - // Install the in-flight slot BEFORE send() (send does not synchronously + // Install the in-flight slot BEFORE followup() (followup does not synchronously // flip status to running; the session/event listener records the turn // number and settle/rejects it). Capture the log length now as the // A turn that ends in error rejects this promise (the codec never // produces an error stop reason). const stopReason = await new Promise((resolve, reject) => { rec.inflight = { resolve, reject, turn: undefined } - rec.agent.send(preparedContent, { contexts: preparedContexts }) + rec.agent.followup(preparedContent, { contexts: preparedContexts }) }) return { stopReason } }, @@ -1333,8 +1333,8 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void { * generic fallback (title = tool name, raw args as input) when no registry is * available (e.g. pure translator tests). * - * Other event types (turn/step boundaries, context/message, …) produce - * no client update. + * Other event types (turn/step boundaries, injected-context user messages, …) + * produce no client update. * @param sessionId - the ACP session id stamped on every emitted notification. * @param event - the harness session event to translate. * @param notify - sink for each produced `session/update` notification; called @@ -1374,6 +1374,9 @@ export function streamSessionEventUpdate( } case 'user/message': { if (!includeUserMessages) return + // Only a direct human prompt replays as a user message; injected context + // (plugin/goal source) is not the user's turn and produces no update. + if (event.data.source.kind !== 'user') return // Replay the user's prompt so a loaded session shows both sides of each // turn. Live prompt turns suppress this path to avoid duplicating what // the client just sent. @@ -1420,7 +1423,7 @@ export function streamSessionEventUpdate( notify({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } }) return } - // non-error turn/step boundaries, context/message, steering, + // non-error turn/step boundaries, injected-context user messages, steering, // assistant/message — no direct ACP client update. default: return diff --git a/packages/ui/acp/tests/bridge.spec.ts b/packages/ui/acp/tests/bridge.spec.ts index 1e15910ce7..6e24acce24 100644 --- a/packages/ui/acp/tests/bridge.spec.ts +++ b/packages/ui/acp/tests/bridge.spec.ts @@ -383,7 +383,7 @@ describe('acp bridge', () => { }, }], }) - expect(target.events.some(event => event.type === 'context/message')).toBe(false) + expect(target.events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false) const request = JSON.stringify(harness.adapter.requests[0]?.messages) expect(request).toContain('untrusted, read-only snapshot') expect(request).toContain('source background') diff --git a/packages/ui/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts index 8baf076d58..bcfb177b16 100644 --- a/packages/ui/acp/tests/dispose.spec.ts +++ b/packages/ui/acp/tests/dispose.spec.ts @@ -264,7 +264,7 @@ describe('acp bridge — disposal & HMR safety', () => { const handle = await harness.ctx.agents.create({ sessionId: SessionId('guard-a'), agentOptions: { provider: 'mock', model: 'mock' }, }) - handle.agent.send([{ type: 'text', text: 'go' }]) + handle.agent.followup([{ type: 'text', text: 'go' }]) await handle.agent.whenIdle() expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeDefined() @@ -288,7 +288,7 @@ describe('acp bridge — disposal & HMR safety', () => { // Drive a turn that hangs in the model stream, so the loop is mid-turn when // disposed — its exit runs a final session/flush we can gate to hold the // teardown observably in-flight. - handle.agent.send([{ type: 'text', text: 'go' }]) + handle.agent.followup([{ type: 'text', text: 'go' }]) await new Promise(r => setTimeout(r, 30)) expect(handle.agent.status).toBe('running') let releaseFlush!: () => void diff --git a/packages/ui/acp/tests/edges.spec.ts b/packages/ui/acp/tests/edges.spec.ts index fdbaf241e6..ac85fbd7e0 100644 --- a/packages/ui/acp/tests/edges.spec.ts +++ b/packages/ui/acp/tests/edges.spec.ts @@ -30,7 +30,7 @@ describe('acp bridge — demux & config edges', () => { const before = harness.updates.length const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } }) - foreign.send([{ type: 'text', text: 'hi' }]) + foreign.followup([{ type: 'text', text: 'hi' }]) await foreign.whenIdle() await new Promise(r => setTimeout(r, 10)) diff --git a/packages/ui/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts index 2535102405..b3dcc22a55 100644 --- a/packages/ui/acp/tests/turns.spec.ts +++ b/packages/ui/acp/tests/turns.spec.ts @@ -285,10 +285,10 @@ describe('acp bridge — turn outcomes', () => { const sessionId = await newSession(harness) const agent = harness.ctx.agents.get(SessionId(sessionId))! // On the queued prompt, synchronously inject a one-shot context turn (idle - // inject writes turn/start{injection} → context/message → turn/end). Fire + // inject writes turn/start{injection} → user/message → turn/end). Fire // once so it lands between install and the prompt turn. let injected = false - harness.ctx.on('agent/queued', (subject) => { + harness.ctx.on('agent/inbox/enqueue', (subject) => { if (subject === agent && !injected) { injected = true agent.inject([{ type: 'text', text: 'ctx note' }], { source: { kind: 'plugin', plugin: 'test' } }) diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 65ca3ed52c..5200a2de13 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -152,7 +152,7 @@ export class HarnessSdkServer { rec.activePrompt = true try { rec.lastTurnEnd = undefined - rec.handle.agent.send(params.contentBlocks) + rec.handle.agent.followup(params.contentBlocks) await rec.handle.agent.whenIdle() const status = this.finishedStatus(rec.lastTurnEnd) this.transport.notify('session.finished', { diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 40dbc81acd..91d3503263 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' +import { AgentMessageId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' @@ -152,7 +152,7 @@ describe('HarnessSdkServer', () => { meta: { cwd: storageDir }, agentOptions: { provider: 'deepseek', model: 'dsagent-model' }, }) - orphanHandle.agent.send([{ type: 'text', text: 'outside the sdk session map' }]) + orphanHandle.agent.followup([{ type: 'text', text: 'outside the sdk session map' }]) await orphanHandle.agent.whenIdle() await orphanHandle.dispose() expect(llmServer.requests).toHaveLength(3) @@ -170,16 +170,16 @@ describe('HarnessSdkServer', () => { const mainWhenIdle = vi.fn<() => Promise>() .mockReturnValueOnce(firstMainIdle) .mockResolvedValue(undefined) - const mainSend = vi.fn() - const mainAgent = { - send: mainSend, + const mainFollowup = vi.fn().mockReturnValue(AgentMessageId('main-followup')) + const mainAgent = ({ + followup: mainFollowup, whenIdle: mainWhenIdle, - } as unknown as Agent - const otherSend = vi.fn() - const otherAgent = { - send: otherSend, + } satisfies Pick) as unknown as Agent + const otherFollowup = vi.fn().mockReturnValue(AgentMessageId('other-followup')) + const otherAgent = ({ + followup: otherFollowup, whenIdle: vi.fn(() => Promise.resolve()), - } as unknown as Agent + } satisfies Pick) as unknown as Agent const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) } const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) } const create = vi.fn(async (options: { sessionId: SessionId }) => @@ -196,7 +196,7 @@ describe('HarnessSdkServer', () => { }) const first = prompt('main', 'first') - await vi.waitFor(() => { expect(mainSend).toHaveBeenCalledOnce() }) + await vi.waitFor(() => { expect(mainFollowup).toHaveBeenCalledOnce() }) await expect(prompt('main', 'overlap')).rejects.toThrow('session already has an active prompt: main') await expect(prompt('other', 'independent')).resolves.toEqual({ accepted: true }) @@ -208,8 +208,8 @@ describe('HarnessSdkServer', () => { await expect(prompt('main', 'failing')).rejects.toThrow('turn wait failed') await expect(prompt('main', 'after failure')).resolves.toEqual({ accepted: true }) - expect(mainSend).toHaveBeenCalledTimes(4) - expect(otherSend).toHaveBeenCalledOnce() + expect(mainFollowup).toHaveBeenCalledTimes(4) + expect(otherFollowup).toHaveBeenCalledOnce() await server.shutdown() expect(mainHandle.dispose).toHaveBeenCalledOnce() expect(otherHandle.dispose).toHaveBeenCalledOnce() @@ -225,9 +225,9 @@ describe('HarnessSdkServer', () => { shutdown(): Promise> } const session = ctx.sessions.create(SessionId('message-outcome')) - const agent = { + const agent = ({ session, - send(content: { type: 'text'; text: string }[]) { + followup(content: { type: 'text'; text: string }[]) { session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } }, @@ -241,14 +241,15 @@ describe('HarnessSdkServer', () => { turn: 2, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } }, }) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'late metadata' }], source: { kind: 'plugin', plugin: 'late-metadata' }, }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + return AgentMessageId('message-outcome') }, whenIdle: () => Promise.resolve(), - } as unknown as Agent + } satisfies Pick) as unknown as Agent server.sessions.set('message-outcome', { handle: { agent, dispose: () => Promise.resolve() }, lastTurnEnd: undefined, diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 3dae79bf73..616544779f 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -18,9 +18,9 @@ Before model output, session events, tool presenters, questions, configuration, Typing `@` at a token boundary searches files and directories under the session working directory. A bare fuzzy query uses a reusable bounded workspace index; a query containing `/` lists that directory directly, and selecting a folder keeps completion open for descent. Whitespace-bearing paths are inserted as `@"path with spaces"`. Selecting a file inserts only its path and a trailing space: the TUI does not read it, attach hidden context, or replace it with a reference object. When a model-facing `read` tool is registered, the TUI adds one fixed system-prompt instruction telling the model to read an explicit path when its contents are needed. -When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.send()` from the status after that asynchronous preparation, so idle sends still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. +When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. -While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. +While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. @@ -80,7 +80,7 @@ The palette uses the standard 16-color ANSI foregrounds and SGR attributes, whic #### What the model sees -Each non-empty ordinary editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. A session mention becomes readable `@label` text plus the durable untrusted context defined by [`dsh-session-reference`](../../context/session-reference/README.md); its full JSON is hidden behind a compact reference card. Slash commands and keybindings are TUI-only; command results remain terminal notices. A command producer may schedule a separate agent input, such as the optional message accepted by `/plan [message]`. +Each non-empty ordinary editor submission becomes one text block, sent with `agent.followup()` while the target agent is idle and `agent.steer()` while it is running. A session mention becomes readable `@label` text plus the durable untrusted context defined by [`dsh-session-reference`](../../context/session-reference/README.md); its full JSON is hidden behind a compact reference card. Slash commands and keybindings are TUI-only; command results remain terminal notices. A command producer may schedule a separate agent input, such as the optional message accepted by `/plan [message]`. #### Token effect @@ -128,7 +128,7 @@ Changing provider or model enters that target's cache domain; no cache reuse acr #### What the model sees -A `/skill: [instructions]` submission loads the named skill and delivers one text block: a `` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same send-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name. +A `/skill: [instructions]` submission loads the named skill and delivers one text block: a `` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name. #### Token effect diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 2ade1937e3..de0ef02f7e 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1884,14 +1884,15 @@ export function createTuiChat( let toolsExpanded = false let streaming: StreamingAssistantComponent | undefined let runningStatus: RunningStatus | undefined - // Steering messages queued during the running turn (`agent/queued`) that the - // loop has not yet drained, shown as a badge on the status line. Each entry is - // the queued message's serialized source: a drain (`steering/message`) removes - // one MATCHING entry, so loop-authored steering — continuation reasons enter - // the inbox without an `agent/queued` event — cannot consume a pending user - // message's slot. Cleared on leaving `running`, which also absorbs a - // cancellation that discards the queue without logging drains; the status - // line exists only while running, so idle carries no badge to keep current. + // Steering messages queued during the running turn (`agent/inbox/enqueue` + // with `info.steering`) that the loop has not yet drained, shown as a badge on + // the status line. Each entry is the queued message's serialized source: a + // drain (`steering/message`) removes one MATCHING entry, so a loop-authored + // continuation reason (which enqueues and drains under its own source) pushes + // and pops its own slot and cannot consume a pending user message's slot. + // Cleared on leaving `running`, which also absorbs a cancellation that + // discards the queue without logging drains; the status line exists only + // while running, so idle carries no badge to keep current. const pendingSteering: string[] = [] let disposed = false let shuttingDown: Promise | undefined @@ -2238,6 +2239,29 @@ export function createTuiChat( const renderEvent = (event: SessionEvent, options: { addHistory: boolean; renderChunks: boolean }): void => { switch (event.type) { case 'user/message': { + // Injected context (plugin/goal source) renders as a dim context card, + // not a human bubble; only a direct human prompt is a user message. The + // boolean avoids narrowing `source`, so the label keeps its full union. + const source = event.data.source + if (source.kind !== 'user') { + const references = sessionReferenceCard(event.data.meta) + if (references !== undefined) { + chat.addChild(new Spacer(1)) + chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0)) + break + } + const text = displayText(contentText(event.data.content).trim()) + if (text) { + // The tui type view lacks plugin-augmented source kinds (e.g. goal), + // so read the display label without narrowing on `kind`. + const labelled = source as { kind: string; plugin?: string } + const label = labelled.plugin ?? labelled.kind + chat.addChild(new Spacer(1)) + chat.addChild(new Text(palette.dim(`Context · ${displayText(label)}`), 1, 0)) + chat.addChild(new Text(palette.muted(text), 1, 0)) + } + break + } const text = displayText(contentText(displayPromptContent(event.data)).trim()) if (text) { chat.addChild(new Spacer(1)) @@ -2262,22 +2286,6 @@ export function createTuiChat( } break } - case 'context/message': { - const references = sessionReferenceCard(event.data.meta) - if (references !== undefined) { - chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0)) - break - } - const text = displayText(contentText(event.data.content).trim()) - if (text) { - const source = event.data.source.kind === 'plugin' ? event.data.source.plugin : event.data.source.kind - chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.dim(`Context · ${displayText(source)}`), 1, 0)) - chat.addChild(new Text(palette.muted(text), 1, 0)) - } - break - } case 'prompt/blocked': appendNotice(`Prompt blocked: ${event.data.reason}`, 'warning') break @@ -2362,7 +2370,6 @@ export function createTuiChat( const isSurface = event.type === 'user/message' || event.type === 'assistant/message' || event.type === 'tool/result' - || event.type === 'context/message' || event.type === 'steering/message' if (isSurface && !active.has(event.seq)) continue if (event.type === 'tool/call' && !activeCalls.has(event.data.callId)) continue @@ -2804,7 +2811,7 @@ export function createTuiChat( } else if (agent.status === 'running') { agent.steer(content, { contexts }) } else { - agent.send(content, { contexts }) + agent.followup(content, { contexts }) } } @@ -3148,9 +3155,9 @@ export function createTuiChat( advanceTurnPhase(event) if (event.type === 'steering/message') { // A queued steering message reached the model as it drained; drop its - // entry from the badge. Matching by source keeps loop-authored steering - // (e.g. continuation reasons), which logs here without a matching - // `agent/queued` increment, from consuming a pending user slot. + // entry from the badge. Matching by source keeps a loop-authored + // continuation reason popping its own enqueued slot rather than a pending + // user message's slot. const drained = pendingSteering.indexOf(JSON.stringify(event.data.source)) if (drained >= 0) { pendingSteering.splice(drained, 1) @@ -3164,7 +3171,7 @@ export function createTuiChat( renderEvent(event, { addHistory: false, renderChunks: true }) requestRender() }) - const disposeQueued = ctx.on('agent/queued', (subject, _content, info) => { + const disposeQueued = ctx.on('agent/inbox/enqueue', (subject, info) => { if (subject !== agent || !info.steering) return pendingSteering.push(JSON.stringify(info.source)) refreshStatus() diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 97d0947536..37eee99735 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -1,6 +1,7 @@ import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' import AgentRegistry, { + AgentMessageId, type Agent, type AgentCancelCause, type AgentOptions, @@ -171,15 +172,23 @@ export async function createTuiTestHarness AgentMessageId('stub'), + send: () => AgentMessageId('stub'), cancel(cause = { kind: 'user' }) { cancelled.push(cause) }, diff --git a/packages/ui/tui/tests/session-reference.snapshot.ts b/packages/ui/tui/tests/session-reference.snapshot.ts index bfbc98b590..f0b5e0eb61 100644 --- a/packages/ui/tui/tests/session-reference.snapshot.ts +++ b/packages/ui/tui/tests/session-reference.snapshot.ts @@ -128,7 +128,7 @@ describe('TUI session-reference snapshot', () => { type: 'text', text: '\n\n## My request:\n', }) - expect(target.session.events.some(event => event.type === 'context/message')).toBe(false) + expect(target.session.events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false) const snapshot = await terminal.snapshot({ includeScrollback: true }) if (REFRESHING) { diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 16d6794002..d837841286 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -471,7 +471,7 @@ describe('TUI terminal-state snapshots', () => { session.append('todo/write', { todos: [{ content: `Unsafe todo ${CONTROL_PROBE}`, status: 'in_progress' }], }) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: `Unsafe context ${CONTROL_PROBE}` }], source: { kind: 'plugin', plugin: `unsafe-${CONTROL_PROBE}` }, }, { surfaceOp: 'append' }) @@ -587,7 +587,7 @@ describe('TUI terminal-state snapshots', () => { await checkpoint('surface-before-compaction', harness.terminal, { includeScrollback: true }) await renderAfter(harness, () => { - harness.session.append('context/message', { + harness.session.append('user/message', { content: [{ type: 'text', text: 'Compacted summary: the prior command completed and its details were retired from the active surface.' }], source: { kind: 'plugin', plugin: 'compact' }, }, { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 73be9abe76..2c4fd21650 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4,7 +4,7 @@ import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { CombinedAutocompleteProvider, type Terminal } from '@earendil-works/pi-tui' -import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, assembleContextFor, AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' import { type LlmCallConfig } from '@deepseek-ai/dsh-llm' import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands' @@ -1073,7 +1073,7 @@ describe('pi-tui chat lifecycle and transcript', () => { } const result = await setup({ beforeMount(session) { - session.append('context/message', { + session.append('user/message', { content: renderGoalChange(change), source: { kind: 'goal', goalId: change.goal.id, revision: change.goal.revision, round: 0 }, meta: change as unknown as JsonValue, @@ -1172,8 +1172,11 @@ describe('pi-tui chat lifecycle and transcript', () => { result.session.append('user/message', { content: [{ type: 'text', text: ' ' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: 'steering note' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - result.session.append('context/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - result.session.append('context/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + result.session.append('user/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' }) + result.session.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' }) + // A non-plugin injected source (goal) has no `plugin` field, so its context + // card label falls back to the source kind. + result.session.append('user/message', { content: [{ type: 'text', text: 'goal context' }], source: { kind: 'goal', goalId: 'g1', revision: 1, round: 0 } as never }, { surfaceOp: 'append' }) result.session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked' }], source: { kind: 'user' }, reason: 'test policy' }) appendAssistant(result.session, []) result.session.append('step/end', { turn: 1, step: 1 }) @@ -1254,6 +1257,7 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('Enter sends steering, Esc cancels') expect(result.terminal.output).toContain('Steering') expect(result.terminal.output).toContain('user context') + expect(result.terminal.output).toContain('Context · goal') // goal-sourced injected context labels by kind expect(result.terminal.output).toContain('Prompt blocked') expect(result.terminal.output).toContain('Turn cancelled') expect(result.terminal.progress).toContain(true) @@ -1350,16 +1354,16 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).not.toContain('queued') const queueSteering = (text: string): void => { - result.ctx.emit('agent/queued', result.agent, [{ type: 'text', text }], { source: { kind: 'user' }, contexts: [], steering: true }) + result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) } const drainSteering = (text: string): void => { result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' }) } // A steering queue for a different agent never touches this status line. - const other = { ...result.agent, id: SessionId('other') } as Agent + const other = { ...result.agent, id: SessionId('other') } as unknown as Agent result.terminal.output = '' - result.ctx.emit('agent/queued', other, [{ type: 'text', text: 'elsewhere' }], { source: { kind: 'user' }, contexts: [], steering: true }) + result.ctx.emit('agent/inbox/enqueue', other, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) await tick() expect(result.terminal.output).not.toContain('queued') @@ -1372,7 +1376,7 @@ describe('pi-tui chat lifecycle and transcript', () => { // A non-steering queue (an idle-style send) leaves the badge untouched. result.terminal.output = '' - result.ctx.emit('agent/queued', result.agent, [{ type: 'text', text: 'sent' }], { source: { kind: 'user' }, contexts: [], steering: false }) + result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'sent' }], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }) drainSteering('first') await tick() expect(result.terminal.output).toContain('1 queued') @@ -1392,8 +1396,9 @@ describe('pi-tui chat lifecycle and transcript', () => { await tick() expect(result.terminal.output).toContain('1 queued') - // A loop-authored steering event (plugin source, no matching agent/queued) - // cannot consume a pending user slot, even when it drains first. + // A steering/message whose source matches no pending badge entry (here a + // plugin source with no tracked enqueue) pops nothing, so it cannot consume + // a pending user slot even when it drains first. result.terminal.output = '' result.session.append('steering/message', { turn: 1, @@ -1425,7 +1430,7 @@ describe('pi-tui chat lifecycle and transcript', () => { const idle = await setup() // A steering queue arriving while idle has no status line to badge, so the // refresh is a no-op beyond requesting a render. - idle.ctx.emit('agent/queued', idle.agent, [{ type: 'text', text: 'early' }], { source: { kind: 'user' }, contexts: [], steering: true }) + idle.ctx.emit('agent/inbox/enqueue', idle.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'early' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) idle.session.append('tool/call', { turn: 1, step: 0, callId: 'pre' as never, name: 'bash', arguments: '{}' }) await tick() expect(idle.terminal.output).not.toContain('Executing tools') @@ -1909,10 +1914,11 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('Folder · docs/') }) result.terminal.send('\t') - await vi.waitFor(() => { - expect(result.terminal.output).toContain('File · design notes.md') - }) + result.terminal.output = '' result.terminal.send('\t') + await vi.waitFor(() => { + expect(result.terminal.output).toContain('@"docs/design notes.md"') + }) await tick() result.terminal.send('\r') await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) }) @@ -2150,7 +2156,7 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('Referenced sessions · Steering source (steering-source)') expect(result.terminal.output).not.toContain('hidden non-reference prefix') - result.session.append('context/message', { + result.session.append('user/message', { content: [{ type: 'text', text: 'secret full snapshot payload' }], source: { kind: 'plugin', plugin: 'session-reference' }, meta: { @@ -2169,13 +2175,13 @@ describe('pi-tui chat lifecycle and transcript', () => { [{ kind: 'session-reference', references: [{}] }, 'invalid-fields'], ] for (const [meta, text] of invalidCards) { - result.session.append('context/message', { + result.session.append('user/message', { content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'session-reference' }, meta, }, { surfaceOp: 'append' }) } - result.session.append('context/message', { + result.session.append('user/message', { content: [{ type: 'text', text: 'same-label snapshot' }], source: { kind: 'plugin', plugin: 'session-reference' }, meta: { kind: 'session-reference', references: [{ sessionId: 'same', label: 'same' }] }, @@ -2609,7 +2615,7 @@ describe('pi-tui chat lifecycle and transcript', () => { const events = await setup() const unrelatedSession = events.ctx.sessions.create(SessionId('unrelated-session')) - const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession } + const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession } as unknown as Agent unrelatedSession.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) unrelatedSession.append('todo/write', { todos: [{ content: 'hidden', status: 'pending' }] }) agentEvents(events.ctx, unrelatedAgent).emit('agent/status', 'running') @@ -2972,7 +2978,7 @@ describe('tool cards and surface replay', () => { turn: 1, step: 1, callId: 'old-call' as never, content: [{ type: 'text', text: 'old output' }], isError: false, }, { surfaceOp: 'append' }) const start = result.session.surface.nodes[0] as number - result.session.append('context/message', { + result.session.append('user/message', { content: [{ type: 'text', text: 'summary replacement' }], source: { kind: 'plugin', plugin: 'compact' }, }, { @@ -3293,7 +3299,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() mountTui(ctx, { color: false }, { terminal, exit: vi.fn() }) @@ -3317,7 +3323,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). @@ -3351,14 +3357,14 @@ describe('terminal mounting', () => { const otherSession = ctx.sessions.create(SessionId('other-session')) ctx.agents.register({ id: otherSession.id, options: {}, session: otherSession, status: 'idle', ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) expect(terminal.started).toBe(0) const session = ctx.sessions.create(SessionId('late-session')) const agent = { id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } as Agent ctx.agents.register(agent) await tick() @@ -3388,7 +3394,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main-session')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) await tick() expect(terminal.started).toBe(0) @@ -3430,7 +3436,7 @@ describe('terminal mounting', () => { session.append('step/start', { turn: 1, step: 1 }) ctx.agents.register({ id: session.id, options: {}, session, status: 'running', ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() terminal.start = () => { throw new Error('terminal startup failed') } diff --git a/packages/workflow/tool-ralph/tests/integration.spec.ts b/packages/workflow/tool-ralph/tests/integration.spec.ts index dcc3b947fb..c9492b9450 100644 --- a/packages/workflow/tool-ralph/tests/integration.spec.ts +++ b/packages/workflow/tool-ralph/tests/integration.spec.ts @@ -70,7 +70,7 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { agentOptions: { provider: 'mock', model: 'mock' }, }) const parent = parentHandle.agent - parent.send([{ type: 'text', text: 'PARENT_PROMPT_MARKER' }]) + parent.followup([{ type: 'text', text: 'PARENT_PROMPT_MARKER' }]) await parent.whenIdle() const children: Agent[] = [] diff --git a/scripts/gen-cordis-api.ts b/scripts/gen-cordis-api.ts index 7c61b9395d..5f2080b149 100644 --- a/scripts/gen-cordis-api.ts +++ b/scripts/gen-cordis-api.ts @@ -30,8 +30,45 @@ function quote(value: string): string { } /** - * Collect exported interface and type shapes; omit names declared in multiple - * packages rather than risk serving the wrong package's shape. + * Reduce an exported class to its type shape: drop method/constructor bodies + * and property initializers so the catalog serves member signatures, not + * implementation. + */ +function classShape(node: ts.ClassDeclaration): ts.ClassDeclaration { + const isNonPublic = (member: ts.ClassElement): boolean => + (ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined)?.some(m => + m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword) ?? false + const members = node.members.flatMap((member): ts.ClassElement[] => { + if (isNonPublic(member) || (ts.isPropertyDeclaration(member) && ts.isPrivateIdentifier(member.name))) return [] + if (ts.isMethodDeclaration(member)) { + return [ts.factory.updateMethodDeclaration( + member, member.modifiers, member.asteriskToken, member.name, member.questionToken, + member.typeParameters, member.parameters, member.type, undefined)] + } + if (ts.isConstructorDeclaration(member)) { + return [ts.factory.updateConstructorDeclaration(member, member.modifiers, member.parameters, undefined)] + } + if (ts.isGetAccessorDeclaration(member)) { + return [ts.factory.updateGetAccessorDeclaration( + member, member.modifiers, member.name, member.parameters, member.type, undefined)] + } + if (ts.isSetAccessorDeclaration(member)) { + return [ts.factory.updateSetAccessorDeclaration( + member, member.modifiers, member.name, member.parameters, undefined)] + } + if (ts.isPropertyDeclaration(member)) { + return [ts.factory.updatePropertyDeclaration( + member, member.modifiers, member.name, member.questionToken ?? member.exclamationToken, member.type, undefined)] + } + return [member] + }) + return ts.factory.updateClassDeclaration( + node, node.modifiers, node.name, node.typeParameters, node.heritageClauses, members) +} + +/** + * Collect exported interface, type-alias, and body-stripped class shapes; omit + * names declared in multiple packages rather than serve the wrong shape. */ function collectTypeDecls(scanRoot: string = root): Map { const printer = ts.createPrinter({ removeComments: true }) @@ -41,14 +78,16 @@ function collectTypeDecls(scanRoot: string = root): Map { const abs = resolve(scanRoot, rel) const sf = ts.createSourceFile(abs, readFileSync(abs, 'utf8'), ts.ScriptTarget.Latest, true) for (const stmt of sf.statements) { - if (!ts.isInterfaceDeclaration(stmt) && !ts.isTypeAliasDeclaration(stmt)) continue + const named = ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) || ts.isClassDeclaration(stmt) + if (!named || stmt.name === undefined) continue if (!(stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)) continue const name = stmt.name.text if (decls.has(name)) { ambiguous.add(name) continue } - const printed = printer.printNode(ts.EmitHint.Unspecified, stmt, sf).replace(/\r/g, '') + const emit = ts.isClassDeclaration(stmt) ? classShape(stmt) : stmt + const printed = printer.printNode(ts.EmitHint.Unspecified, emit, sf).replace(/\r/g, '') decls.set(name, printed.length > MAX_DECL_CHARS ? `${printed.slice(0, MAX_DECL_CHARS)} /* …truncated — full shape in source */` : printed) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index d87fba3d08..a3274a83c3 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -35,6 +35,8 @@ export const LINK_MAP: Record = { ContinuationDecision: 'core.md', ContinuationStop: 'core.md', GenerateOptions: 'core.md', + AgentMessage: 'core.md', + AgentMessageId: 'core.md', HookContext: 'core.md', LlmCallConfig: 'core.md', LlmModelContext: 'core.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 5cfa05bc2f..1f54f7358f 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -918,8 +918,8 @@ function renderLifecycle(): string { ' participant Session', ' participant Persistence', ' participant SDK as UI or SDK listener', - ' User->>Agent: send(content)', - ` Agent-->>SDK: ${mermaidCode('agent/queued')}`, + ' User->>Agent: followup(content)', + ` Agent-->>SDK: ${mermaidCode('agent/inbox/enqueue')}`, ' Agent->>Driver: queued work wakes driver', ` Driver-->>SDK: ${mermaidCode('agent/status')} running`, ` Driver->>Session: ${mermaidCode('turn/start')}`, @@ -998,7 +998,7 @@ function renderToolPipeline(): string { ' normalized["Registry outer normalization
pipeline/result snapshot throws become isError"]', ' finalize["ToolDefinition.finalizeContent
last content-only invariant"]', ` final["${mermaidCode('tools/result')} synchronous notification
frozen authoritative outcome"]`, - ' context["Active-batch additionalContexts FIFO
context/message after recorded tool results"]', + ' context["Active-batch additionalContexts FIFO
injected user/message after recorded tool results"]', ` toolResult["Session event: ${mermaidCode('tool/result')}
single model-facing outcome"]`, ' allResults["Tool batch settled
recorded tool/result events complete"]', ' presentResult["UI completed card
presentResult(args, result)"]', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index cc85d48ba5..0ddfff5aa6 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -263,7 +263,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ dir: 'tool-goal', source: 'packages/goal/tool-goal/src/index.ts', requires: ['ctx.tools', 'ctx.agents', 'ctx.goals', 'ctx.systemPrompt', 'a calling Agent in an authorized open turn'], - writes: ['tool/call', 'context/message goal snapshot for mutations', 'tool/result'], + writes: ['tool/call', 'user/message goal snapshot for mutations', 'tool/result'], async mount(ctx) { await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService) @@ -336,7 +336,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ dir: 'tool-tasks', source: 'packages/tasks/tool-tasks/src/index.ts', requires: ['ctx.tools', 'ctx.tasks', 'ctx.systemPrompt'], - writes: ['tool/call', 'tool/result', 'context/message via agent.inject() for background completion notices'], + writes: ['tool/call', 'tool/result', 'user/message via agent.inject() for background completion notices'], async mount(ctx) { await ctx.plugin(TaskService) await ctx.plugin(ToolTasks) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index a15fc21a96..3c199d4d04 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -73,12 +73,32 @@ }, { "doc": "docs/core-data-structures/core.md", - "symbol": "AgentCancelCause", + "symbol": "InjectOptions", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", - "symbol": "InjectOptions", + "symbol": "ResolvedAgentInput", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "AgentMessageId", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "AgentMessage", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "CancelOptions", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "AgentCancelCause", "source": "packages/core/agent/src/types.ts" }, { @@ -1289,6 +1309,26 @@ "symbol": "InjectOptions", "source": "packages/core/agent/src/types.ts" }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "ResolvedAgentInput", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "AgentMessageId", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "AgentMessage", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "CancelOptions", + "source": "packages/core/agent/src/types.ts" + }, { "doc": "docs/core-data-structures/core.zh.md", "symbol": "Agent",