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 index 00fe96d2f2..cbf529c8aa 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-unified-send-and-coalesced-user-messages.md: 61f9775c7b99c783a86e6a0814dc69d548ecbbe1 -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: d9ce4c482203592b8e5aada976debb6f088bb1eb +2026-07-22-unified-send-and-coalesced-user-messages.md: d88cd60f5c09f7961d7a59dbbd0703abdd26cc1a +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: d16ed9f763a350e61abc00d3971e4a7711e8f610 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 index 61f9775c7b..d88cd60f5c 100644 --- 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 @@ -20,7 +20,9 @@ Separately, `context/message` and `user/message` had converged: the surface proj **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. -**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO; carries `target`/`wakeup` on `InboxItemInfo`), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items). 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. +**`send` returns an id.** `send` (and the aliases) return an opaque branded `AgentMessageId` for the accepted message; `send`'s previous return was `void`. + +**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items) each carry an `AgentMessage` — the accepted message including its returned `id`, `target`/`wakeup`, source, and contexts — so a caller can correlate a queued item with its lifecycle. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including the loop-authored continuation-reason steer (`agent/turn-continuation` returning `{ action: 'continue', reason }`), so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. **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). 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 index d9ce4c4822..d16ed9f763 100644 --- 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 @@ -20,7 +20,9 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` **goal 回放靠轮次而非类型来区分。** 一次 goal 状态变更是一条第 0 轮、来源为 goal 的 `user/message`,携带 `goal/change` 元数据;正数轮次则是一条已准入的继续执行提示词。`decodeGoalEvent` 现在接收一条 `user/message`,并仍会在非 goal 来源携带 goal 元数据、或 goal 来源缺少元数据时立即报错。 -**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO;在 `InboxItemInfo` 上携带 `target`/`wakeup`)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括由 loop 生成的携带继续原因的 steer(`agent/turn-continuation` 返回 `{ action: 'continue', reason }`),因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 +**`send` 返回一个 id。** `send`(以及其别名)为被接受的消息返回一个不透明的 branded `AgentMessageId`;`send` 此前的返回值是 `void`。 + +**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都携带一条 `AgentMessage`——即被接受的消息,包含其返回的 `id`、`target`/`wakeup`、来源和上下文——因此调用方可以把一个排队项与其生命周期关联起来。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括由 loop 生成的携带继续原因的 steer(`agent/turn-continuation` 返回 `{ action: 'continue', reason }`),因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 **cancel 新增 keepInbox。** `cancel(cause?, { keepInbox? })`;当其为 true 时,它中止活跃轮次,但保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 7c9ae4bea8..d61b126661 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:329`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:353`](../../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:269`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:293`](../../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:278`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ 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:477`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:501`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -108,16 +108,16 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, * 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 info - the claimed item's accepted content, source, contexts, steering, and wakeup facts. + * @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, info: InboxItemInfo): void +'agent/inbox/dequeue'(this: Scoped, agent: Agent, message: AgentMessage): void ``` -Types: [Agent](../core-data-structures/core.md) · [InboxItemInfo](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +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:309`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -129,16 +129,16 @@ Source: [`packages/core/agent/src/types.ts:309`](../../packages/core/agent/src/t * delivering them. Fires once per effective clearing call with every * discarded item, after `agent/cancel-requested` and before the abort. * @param agent - the agent whose inbox was cleared. - * @param items - the discarded items in FIFO order (queued then steering); empty when nothing was pending. + * @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/discard'(this: Scoped, agent: Agent, items: InboxItemInfo[]): void +'agent/inbox/discard'(this: Scoped, agent: Agent, messages: AgentMessage[]): void ``` -Types: [Agent](../core-data-structures/core.md) · [InboxItemInfo](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +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:319`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:343`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -152,16 +152,16 @@ A detached, frozen item entered the agent's inbox (queued or steering FIFO). Sou * is the eventual `user/message`/`steering/message`. Injection * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. * @param agent - the agent whose inbox received the item. - * @param info - the accepted content, source, contexts, steering, and wakeup facts. + * @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, info: InboxItemInfo): void +'agent/inbox/enqueue'(this: Scoped, agent: Agent, message: AgentMessage): void ``` -Types: [Agent](../core-data-structures/core.md) · [InboxItemInfo](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +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:299`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:323`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -184,7 +184,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:427`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:451`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -207,7 +207,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:358`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:382`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -234,7 +234,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:374`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:398`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -259,7 +259,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:388`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:412`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -285,7 +285,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:442`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:466`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -311,7 +311,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:403`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:427`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -333,7 +333,7 @@ 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:342`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:366`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -353,7 +353,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:287`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -376,7 +376,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:415`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:439`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -398,7 +398,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:453`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:477`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -420,7 +420,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:464`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:488`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index c2d8b56a80..69f917eaad 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -409,16 +409,30 @@ The fixed-preset aliases own `target` and `wakeup`, so they accept only the rema type AliasSendOptions = Omit ``` -The `agent/inbox/*` live events carry the resolved facts of one FIFO item; injection bypasses the FIFOs and never appears on them: +`send` returns the accepted message's opaque `AgentMessageId`, stable across that message's `agent/inbox/*` events: ```ts type-equiv /** - * The resolved facts of one inbox FIFO item, carried by the `agent/inbox/*` - * live events. Source defaults are already applied, so these are the exact - * values the item was accepted with. `steering` is true for a `next-step` - * item drained between steps; a `next-turn` item is claimed at a turn boundary. + * Opaque id assigned to one accepted {@link Agent.send} message; returned by + * `send` and carried on its `agent/inbox/*` events for correlation. */ -interface InboxItemInfo { +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 {@link Agent.send} message, carried by the `agent/inbox/*` live + * events. `id` is the value `send` returned to the caller, stable across this + * message's enqueue, dequeue, and discard events. Source defaults are already + * applied, so these are the exact values the item was accepted with. `steering` + * is true for a `next-step` item drained between steps; a `next-turn` item is + * claimed at a turn boundary. + */ +interface AgentMessage { + /** The id `send` returned for this message. */ + id: AgentMessageId content: ContentBlock[] source: MessageSource contexts: HookContext[] @@ -488,8 +502,9 @@ abstract class Agent { * input throws synchronously before any notification, enqueue, or append. * @param content - the model-facing content blocks to deliver. * @param options - target queue, wakeup decision, source, contexts, and meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - abstract send(content: ContentBlock[], options?: SendOptions): void + abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId /** * Clear queued and steering work — unless `keepInbox` — and abort the active @@ -512,9 +527,10 @@ abstract class Agent { * ordinary message of its own turn. * @param content - the prompt content blocks. * @param options - source and attached contexts. + * @returns the accepted message's {@link AgentMessageId}. */ - followup(content: ContentBlock[], options?: AliasSendOptions): void { - this.send(content, { ...options, target: 'next-turn', wakeup: true }) + followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-turn', wakeup: true }) } /** @@ -526,9 +542,10 @@ abstract class Agent { * Idle steering falls back to a woken follow-up turn. * @param content - the steering content blocks. * @param options - source and attached contexts. + * @returns the accepted message's {@link AgentMessageId}. */ - steer(content: ContentBlock[], options?: AliasSendOptions): void { - this.send(content, { ...options, target: 'next-step', wakeup: true }) + steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-step', wakeup: true }) } /** @@ -541,9 +558,10 @@ abstract class Agent { * `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 message's {@link AgentMessageId}. */ - inject(content: ContentBlock[], options?: AliasSendOptions): void { - this.send(content, { ...options, target: 'next-step', wakeup: false }) + inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-step', wakeup: false }) } } ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 0825bbf348..2cf7e3a577 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,24 +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:329`](../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:269`](../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:278`](../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:477`](../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:309`](../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:319`](../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:299`](../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:427`](../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:358`](../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:374`](../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:388`](../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:442`](../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:403`](../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:342`](../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:287`](../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:415`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:453`](../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:464`](../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:353`](../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:293`](../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:302`](../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:501`](../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:333`](../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:343`](../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:323`](../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:451`](../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:382`](../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:398`](../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:412`](../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:466`](../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:427`](../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:366`](../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:311`](../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:439`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:477`](../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:488`](../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/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 26bb5d7a9d..c87915dda7 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -4,7 +4,7 @@ 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, type SessionEvent } from '@deepseek-ai/dsh-session' -import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' import { defineTool } 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,15 @@ function sessionAgent(session: Session, id = 'agent'): Agent { session, status: 'running', ctx: new Context(), - send() {}, - followup() {}, - steer() {}, + send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), inject(content, options) { session.append('user/message', { content, source: options?.source ?? { kind: 'user' }, }, { surfaceOp: 'append' }) + return AgentMessageId('stub') }, cancel() {}, whenIdle: () => Promise.resolve(), diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index c96cee8b38..e04c92fd19 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 { @@ -174,15 +174,16 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { options: {}, session, status: 'idle', - send() {}, - followup() {}, - steer() {}, + send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), inject(content, options) { session.append('user/message', { content, source: options?.source ?? { kind: 'user' }, ...options?.meta !== undefined ? { meta: options.meta } : {}, }, { surfaceOp: 'append' }) + return AgentMessageId('stub') }, cancel() {}, whenIdle: () => Promise.resolve(), diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index f5872f5fcc..9e6087a4de 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -846,22 +846,22 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'agent/inbox/dequeue', mode: 'emit', - signature: '\'agent/inbox/dequeue\'(this: Scoped, agent: Agent, info: InboxItemInfo): 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 info - the claimed item\'s accepted 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 */', + 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, items: InboxItemInfo[]): void', - jsDoc: '/**\n * `cancel()` (without `keepInbox`) dropped pending inbox items without\n * delivering them. Fires once per effective clearing call with every\n * discarded item, after `agent/cancel-requested` and before the abort.\n * @param agent - the agent whose inbox was cleared.\n * @param items - the discarded items in FIFO order (queued then steering); empty when nothing was pending.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/inbox/discard\'(this: Scoped, agent: Agent, messages: AgentMessage[]): void', + jsDoc: '/**\n * `cancel()` (without `keepInbox`) dropped pending inbox items without\n * delivering them. Fires once per effective clearing call with every\n * discarded item, after `agent/cancel-requested` and before the abort.\n * @param agent - the agent whose inbox was cleared.\n * @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: '`cancel()` (without `keepInbox`) dropped pending inbox items without delivering them.', }, { name: 'agent/inbox/enqueue', mode: 'emit', - signature: '\'agent/inbox/enqueue\'(this: Scoped, agent: Agent, info: InboxItemInfo): void', - jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `info` 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\n * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param info - the accepted 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 */', + 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 `info` 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\n * (`next-step`/no-wakeup) bypasses the FIFOs 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).', }, { diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 670945a8fa..2ce1928af2 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -6,15 +6,16 @@ * @module dsh-agent-loop/agent */ +import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' -import { agentEvents } from '@deepseek-ai/dsh-agent' +import { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent' import { Agent } from '@deepseek-ai/dsh-agent' import type { AgentCancelCause, AgentOptions, AgentStatus, CancelOptions, HookContext, SendOptions } from '@deepseek-ai/dsh-agent' import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } 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, inboxInfo, 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. */ @@ -196,9 +197,11 @@ export class ReactLoopAgent extends Agent { * 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[], source: MessageSource, wakeup: boolean, options?: SendOptions): InboxMessage { + private acceptMessage( + id: AgentMessageId, content: ContentBlock[], source: MessageSource, wakeup: boolean, options?: SendOptions, + ): InboxMessage { const contexts = options?.contexts ?? [] - const accepted = snapshotJsonValue({ content, source, contexts, wakeup }) + const accepted = snapshotJsonValue({ id, content, source, contexts, wakeup }) if (accepted === undefined) { throw new TypeError('agent message content, source, and contexts must be losslessly JSON-serializable') } @@ -219,23 +222,25 @@ export class ReactLoopAgent extends Agent { if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`) } - send(content: ContentBlock[], options?: SendOptions): void { + send(content: ContentBlock[], options?: SendOptions): AgentMessageId { this.assertNotDisposed() + const id = AgentMessageId(randomUUID()) const target = options?.target ?? 'next-turn' const wakeup = options?.wakeup ?? true // next-step/no-wakeup is injection: durable context without running the model. - if (target === 'next-step' && !wakeup) { this.injectContext(content, options); return } + if (target === 'next-step' && !wakeup) { this.injectContext(content, options); return id } // next-step/wakeup is steering into the running turn; idle falls back to a // woken follow-up turn (there is no active turn to attach to). const steering = target === 'next-step' && this._status === 'running' const source = options?.source ?? { kind: 'user' } - const accepted = this.acceptMessage(content, source, wakeup, options) + const accepted = this.acceptMessage(id, content, source, wakeup, options) if (steering) { this.#inbox.steer(accepted) } else { this.#inbox.enqueue(accepted, wakeup) } - agentEvents(this.loopCtx, this).emit('agent/inbox/enqueue', inboxInfo(accepted, steering)) + agentEvents(this.loopCtx, this).emit('agent/inbox/enqueue', agentMessage(accepted, steering)) + return id } /** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */ @@ -346,7 +351,7 @@ export class ReactLoopAgent extends Agent { // Clear work already present before abort observers run. this.#inbox.clear() if (discarded.length > 0) { - const items = discarded.map(({ message, steering }) => inboxInfo(message, steering)) + const items = discarded.map(({ message, steering }) => agentMessage(message, steering)) agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) } } diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index e6bbbcfb7f..fd2810ea9a 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -7,10 +7,11 @@ */ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import type { HookContext, InboxItemInfo } from '@deepseek-ai/dsh-agent' +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 `send` returned. */ export interface InboxMessage { + id: AgentMessageId content: ContentBlock[] source: MessageSource contexts: HookContext[] @@ -22,10 +23,10 @@ export interface InboxMessage { * 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 facts for enqueue/dequeue/discard. + * @returns the live-event message for enqueue/dequeue/discard. */ -export function inboxInfo(message: InboxMessage, steering: boolean): InboxItemInfo { - return { content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup } +export function agentMessage(message: InboxMessage, steering: boolean): AgentMessage { + return { id: message.id, content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup } } /** diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 11733b17e8..6b57f87e74 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 { inboxInfo, type Inbox, type InboxMessage } 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. */ @@ -279,7 +280,7 @@ async function runTurn( const drainSteering = (): boolean => { const messages = handle.inbox.drainSteering() for (const message of messages) { - events.emit('agent/inbox/dequeue', inboxInfo(message, true)) + 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' }) for (const context of prepared.separateContexts) { @@ -297,7 +298,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', inboxInfo(message, false)) + events.emit('agent/inbox/dequeue', agentMessage(message, false)) const trigger: TurnTrigger = { kind: 'message', source: message.source } let reason: TurnEndReason = { kind: 'completed' } @@ -542,9 +543,12 @@ async function runTurn( // 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) { - const item: InboxMessage = { content: decision.reason.content, source: decision.reason.source, contexts: [], wakeup: true } + const item: InboxMessage = { + id: AgentMessageId(randomUUID()), content: decision.reason.content, + source: decision.reason.source, contexts: [], wakeup: true, + } handle.inbox.steer(item) - events.emit('agent/inbox/enqueue', inboxInfo(item, true)) + events.emit('agent/inbox/enqueue', agentMessage(item, true)) } let shouldContinue = decision.action === 'continue' diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 91858e7050..791eae3bda 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest' +import { AgentMessageId } from '@deepseek-ai/dsh-agent' import { Inbox } from '../src/inbox.ts' function message(text: string) { - return { content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [], wakeup: true } + return { id: AgentMessageId(text), content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [], wakeup: true } } function resolverPair() { diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 06a7e618f9..ebb756b139 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -56,7 +56,7 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(content, options?)` — the one delivery primitive over the (`target` × `wakeup`) matrix; `Agent` is an abstract class whose `followup`/`steer`/`inject` aliases are fixed-preset delegates to it. `target: 'next-turn'` (default) queues one independent FIFO item that, if claimed, becomes the sole ordinary message in its turn; `wakeup` (default `true`) wakes a parked driver, while `wakeup: false` queues without waking. `target: 'next-step'` with `wakeup: true` submits steering, and with `wakeup: false` injects durable context without running the model. Omitting `options.source` attests direct human input as `{ kind: 'user' }` (injection defaults to `{ kind: 'plugin', plugin: '' }`) 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/inbox/enqueue` 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 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.send(content, options?)` — the one delivery primitive over the (`target` × `wakeup`) matrix; `Agent` is an abstract class whose `followup`/`steer`/`inject` aliases are fixed-preset delegates to it. It returns the accepted message's opaque `AgentMessageId`, which the message's `agent/inbox/enqueue`/`dequeue`/`discard` events carry so a caller can correlate a queued item with its lifecycle. `target: 'next-turn'` (default) queues one independent FIFO item that, if claimed, becomes the sole ordinary message in its turn; `wakeup` (default `true`) wakes a parked driver, while `wakeup: false` queues without waking. `target: 'next-step'` with `wakeup: true` submits steering, and with `wakeup: false` injects durable context without running the model. Omitting `options.source` attests direct human input as `{ kind: 'user' }` (injection defaults to `{ kind: 'plugin', plugin: '' }`) 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/inbox/enqueue` 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 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.followup(content, options?)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. - `agent.steer(content, options?)` — the `next-step`/wakeup preset: while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; when idle, delegate to a woken follow-up. 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?)` — the `next-step`/no-wakeup preset: 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. `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)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index f04c999cb6..e7d733c920 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' @@ -68,12 +69,31 @@ export interface SendOptions { export type AliasSendOptions = Omit /** - * The resolved facts of one inbox FIFO item, carried by the `agent/inbox/*` - * live events. Source defaults are already applied, so these are the exact - * values the item was accepted with. `steering` is true for a `next-step` - * item drained between steps; a `next-turn` item is claimed at a turn boundary. + * Opaque id assigned to one accepted {@link Agent.send} message; returned by + * `send` and carried on its `agent/inbox/*` events for correlation. */ -export interface InboxItemInfo { +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 {@link Agent.send} message, carried by the `agent/inbox/*` live + * events. `id` is the value `send` returned to the caller, stable across this + * message's enqueue, dequeue, and discard events. Source defaults are already + * applied, so these are the exact values the item was accepted with. `steering` + * is true for a `next-step` item drained between steps; a `next-turn` item is + * claimed at a turn boundary. + */ +export interface AgentMessage { + /** The id `send` returned for this message. */ + id: AgentMessageId content: ContentBlock[] source: MessageSource contexts: HookContext[] @@ -194,8 +214,9 @@ export abstract class Agent { * input throws synchronously before any notification, enqueue, or append. * @param content - the model-facing content blocks to deliver. * @param options - target queue, wakeup decision, source, contexts, and meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - abstract send(content: ContentBlock[], options?: SendOptions): void + abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId /** * Clear queued and steering work — unless `keepInbox` — and abort the active @@ -218,9 +239,10 @@ export abstract class Agent { * ordinary message of its own turn. * @param content - the prompt content blocks. * @param options - source and attached contexts. + * @returns the accepted message's {@link AgentMessageId}. */ - followup(content: ContentBlock[], options?: AliasSendOptions): void { - this.send(content, { ...options, target: 'next-turn', wakeup: true }) + followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-turn', wakeup: true }) } /** @@ -232,9 +254,10 @@ export abstract class Agent { * Idle steering falls back to a woken follow-up turn. * @param content - the steering content blocks. * @param options - source and attached contexts. + * @returns the accepted message's {@link AgentMessageId}. */ - steer(content: ContentBlock[], options?: AliasSendOptions): void { - this.send(content, { ...options, target: 'next-step', wakeup: true }) + steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-step', wakeup: true }) } /** @@ -247,9 +270,10 @@ export abstract class Agent { * `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 message's {@link AgentMessageId}. */ - inject(content: ContentBlock[], options?: AliasSendOptions): void { - this.send(content, { ...options, target: 'next-step', wakeup: false }) + inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-step', wakeup: false }) } } @@ -292,31 +316,31 @@ declare module 'cordis' { * is the eventual `user/message`/`steering/message`. Injection * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. * @param agent - the agent whose inbox received the item. - * @param info - the accepted content, source, contexts, steering, and wakeup facts. + * @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, info: InboxItemInfo): 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 info - the claimed item's accepted content, source, contexts, steering, and wakeup facts. + * @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, info: InboxItemInfo): void + 'agent/inbox/dequeue'(this: Scoped, agent: Agent, message: AgentMessage): void /** * `cancel()` (without `keepInbox`) dropped pending inbox items without * delivering them. Fires once per effective clearing call with every * discarded item, after `agent/cancel-requested` and before the abort. * @param agent - the agent whose inbox was cleared. - * @param items - the discarded items in FIFO order (queued then steering); empty when nothing was pending. + * @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/discard'(this: Scoped, agent: Agent, items: InboxItemInfo[]): void + '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 d157eeab57..aefa739dde 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -4,6 +4,7 @@ import type { Events } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Agent, + AgentMessageId, agentEvents, agentInterruptReasonOf, } from '@deepseek-ai/dsh-agent' @@ -20,7 +21,7 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { session: new Session(id), status: 'idle', ctx: new Context(), - send() {}, + send: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, ...overrides, diff --git a/packages/core/agent/tests/invariant.spec.ts b/packages/core/agent/tests/invariant.spec.ts index b850e8743a..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' @@ -58,7 +58,7 @@ describe('agent status invariants', () => { }) describe('agent inbox invariants', () => { - const info = (steering: boolean) => ({ content: [], source: { kind: 'user' as const }, contexts: [], steering, wakeup: true }) + 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() diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index bcb3944cbd..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,8 +42,8 @@ describe('scoped-dispatch invariants', () => { 'agent/created': [agent], 'agent/disposed': [agent], 'agent/status': [agent, 'idle'], - 'agent/inbox/enqueue': [agent, { content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }], - 'agent/inbox/dequeue': [agent, { content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }], + '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'], diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 6ea767e091..8964d02df2 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, AliasSendOptions } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' import GoalService from '@deepseek-ai/dsh-goal' @@ -48,10 +48,10 @@ function stubAgent(id: string): { agent: Agent; session: Session } { session, ctx: new Context(), get status() { return status }, - send() {}, - followup() {}, - steer() {}, - inject(content, options) { appendInjection(session, content, options) }, + send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), + inject(content, options) { appendInjection(session, content, options); return AgentMessageId('stub') }, cancel() { status = 'idle' }, whenIdle() { return Promise.resolve() }, } diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index e323180e88..d4994466f9 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -488,7 +488,7 @@ describe('same-session goal driving', () => { if (options?.source?.kind === 'goal' && (options.target ?? 'next-turn') === 'next-turn') { throw new Error('queue rejected') } - realSend(content, options) + return realSend(content, options) }) test.ctx.goals.create(test.agent, { objective: 'handle queue failure' }) @@ -510,7 +510,7 @@ describe('same-session goal driving', () => { test.ctx.goals.disarm(test.agent) throw new Error('queue rejected after disarm') } - realSend(content, options) + return realSend(content, options) }) test.ctx.goals.create(test.agent, { objective: 'preserve the newer activation state' }) diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 18f8d5dfe2..4f6e4e2cfc 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, AliasSendOptions } 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' @@ -64,12 +64,13 @@ function stubAgentForSession(session: Session): StubAgent { session, ctx: new Context(), get status() { return status }, - send() {}, - followup() {}, - steer() {}, + send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), inject(content, options) { if (shouldDefer) deferred.push({ content, options }) else appendInjection(session, content, options) + return AgentMessageId('stub') }, cancel() {}, whenIdle() { return Promise.resolve() }, @@ -476,7 +477,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) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 171fe1c0ef..a4f21392a3 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, AliasSendOptions } from '@deepseek-ai/dsh-agent' import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' @@ -31,9 +31,9 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { session, get status() { return status }, ctx: new Context(), - send() {}, - followup() {}, - steer() {}, + send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), inject(content: ContentBlock[], options?: AliasSendOptions) { const source = options?.source ?? { kind: 'plugin', plugin: '' } session.append('user/message', { @@ -41,6 +41,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { source, ...options?.meta === undefined ? {} : { meta: options.meta }, }, { surfaceOp: 'append' }) + return AgentMessageId('stub') }, cancel() {}, whenIdle() { return Promise.resolve() }, diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index 360b71c3f8..dffd80dfbd 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -3,7 +3,7 @@ import type { IPty, IPtyForkOptions } from 'node-pty' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { 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 from '@deepseek-ai/dsh-sandbox-policy' @@ -42,7 +42,7 @@ function agent(ctx: Context): Agent { const id = SessionId('agent') return { id, options: {}, session: new Session(id), status: 'idle', ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index ad463a0352..512c84c6eb 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 SandboxProvider from '@deepseek-ai/dsh-sandbox' @@ -34,7 +34,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() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => 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 4a690ed520..91842173c8 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, { PtyError, PtySessionId } from '@deepseek-ai/dsh-pty' import type { @@ -27,10 +27,10 @@ function stubAgent(ctx: Context, rawId: string): Agent { session: new Session(id), status: 'idle', ctx: scopeFiber.ctx, - send() {}, - followup() {}, - steer() {}, - inject() {}, + send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), + inject: () => 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 f71a637928..cf096ebb70 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() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => 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 0439e5f876..b0da3adb27 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 from '@deepseek-ai/dsh-tools' @@ -17,7 +17,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() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(agent) return agent diff --git a/packages/tasks/tasks/tests/tasks.spec.ts b/packages/tasks/tasks/tests/tasks.spec.ts index b4d57191a2..cb72658968 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,10 +23,10 @@ function stubAgent(ctx: Context, rawId: string): Agent { session: new Session(id), status: 'idle' as const, ctx: scopeFiber.ctx, - send() {}, - followup() {}, - steer() {}, - inject() {}, + send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), + inject: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index c8755e4bb3..243f492d2c 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, @@ -152,16 +153,19 @@ export async function createTuiTestHarness AgentMessageId('stub'), cancel(cause = { kind: 'user' }) { cancelled.push(cause) }, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index b45b7a826f..23e297783c 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -3,7 +3,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 CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type JsonValue, type SessionHeader } from '@deepseek-ai/dsh-session' @@ -552,7 +552,7 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).not.toContain('queued') const queueSteering = (text: string): void => { - result.ctx.emit('agent/inbox/enqueue', result.agent, { content: [{ type: 'text', text }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: 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' }) @@ -561,7 +561,7 @@ describe('pi-tui chat lifecycle and transcript', () => { // A steering queue for a different agent never touches this status line. const other = { ...result.agent, id: SessionId('other') } as unknown as Agent result.terminal.output = '' - result.ctx.emit('agent/inbox/enqueue', other, { content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: 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') @@ -574,7 +574,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/inbox/enqueue', result.agent, { content: [{ type: 'text', text: 'sent' }], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }) + 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') @@ -628,7 +628,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/inbox/enqueue', idle.agent, { content: [{ type: 'text', text: 'early' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: 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') @@ -2208,7 +2208,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() mountTui(ctx, { color: false }, { terminal, exit: vi.fn() }) @@ -2232,7 +2232,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). @@ -2266,14 +2266,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() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => 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() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } as Agent ctx.agents.register(agent) await tick() @@ -2303,7 +2303,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main-session')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) await tick() expect(terminal.started).toBe(0) @@ -2345,7 +2345,7 @@ describe('terminal mounting', () => { session.append('step/start', { turn: 1, step: 1 }) ctx.agents.register({ id: session.id, options: {}, session, status: 'running', ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() terminal.start = () => { throw new Error('terminal startup failed') } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 2eee7e0bb0..800f59c22e 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -35,8 +35,9 @@ export const LINK_MAP: Record = { ContinuationDecision: 'core.md', ContinuationStop: 'core.md', GenerateOptions: 'core.md', + AgentMessage: 'core.md', + AgentMessageId: 'core.md', HookContext: 'core.md', - InboxItemInfo: 'core.md', LlmCallConfig: 'core.md', LlmModelContext: 'core.md', LlmFailure: 'llm-streaming.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5014148504..45c08e494a 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -83,7 +83,12 @@ }, { "doc": "docs/core-data-structures/core.md", - "symbol": "InboxItemInfo", + "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" }, {