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 0d217f098a..a9d7708bc8 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: dbd157ad4c81f278cf1417765816497b9e3568df -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: dea32f142c4ef005ff04d00ef8c8ae1ea19c9705 +2026-07-22-unified-send-and-coalesced-user-messages.md: 359ea62b77685a267b7a8c36e58929f67569d92f +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: d4a0f661900a10a0b74ebdb67077881b6a9d24ee 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 dbd157ad4c..359ea62b77 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 @@ -22,7 +22,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj **`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. +**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 type their `AgentMessage` payload with only the accepted message's returned `id`, content, and source, so a caller can correlate a queued item with its lifecycle without depending on driver routing state. 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? })`; callers choose the cause explicitly, and `keepInbox: true` aborts the active turn while preserving queued and steering items (no discard event, and un-started work is not dropped). 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 dea32f142c..d4a0f66190 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 @@ -22,7 +22,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` **`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 永远无法把它压到负数。 +**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都将各自的 `AgentMessage` 载荷类型限定为仅包含被接受消息所返回的 `id`、内容和来源;调用方因此可以把一个排队项与其生命周期关联起来,而无需依赖驱动器的路由状态。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括由 loop 生成的携带继续原因的 steer(`agent/turn-continuation` 返回 `{ action: 'continue', reason }`),因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 **cancel 新增 keepInbox。** `cancel(cause, { keepInbox? })`;调用方显式选择 cause,且 `keepInbox: true` 会中止活跃轮次,同时保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.i18n.yaml index ddf4792769..4b47c4a3b2 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.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-21-tui-steering-queue-badge.md: b29a4667e778e65b0678f946fcaa34b79c4d7da0 -2026-07-21-tui-steering-queue-badge.zh.md: 4bfce461e11bce1773d6e0b15aabecf6a6a6144c +2026-07-21-tui-steering-queue-badge.md: b1d111187bb42dfaaa6f3618c1d31f25b7e4b455 +2026-07-21-tui-steering-queue-badge.zh.md: 6ddb5351e9b8db5bd4e73f12768d37667483d874 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.md b/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.md index b29a4667e7..b1d111187b 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.md @@ -10,10 +10,10 @@ While a turn runs, an editor submission calls `agent.steer()` and joins the stee ## Decision -The agent's inbox is the authoritative steering queue but is not observable from the TUI, so the badge is a live count reconstructed from the public `agent/queued` and `steering/message` events rather than a projection of the queue itself. +The agent's inbox is the authoritative steering queue but is not observable from the TUI, so the badge is a live count reconstructed from successful TUI steering submissions and `steering/message` events rather than a projection of the queue itself. - The running status line composes through `formatTurnStatus`, which inserts a `${queued} queued · ` badge before the `Enter sends steering, Esc cancels` hint when `queued > 0` and shows the plain hint at zero; the phase label and elapsed timing before it are the [verbose status line](2026-07-21-tui-verbose-status-line.md)'s. -- `createTuiChat` owns a `pendingSteering` counter: `+1` on each `agent/queued` for this agent whose `info.steering` is set, `-1` (floored at zero) on each `steering/message` session event as the loop drains one, and reset to zero whenever the agent leaves `running`. +- `createTuiChat` records each successful running-state `agent.steer()` submission, removes its matching source on each `steering/message` session event as the loop drains one, and resets the list whenever the agent leaves `running`. - The count refreshes onto the live `Loader` through `setMessage`; the refresh is a no-op while idle because the loader exists only during a running turn. - The reset lives in the `agent/status` transition, not in `setStatus`, because `setStatus` also runs on mid-turn palette changes and must not clear a live count. @@ -23,7 +23,7 @@ The agent's inbox is the authoritative steering queue but is not observable from **Reset inside `setStatus`.** Rejected: `setStatus` re-runs on `applyColorScheme` mid-turn, which would wrongly zero a live count; the status transition is the only place a turn actually ends. -**Drop the decrement clamp.** Rejected: loop-authored steering (e.g. continuation reasons) logs `steering/message` with no matching user-queued increment, which would drive the count negative; the zero floor keeps the badge a lower bound rather than a lie. +**Count every public inbox enqueue.** Rejected: `AgentMessage` intentionally omits driver routing state, so an observer cannot distinguish queued turns from steering. The TUI instead owns the submissions represented by its badge. **Make the wording or a threshold configurable.** Rejected: the no-hardcoded-tunables rule targets deployment-varying behavior, not brand copy; the `welcome`/hint strings are already fixed presentation. @@ -31,9 +31,9 @@ The agent's inbox is the authoritative steering queue but is not observable from - The badge is best-effort live UI state, not a logged surface: it is rebuilt from events and reset each turn, never persisted, so a resumed running turn starts its badge from zero. - A cancellation mid-queue clears the badge cleanly through the non-running reset, and a drain past zero is a no-op — neither can strand a stale count. -- A loop continuation that keeps the agent `running` while re-enqueuing undrained late steering can transiently over-count until the next idle reset; the badge is advisory, so the window is acceptable. +- Steering submitted outside this TUI is absent from the badge; the count describes feedback for this editor's submissions rather than the agent's complete inbox. - `packages/ui/tui/src/index.ts` stays at 100 % per-file coverage. ## Testing -`packages/ui/tui/tests/tui.spec.ts` drives the running status frame through the real `createTuiChat`: the plain hint at zero, a foreign-agent queue ignored, the increment to `2 queued`, a non-steering queue left untouched, the decrement as each message drains, the clamp on a drain past zero, and the reset when the turn ends. Verified live in tmux — the badge showed `3 queued` after three `agent.steer()` calls, then `1 queued` as two drained. +`packages/ui/tui/tests/tui.spec.ts` drives the running status frame through the real `createTuiChat`: the plain hint at zero, the increment to `2 queued` after editor submissions, the decrement as each message drains, an unrelated drain ignored, and the reset when the turn ends. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.zh.md index 4bfce461e1..6ddb5351e9 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.zh.md @@ -10,10 +10,10 @@ Status: implemented ## Decision -agent(智能体)的收件箱(inbox)才是权威的 steering 队列,但 TUI 无法观测它,因此徽标是从公开的 `agent/queued` 与 `steering/message` 事件重建出的实时计数,而非对队列本身的投影。 +agent(智能体)的收件箱(inbox)才是权威的 steering 队列,但 TUI 无法观测它,因此徽标是根据 TUI 成功提交的 steering 和 `steering/message` 事件重建出的实时计数,而非对队列本身的投影。 - 运行时的状态行经 `formatTurnStatus` 组装:`queued > 0` 时在 `Enter sends steering, Esc cancels` 提示前插入 `${queued} queued · ` 徽标,为零时是纯提示文本;其前的阶段标签与耗时归[详细状态行](2026-07-21-tui-verbose-status-line.md)所有。 -- `createTuiChat` 持有一个 `pendingSteering` 计数器:每收到一个针对本 agent 且 `info.steering` 为真的 `agent/queued` 就 `+1`,agent loop(智能体循环)每排空一条时随对应的 `steering/message` 会话事件 `-1`(下限为零),agent 一旦离开 `running` 状态即重置为零。 +- `createTuiChat` 记录每一次在 `running` 状态下成功的 `agent.steer()` 提交;agent loop(智能体循环)每排空一条并发出 `steering/message` 会话事件时,就按来源移除匹配项;agent 离开 `running` 时则重置整个列表。 - 计数通过 `setMessage` 刷新到实时的 `Loader` 上;空闲时刷新是空操作,因为 loader 只在运行中的轮次期间存在。 - 重置放在 `agent/status` 状态切换里,而非 `setStatus` 中,因为 `setStatus` 在轮次中途的颜色方案变化时也会运行,绝不能清掉一个实时计数。 @@ -23,7 +23,7 @@ agent(智能体)的收件箱(inbox)才是权威的 steering 队列,但 **在 `setStatus` 内重置。** 否决:`setStatus` 会在轮次中途的 `applyColorScheme` 时重新运行,会错误地把实时计数清零;状态切换才是轮次真正结束的唯一位置。 -**去掉递减的下限钳制。** 否决:agent loop 自行产生的 steering(如 continuation 续跑原因)会记录 `steering/message`,却没有对应的用户入队递增,这会把计数压到负数;零下限让徽标成为下界,而非谎报。 +**统计每一次公开的 inbox 入队。** 否决:`AgentMessage` 刻意省略驱动器路由状态,因此观察方无法区分排队轮次与 steering。TUI 转而自行维护徽标所代表的那些提交。 **把措辞或某个阈值做成配置。** 否决:「插件里不许硬编码可调参数」规则针对的是随部署变化的行为,不是品牌文案;`welcome`/提示字符串本就是固定的展示文案。 @@ -31,9 +31,9 @@ agent(智能体)的收件箱(inbox)才是权威的 steering 队列,但 - 徽标是尽力而为的实时 UI 状态,不写入日志:它由事件重建、每轮重置、从不持久化,因此恢复(resume)出的运行中轮次徽标从零开始。 - 队列中途取消会经由「离开运行态即重置」干净地清掉徽标,排空到零以下则是空操作——两者都不会残留一个陈旧计数。 -- 如果 agent loop 续跑时让 agent 保持 `running`、同时把未排空的迟到 steering 重新入队,则可能短暂多计,直到下一次空闲重置;徽标只作参考,因此这个窗口可以接受。 +- 通过此 TUI 以外的入口提交的 steering 不会出现在徽标中;该计数反馈的是此编辑器的提交,而不是 agent 的完整 inbox。 - `packages/ui/tui/src/index.ts` 保持 100% 的单文件覆盖率。 ## Testing -`packages/ui/tui/tests/tui.spec.ts` 通过真实的 `createTuiChat` 驱动运行时状态帧:为零时的纯提示、忽略他方 agent 的入队、递增到 `2 queued`、非 steering 的入队保持不变、每条消息排空时的递减、排空到零以下时的钳制、以及轮次结束时的重置。已在 tmux 中实机验证——三次 `agent.steer()` 调用后徽标显示 `3 queued`,随后两条排空时显示 `1 queued`。 +`packages/ui/tui/tests/tui.spec.ts` 通过真实的 `createTuiChat` 驱动运行时状态帧:为零时显示纯提示,编辑器提交后递增到 `2 queued`,每条消息排空时递减,忽略无关的排空,并在轮次结束时重置。 diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 2faf718836..3c69bd5289 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: 9d0df9f5b8f1b58366f3f54f1627cdcedc7bda57 -core.zh.md: 33c4267f7e2e40f1c48b3d6f07563c8db6817213 +core.md: 49cb29339e4cab922a8ce1bba50d25ca9d94dab3 +core.zh.md: 45aaf95dbacf86efd13792d886979d88cc1f7730 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 9d0df9f5b8..49cb29339e 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -426,20 +426,13 @@ The `agent/inbox/*` live events carry one accepted message; injection bypasses t * 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. + * applied, so these are the exact values the item was accepted with. */ interface AgentMessage { /** The id `send` returned for this message. */ id: AgentMessageId content: ContentBlock[] source: MessageSource - contexts: HookContext[] - /** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */ - steering: boolean - /** Whether the item is marked to wake the driver or force a continuation. */ - wakeup: boolean } ``` diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 33c4267f7e..45aaf95dba 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -428,20 +428,13 @@ type AgentMessageId = Branded<'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. + * applied, so these are the exact values the item was accepted with. */ interface AgentMessage { /** The id `send` returned for this message. */ id: AgentMessageId content: ContentBlock[] source: MessageSource - contexts: HookContext[] - /** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */ - steering: boolean - /** Whether the item is marked to wake the driver or force a continuation. */ - wakeup: boolean } ``` diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 04f882a323..03b07e8ead 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -12,7 +12,6 @@ import { Agent, AgentMessageId, agentCarrier, agentInterruptReasonOf, assembleCo import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import type { - AgentMessage, AgentMessageId as AgentMessageIdType, CancelOptions, AgentInterruptReason, @@ -48,20 +47,6 @@ interface OutboxItem extends PromptMessageData { steering?: PendingMessage } -/** Build one live inbox event payload from a pending message. */ -function inboxMessage(message: PendingMessage, steering: boolean): AgentMessage { - return { - id: message.id, - content: message.content, - source: message.source, - steering, - wakeup: message.wakeup, - } -} - -/** Stable runtime-only reason used when lifecycle teardown interrupts a turn. */ -export const DISPOSED_INTERRUPT_REASON = Object.freeze({ kind: 'disposed' } as const) - /** Normalize thrown values while preserving an existing error code. */ function toError(error: unknown): Error & { code?: string } { return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error }) @@ -69,12 +54,7 @@ function toError(error: unknown): Error & { code?: string } { /** Rebuild the live {@link LlmError} for serializable provider facts; `cause` keeps the foreign original. */ function llmError(facts: LlmFailure, cause?: Error): LlmError { - return new LlmError(facts.message, facts.code, { - ...facts.status === undefined ? {} : { status: facts.status }, - ...facts.providerRetryAfterMs === undefined ? {} : { providerRetryAfterMs: facts.providerRetryAfterMs }, - ...facts.requestId === undefined ? {} : { requestId: facts.requestId }, - ...cause === undefined ? {} : { cause }, - }) + return new LlmError(facts.message, facts.code, { ...facts, cause }) } function withoutToolCalls(message: Message): Message { @@ -154,7 +134,7 @@ export class ReactLoopAgent extends Agent { } else { this.queued.push(message) } - emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', inboxMessage(message, steering)) + emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', message) if (!steering && wakeup) this.kick() return id } @@ -174,8 +154,8 @@ export class ReactLoopAgent extends Agent { } if (!options.keepInbox) { const discarded = [ - ...this.queued.map(message => inboxMessage(message, false)), - ...this.outbox.flatMap(item => item.steering === undefined ? [] : [inboxMessage(item.steering, true)]), + ...this.queued, + ...this.outbox.map(item => item.steering).filter(steering => steering !== undefined), ] // Clear before abort observers run: replacement work belongs to the next turn. this.queued.length = 0 @@ -212,7 +192,7 @@ export class ReactLoopAgent extends Agent { const message = this.queued.shift() if (message === undefined) return - emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', inboxMessage(message, false)) + emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', message) const admission = new AbortController() this.abort = admission this.done = this.loopCtx.agents.withInitiator(this, async () => { @@ -472,7 +452,7 @@ export class ReactLoopAgent extends Agent { continue } steered = true - emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', inboxMessage(message, true)) + emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', message) this.session.append('steering/message', { turn, ...data }, { surfaceOp: 'append' }) } return steered diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 79659bbec7..c67245b238 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -24,7 +24,7 @@ import type { Session, SessionHeader } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import { DISPOSED_INTERRUPT_REASON, ReactLoopAgent } from './agent.ts' +import { ReactLoopAgent } from './agent.ts' import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts' /** Fiber states that cannot own or serve a new lifecycle. */ @@ -362,7 +362,7 @@ export class AgentLoop extends Service implements AgentFactory { // sent after this point is the sender's bug — the registries are about // to drop the agent, so nothing should still hold it. if (machine !== undefined) { - machine.cancel(DISPOSED_INTERRUPT_REASON) + machine.cancel({ kind: 'disposed' }) await Promise.allSettled([machine.done]) await machine.scope.dispose() } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 1e2ad1b4a3..3617f65c17 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -2,23 +2,6 @@ * Public agent types and live-runtime events. Durable transcript facts and * turn/step boundaries remain `@deepseek-ai/dsh-session` events. * - * The agent is a naive message machine over the session log: prompts queue - * (one turn each), steering/context ride the outbox (taken whole at every - * step boundary), and the log re-derives the request history each step — so - * "edit history between steps" needs no dedicated seam. The extension surface - * is deliberately small: - * - * - `agent/prompt-submit` (waterfall): veto/rewrite a claimed prompt. - * - `agent/request` (waterfall): replace the call config per request. - * - `agent/step` (serial): awaited before every request is built — inject - * context, steer, or edit the log here; the request derives after it. - * - `agent/stopping` (serial): the turn is about to close — steer to object. - * - a tool result carrying `concludesTurn` ends the turn at its step (data, - * not a hook): the terminal-tool pattern. - * - `agent/idle` (emit): one per turn close, carrying why it ended. Error - * recovery is a consumer loop: observe an error idle, fix (edit the log, - * wait out a rate limit), then `agent.retry()`. - * * @module @deepseek-ai/dsh-agent/types */ @@ -100,19 +83,13 @@ export function AgentMessageId(id: string): 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. + * applied, so these are the exact values the item was accepted with. */ export interface AgentMessage { /** The id `send` returned for this message. */ id: AgentMessageId content: ContentBlock[] source: MessageSource - /** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */ - steering: boolean - /** Whether the item is marked to wake the driver or force a continuation. */ - wakeup: boolean } /** Options for {@link Agent.cancel}. */ @@ -319,7 +296,7 @@ declare module 'cordis' { /** * A frozen item entered the queued or steering inbox. * @param agent - the owning agent. - * @param message - accepted routing data and correlation identity. + * @param message - accepted content, source, and correlation identity. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ diff --git a/packages/core/agent/tests/invariant.spec.ts b/packages/core/agent/tests/invariant.spec.ts index 7766431c3d..4c9afbb376 100644 --- a/packages/core/agent/tests/invariant.spec.ts +++ b/packages/core/agent/tests/invariant.spec.ts @@ -58,24 +58,24 @@ describe('agent status invariants', () => { }) describe('agent inbox invariants', () => { - const info = (steering: boolean) => ({ id: AgentMessageId('m'), content: [], source: { kind: 'user' as const }, steering, wakeup: true }) + const info = () => ({ id: AgentMessageId('m'), content: [], source: { kind: 'user' as const } }) 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)]) + ctx.emit(at, 'agent/inbox/enqueue', agent, info()) + ctx.emit(at, 'agent/inbox/enqueue', agent, info()) + ctx.emit(at, 'agent/inbox/dequeue', agent, info()) + ctx.emit(at, 'agent/inbox/discard', agent, [info()]) }).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)) }) + expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/dequeue', agent, info()) }) .toThrow(/without a matching prior enqueue/) }) @@ -83,8 +83,8 @@ describe('agent inbox invariants', () => { 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)]) }) + ctx.emit(at, 'agent/inbox/enqueue', agent, info()) + expect(() => { ctx.emit(at, 'agent/inbox/discard', agent, [info(), info()]) }) .toThrow(/dropped 2 items but only 1 were outstanding/) }) diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index 339857f26a..8f29a98305 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -42,8 +42,8 @@ describe('scoped-dispatch invariants', () => { 'agent/created': [agent], 'agent/disposed': [agent], 'agent/status': [agent, 'idle'], - 'agent/inbox/enqueue': [agent, { id: AgentMessageId('m'), content: [], source: { kind: 'user' }, steering: false, wakeup: true }], - 'agent/inbox/dequeue': [agent, { id: AgentMessageId('m'), content: [], source: { kind: 'user' }, steering: false, wakeup: true }], + 'agent/inbox/enqueue': [agent, { id: AgentMessageId('m'), content: [], source: { kind: 'user' } }], + 'agent/inbox/dequeue': [agent, { id: AgentMessageId('m'), content: [], source: { kind: 'user' } }], 'agent/inbox/discard': [agent, []], 'agent/cancel-requested': [agent, { kind: 'user' }], 'agent/session-start': [agent, 'startup'], diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 030ff8bec7..1dfccc7ccc 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1592,15 +1592,10 @@ export function createTuiChat( let toolsExpanded = false let streaming: StreamingAssistantComponent | undefined let runningStatus: RunningStatus | undefined - // 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. + // TUI steering submissions that the loop has not yet drained, shown as a + // badge on the status line. Each entry is the submitted message's serialized + // source, so an unrelated steering/message cannot consume its slot. Leaving + // `running` clears entries discarded by cancellation. const pendingSteering: string[] = [] let disposed = false let shuttingDown: Promise | undefined @@ -2505,7 +2500,10 @@ export function createTuiChat( if (disposed) { appendNotice(`Agent "${agent.id}" is disposed.`, 'error') } else if (agent.status === 'running') { - agent.steer(content, { source: { kind: 'user' } }) + const source = { kind: 'user' } as const + agent.steer(content, { source }) + pendingSteering.push(JSON.stringify(source)) + refreshStatus() } else { agent.followup(content, { source: { kind: 'user' } }) } @@ -2751,11 +2749,6 @@ export function createTuiChat( renderEvent(event, { addHistory: false, renderChunks: true }) requestRender() }) - const disposeQueued = ctx.on('agent/inbox/enqueue', (subject, info) => { - if (subject !== agent || !info.steering) return - pendingSteering.push(JSON.stringify(info.source)) - refreshStatus() - }) const disposeStatus = ctx.on('agent/status', (subject, status) => { if (subject !== agent) return // Leaving 'running' ends the turn's status line; clear any badge so the @@ -2784,7 +2777,6 @@ export function createTuiChat( disposeCommandChanges() stopBannerReveal() disposeSessionEvents() - disposeQueued() disposeStatus() disposeError() disposeAgent() diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index b57a5a764d..18288cecfb 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -574,8 +574,9 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels') expect(result.terminal.output).not.toContain('queued') - const queueSteering = (text: string): void => { - result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text }], source: { kind: 'user' }, steering: true, wakeup: true }) + const submitSteering = (text: string): void => { + result.terminal.send(text) + result.terminal.send('\r') } const drainSteering = (text: string): void => { result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' }) @@ -584,20 +585,19 @@ 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, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' }, steering: true, wakeup: true }) + result.ctx.emit('agent/inbox/enqueue', other, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' } }) await tick() expect(result.terminal.output).not.toContain('queued') // Two steering messages queue while the turn runs. - queueSteering('first') + submitSteering('first') result.terminal.output = '' - queueSteering('second') + submitSteering('second') await tick() expect(result.terminal.output).toContain('2 queued · Enter sends steering, Esc cancels') - // A non-steering queue (an idle-style send) leaves the badge untouched. + // Draining one submitted message decrements the badge. result.terminal.output = '' - result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'sent' }], source: { kind: 'user' }, steering: false, wakeup: true }) drainSteering('first') await tick() expect(result.terminal.output).toContain('1 queued') @@ -613,7 +613,7 @@ describe('pi-tui chat lifecycle and transcript', () => { // A drain with no matching queued entry is ignored rather than underflowing. result.terminal.output = '' drainSteering('continuation') - queueSteering('after') + submitSteering('after') await tick() expect(result.terminal.output).toContain('1 queued') @@ -649,9 +649,8 @@ describe('pi-tui chat lifecycle and transcript', () => { it('derives the fine-grained turn phase from session lifecycle events', async () => { // A live event before the turn runs has no status controller to move. 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, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'early' }], source: { kind: 'user' }, steering: true, wakeup: true }) + // Inbox notifications do not affect the status phase while idle. + idle.ctx.emit('agent/inbox/enqueue', idle.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'early' }], source: { kind: 'user' } }) 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')