Merge pull request #2672 from deepseek-harness/xtr/durable-inbox-recovery

refactor(agent): keep projection-backed Inbox loop-internal
This commit is contained in:
_Kerman
2026-09-07 21:24:32 +08:00
committed by GitHub
159 changed files with 2056 additions and 1042 deletions
@@ -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 .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md
2026-07-31-claimed-pre-step-inbox-lifecycle.md: 73768e1eee8957f8976d40812b0a31a2961f0825
2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 343816abaf394b8f64924cf36b753c6b1b2e34ca
2026-07-31-claimed-pre-step-inbox-lifecycle.md: 737e3835263a3215a0fd2e52dad4ee05402bd888
2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: ecb731df663e0d48b374a3118d7db7f6a34bfc18
@@ -12,17 +12,19 @@ Occurrence-local inbox wrappers also duplicated the identity already carried by
## Decision
Before every proposed step, `Inbox.claim(target)` atomically removes the complete batch: all `next-step` messages and, at a turn boundary, one `next-turn` message. At the initial boundary the loop first commits `turn/start`, so the claim and its single `agent/pre-step` decision have durable turn ownership. Claiming records normalized `agent/inbox/spliced` pure deletions with no outcome. The loop then emits `agent/inbox/claimed { message, turn }` once per claimed message and awaits the waterfall with that exclusive batch and `{ turn, step, signal }`.
Before every proposed step, the loop's package-internal `ReactLoopInbox` atomically claims the complete batch: all `next-step` messages and, at a turn boundary, one `next-turn` message. At the initial boundary the loop first commits `turn/start`, so the claim and its single `agent/pre-step` decision have durable turn ownership. Claiming records normalized `agent/inbox/spliced` pure deletions with no outcome, emits `agent/inbox/claimed { message, turn }` once per claimed message, and returns the exclusive batch for the loop's waterfall with `{ turn, step, signal }`.
`PreStepDecision` is `{ kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }`. Reject opens no step, leaves the claimed batch removed, and closes the turn as blocked without any step events. Empty entry, cancellation, and failure before `step/start` likewise close a balanced no-step turn. Enter supplies the complete batch appended as `user/message` events after `step/start`. A listener wrapping `next()` preserves downstream changes unless it intentionally replaces them, so all message rewrites settle once in the final return value. There is no `agent/prompt-prepare`, `agent/prompt-submit`, or `agent/step` extension point.
The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming is the loop's internal step-boundary operation on the inbox and records pure deletions without notifications or an outcome, so the loop can publish claimed events itself. These live events add no placement, outcome, or batch fields.
The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming records pure deletions without an outcome and emits claimed events from `ReactLoopInbox`. These live events add no placement, outcome, or batch fields.
The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. Whole-queue consumers, including the Web queue projection and reconnect baseline, use the durable `agent/inbox/spliced` stream; UI edits and removals route through `Inbox.splice()` or another Inbox mutation method so the same projection records every change.
`Agent.inbox` exposes only the structural `Inbox` interface for reading and mutating pending work; loop-only `hasPending` and claim operations are absent from that public face. dsh-agent-loop constructs one `ReactLoopInbox` and uses it for both structural commands and driver operations. The concrete constructor receives `SessionProjectionRegistry` directly instead of the wider Cordis `Context` and registers the standard definition on the agent scope before its first read. `AgentLoop` requires the registry service at activation, and the registry reference-counts the definition across live agent scopes.
The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. Each `ReactLoopInbox` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream from its agent scope; UI edits and removals route through an Inbox mutation method so the same projection records every change. When that projection reconstructs durable history, it rejects unsafe or out-of-range coordinates and duplicate `MessageId` values across both lists, and reports the offending event seq. Whole-queue control consumers use the projection change feed: the Session controller publishes the projection frame, then derives the queue replacement from the same post-fold inbox value.
Plugins that need current-step atomic rewriting return messages from `agent/pre-step`. Plugins that only need later context may mutate `agent.inbox` directly. Workspace context uses both paths: asynchronous filesystem projections stage one replaceable `next-step` item, while the next entering pre-step folds that item or a newly composed baseline into its final batch and removes the pending copy. Rejection keeps the item queued.
The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` now owns addressability, while the retained Host queue mirror derives its snapshots from the durable splice projection.
The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` owns addressability, while `ReactLoopInbox` contributes `inbox` as the standard session projection over durable splices. The generic projection carrier serves that fold for live updates, history-tail reconnect baselines, and cold process-restart recovery without a live Agent mirror.
## Alternatives considered
@@ -34,7 +36,7 @@ The archived [addressable queue occurrence decision](../../archived/feature/2026
## Verification
Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, and cancellation. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, and resumed durable projection. Generated event and type catalogs expose only the new waterfall and payloads.
Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, cancellation, and agent-scope projection removal after the last owner unloads. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, resumed durable projection, rejection of invalid persisted coordinates or cross-list identities, and post-fold queue replacement when the controller registers before the projection registry. Consumer-domain tests use a process-local Inbox stub only when durability is outside the test subject; claiming, durable projection, recovery, validation, and live-notification tests create Agents through the production AgentLoop test harness, so test support never reimplements the projection. Generated event and type catalogs expose only the new waterfall and payloads.
## Consequences
@@ -12,17 +12,19 @@ Status: implemented
## 决策
每个拟议步骤之前,`Inbox.claim(target)` 会原子移除完整批次:全部 `next-step` 消息,以及轮次边界上的一条 `next-turn` 消息。在首次边界,循环会先提交 `turn/start`,使领取及其唯一一次 `agent/pre-step` 决策拥有持久轮次归属。领取会记录规范化、不带 outcome 的纯删除 `agent/inbox/spliced`。随后,循环针对每条已领取消息发出一次 `agent/inbox/claimed { message, turn }`,并用该独占批次 `{ turn, step, signal }` 等待 waterfall(瀑布式事件)。
每个拟议步骤之前,循环包内部的 `ReactLoopInbox` 会原子领取完整批次:全部 `next-step` 消息,以及轮次边界上的一条 `next-turn` 消息。在首次边界,循环会先提交 `turn/start`,使领取及其唯一一次 `agent/pre-step` 决策拥有持久轮次归属。领取会记录规范化、不带 outcome 的纯删除 `agent/inbox/spliced`针对每条已领取消息发出一次 `agent/inbox/claimed { message, turn }`,并独占批次返回给循环,由后者用 `{ turn, step, signal }` 等待 waterfall(瀑布式事件)。
`PreStepDecision``{ kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }`。reject 不会打开步骤,会让已领取批次保持已删除,并将轮次关闭为 blocked,且不产生任何步骤事件。空的 enter、取消以及 `step/start` 前的失败同样会关闭一个边界平衡的无步骤轮次。enter 提供在 `step/start` 后以 `user/message` 追加的完整批次。包装 `next()` 的监听器会保留下游变更,除非有意替换,因此全部消息改写只在最终返回值中一次性结算。系统不再存在 `agent/prompt-prepare``agent/prompt-submit``agent/step` 扩展点。
持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append``prepend``splice` 接受 target`replace(messageId, newMessage)``remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取是循环在 inbox 上的内部步骤边界操作,记录不带通知或 outcome 的纯删除,因此循环可以自行发布 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。
持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append``prepend``splice` 接受 target`replace(messageId, newMessage)``remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取记录不带 outcome 的纯删除,并由 `ReactLoopInbox` 发出 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。
两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted``claimed``discarded`。包括 Web 队列投影和重连基线在内的整体队列消费方使用持久 `agent/inbox/spliced` 流;UI 编辑与移除通过 `Inbox.splice()` 或其他 Inbox 变更方法处理,从而让同一投影记录所有变化
`Agent.inbox` 只暴露用于读取和变更待处理工作的结构化 `Inbox` 接口;仅供循环使用的 `hasPending` 与领取操作不在该公开接口上。dsh-agent-loop 只构造一个 `ReactLoopInbox`,同时用于结构化命令与驱动器操作。具体构造函数直接接收 `SessionProjectionRegistry`,而不是更宽泛的 Cordis `Context`,并在首次读取前从 agent 作用域注册标准定义。`AgentLoop` 激活时要求该注册表服务存在,注册表则对多个 live agent 作用域贡献的定义进行引用计数
两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted``claimed``discarded`。每个 `ReactLoopInbox` 都从其 agent 作用域在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。该投影重建持久历史时,会拒绝不安全或越界的坐标,以及跨两份列表重复的 `MessageId`,并报告出错事件的 seq。整体队列的 control 消费方使用投影变更流:Session controller 先发布 projection frame,再从同一份折叠后的 inbox 值派生 queue replacement。
必须对当前步骤进行原子改写的插件从 `agent/pre-step` 返回消息。只需要稍后上下文的插件可以直接修改 `agent.inbox`。Workspace context 同时使用两条路径:异步文件系统投影会暂存一条可替换的 `next-step` 消息,而下一次进入步骤的 pre-step 会把该消息或新组合的基线折入最终批次,并移除仍待处理的副本。reject 会让该条目继续排队。
已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现包装层设计。现在由 `MessageId` 负责寻址,而保留的 Host 队列镜像根据持久 splice 投影派生快照
已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现包装层设计。`MessageId` 负责寻址,而 `ReactLoopInbox``inbox` 作为持久 splice 上的标准会话投影贡献给投影注册表。通用投影传输层会将该折叠结果用于实时更新、历史尾页的重连基线和冷进程重启恢复,无需 live Agent 镜像
## 曾考虑的替代方案
@@ -34,7 +36,7 @@ Status: implemented
## 验证
agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败取消。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点以及恢复后的持久投影。生成的事件与类型目录只公开新的 waterfall 与载荷。
agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败取消,以及最后一个所有者卸载后移除 agent 作用域投影。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点恢复后的持久投影、对非法持久坐标或跨列表重复标识的拒绝,以及 controller 早于投影注册表注册时仍使用折叠后队列值。只有当持久性不属于测试对象时,消费方领域测试才使用进程内 Inbox 桩;领取、持久投影、恢复、校验与实时通知测试通过生产 AgentLoop 测试 harness 创建 Agent,因此测试支持代码不会重新实现该投影。生成的事件与类型目录只公开新的 waterfall 与载荷。
## 后果
+2
View File
@@ -104,6 +104,8 @@
"@agentclientprotocol/sdk": "1.4.0",
"@deepseek-ai/dsh-acp": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-attachment-local": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-credentials-local": "workspace:^",
+3 -2
View File
@@ -1,10 +1,11 @@
import { fileURLToPath } from 'node:url'
import { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
import { SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-skill'
import type {} from '@deepseek-ai/dsh-tools'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
const overlayPath = process.argv[2]
if (overlayPath === undefined) throw new Error('dsh-badge snapshot requires an overlay path')
@@ -23,7 +24,7 @@ try {
id: agentId,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
inbox: unsupportedInbox(),
status: 'idle',
send: () => {},
followup: () => {},
@@ -2,7 +2,6 @@ import { Context } from '@deepseek-ai/cordis'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env'
@@ -56,7 +55,6 @@ export interface CodingHarnessOptions {
export async function codingHarness(workdir: string, options: CodingHarnessOptions = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
await mountAgentLoopTestDependencies(ctx, {
systemPrompt: { personaPrefix: options.personaPrefix ?? '' },
})
@@ -10,7 +10,6 @@ import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts'
import { PARENT_ID, response, resultText, syntheticHistory, TIME_ZERO, WORKLOAD } from './workload.ts'
@@ -82,7 +81,6 @@ async function measure(root: string, scenario: string): Promise<ContinuationRepo
const adapter = new SyntheticAdapter(toolHeavy ? WORKLOAD.toolsPerLiveTurn : 0)
let toolCalls = 0
try {
await ctx.plugin(SessionProjectionRegistry)
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' })
await ctx.plugin(AgentLoop, { agents: [] })
@@ -159,10 +159,12 @@ class SessionBenchmarkHost {
static async create(root: string, scenario: SessionOpenBenchmarkScenario): Promise<SessionBenchmarkHost> {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
const agentScenario = scenario === 'agent-resume'
if (agentScenario) await mountAgentLoopTestDependencies(ctx)
else await ctx.plugin(SessionStore)
else {
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SessionStore)
}
await installProjectionSet(ctx, agentScenario)
await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' })
let history: SessionHistoryController | undefined
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
event-producer-consumer.md: c352fe57795ee3c0c8f8a4adef0b06ff109dec76
event-producer-consumer.zh.md: 5a091d09ca9ba8ea9ee3274096b1cca181768cfe
event-producer-consumer.md: edf5ea33526afb46e941314852dea653105fb748
event-producer-consumer.zh.md: f7d96a94eef611817e9a9c0c2dcc93d62ea3a178
+13 -13
View File
@@ -9,19 +9,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:315`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:213`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:345`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:242`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:250`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:276`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:289`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:305`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:262`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:369`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:296`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:304`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:343`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:359`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:387`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+17 -17
View File
@@ -11,19 +11,19 @@
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:315`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:213`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:345`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:242`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:250`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:276`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:289`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:305`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:262`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:369`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:296`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:304`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:343`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:359`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:387`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
@@ -56,10 +56,10 @@
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:172`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:146`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:152`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:163`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:168`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:159`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/module-graph.md
module-graph.md: 0951780b0bf53b35f75ef13c50a3875138d756cc
module-graph.zh.md: 2a073370bd4de913f3a1293fcbebb9b9e25954f4
module-graph.md: 2c31c50117cc6caabccdcb8fdf6afc9a160fe4dd
module-graph.zh.md: d41df4241163ca9b17e47c62051a891e45e131e0
+10 -7
View File
@@ -457,6 +457,7 @@ flowchart TD
pkg_agent --> pkg_session_projection
pkg_agent --> pkg_system_prompt
pkg_agent --> pkg_typert_protocol
pkg_agent --> pkg_util_values
pkg_fs --> pkg_brand
pkg_fs --> pkg_invariants
pkg_fs --> pkg_llm
@@ -868,11 +869,6 @@ flowchart TD
pkg_tool_terminal --> pkg_system_prompt
pkg_tool_terminal --> pkg_terminal
pkg_tool_terminal --> pkg_tools
pkg_agent_loop_testkit --> pkg_agent
pkg_agent_loop_testkit --> pkg_llm
pkg_agent_loop_testkit --> pkg_session
pkg_agent_loop_testkit --> pkg_system_prompt
pkg_agent_loop_testkit --> pkg_tools
pkg_llm_replay --> pkg_compaction
pkg_llm_replay --> pkg_deepseek_llm_api_extensions
pkg_llm_replay --> pkg_llm
@@ -946,6 +942,13 @@ flowchart TD
pkg_tool_pwsh --> pkg_system_prompt
pkg_tool_pwsh --> pkg_tools
pkg_tool_pwsh --> pkg_user_approval
pkg_agent_loop_testkit --> pkg_agent
pkg_agent_loop_testkit --> pkg_agent_loop
pkg_agent_loop_testkit --> pkg_llm
pkg_agent_loop_testkit --> pkg_session
pkg_agent_loop_testkit --> pkg_session_projection
pkg_agent_loop_testkit --> pkg_system_prompt
pkg_agent_loop_testkit --> pkg_tools
pkg_webhook --> pkg_agent
pkg_webhook --> pkg_agent_default_model
pkg_webhook --> pkg_agent_presets
@@ -1290,7 +1293,7 @@ flowchart TD
| [`session-projection`](../packages/session/session-projection) | `session` | [`session`](../packages/core/session) |
| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`session`](../packages/core/session) |
| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
@@ -1377,7 +1380,6 @@ flowchart TD
| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) |
| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) |
@@ -1390,6 +1392,7 @@ flowchart TD
| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) |
| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
+10 -7
View File
@@ -459,6 +459,7 @@ flowchart TD
pkg_agent --> pkg_session_projection
pkg_agent --> pkg_system_prompt
pkg_agent --> pkg_typert_protocol
pkg_agent --> pkg_util_values
pkg_fs --> pkg_brand
pkg_fs --> pkg_invariants
pkg_fs --> pkg_llm
@@ -870,11 +871,6 @@ flowchart TD
pkg_tool_terminal --> pkg_system_prompt
pkg_tool_terminal --> pkg_terminal
pkg_tool_terminal --> pkg_tools
pkg_agent_loop_testkit --> pkg_agent
pkg_agent_loop_testkit --> pkg_llm
pkg_agent_loop_testkit --> pkg_session
pkg_agent_loop_testkit --> pkg_system_prompt
pkg_agent_loop_testkit --> pkg_tools
pkg_llm_replay --> pkg_compaction
pkg_llm_replay --> pkg_deepseek_llm_api_extensions
pkg_llm_replay --> pkg_llm
@@ -948,6 +944,13 @@ flowchart TD
pkg_tool_pwsh --> pkg_system_prompt
pkg_tool_pwsh --> pkg_tools
pkg_tool_pwsh --> pkg_user_approval
pkg_agent_loop_testkit --> pkg_agent
pkg_agent_loop_testkit --> pkg_agent_loop
pkg_agent_loop_testkit --> pkg_llm
pkg_agent_loop_testkit --> pkg_session
pkg_agent_loop_testkit --> pkg_session_projection
pkg_agent_loop_testkit --> pkg_system_prompt
pkg_agent_loop_testkit --> pkg_tools
pkg_webhook --> pkg_agent
pkg_webhook --> pkg_agent_default_model
pkg_webhook --> pkg_agent_presets
@@ -1292,7 +1295,7 @@ flowchart TD
| [`session-projection`](../packages/session/session-projection) | `session` | [`session`](../packages/core/session) |
| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`session`](../packages/core/session) |
| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
@@ -1379,7 +1382,6 @@ flowchart TD
| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) |
| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) |
@@ -1392,6 +1394,7 @@ flowchart TD
| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) |
| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/persistence-catalog.md
persistence-catalog.md: c47cd8a09d4c7a5179bb36d9c62611564b95a5cc
persistence-catalog.zh.md: c67d315c91565cee5855ee5b0667672151b83323
persistence-catalog.md: 8d6f66dc949b6d41530eb387534689dc66f1e99f
persistence-catalog.zh.md: 06d07ceab984ad20ee6637227d54fafbbb58bdd4
+3 -3
View File
@@ -101,8 +101,8 @@ Sources: [`packages/core/session/src/types.ts:385`](../packages/core/session/src
```ts persistence-catalog
/**
* One normalized mutation of an agent's durable pending-message lists.
* Live dispatch precedes projection mutation, so synchronous observers may
* read the pre-splice inbox to recover the removed messages.
* The session-projection registry applies the committed event before
* `Session.append()` returns; Inbox live notifications follow that commit.
*/
'agent/inbox/spliced': {
target: InboxTarget
@@ -113,7 +113,7 @@ Sources: [`packages/core/session/src/types.ts:385`](../packages/core/session/src
}
```
Source: [`packages/core/agent/src/types.ts:58`](../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:87`](../packages/core/agent/src/types.ts)
### `agent-preset/*`
+3 -3
View File
@@ -103,8 +103,8 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
```ts persistence-catalog
/**
* One normalized mutation of an agent's durable pending-message lists.
* Live dispatch precedes projection mutation, so synchronous observers may
* read the pre-splice inbox to recover the removed messages.
* The session-projection registry applies the committed event before
* `Session.append()` returns; Inbox live notifications follow that commit.
*/
'agent/inbox/spliced': {
target: InboxTarget
@@ -115,7 +115,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
}
```
来源:[`packages/core/agent/src/types.ts:58`](../packages/core/agent/src/types.ts)
来源:[`packages/core/agent/src/types.ts:87`](../packages/core/agent/src/types.ts)
### `agent-preset/*`
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/core.md
core.md: 29e332d068857254e3cd27892adda36a9229a0d7
core.zh.md: c148b8f6a48d7f01b12f59b5fc14c655a1fb8daa
core.md: 4af3a22478f324655dea1b132f0c8a8528f4b883
core.zh.md: 7359e5aaa75e9f750d44f1386acab388665e1e1e
+59 -2
View File
@@ -65,7 +65,7 @@ interface Agent {
readonly options: AgentOptions
/** The live session this agent drives; its log is the durable source of truth. */
readonly session: Session
/** The agent-owned projection of durable pending work. */
/** Agent-owned access to durable pending work. */
readonly inbox: Inbox
/** The current lifecycle state, mirrored on every `agent/status` transition. */
readonly status: AgentStatus
@@ -210,12 +210,69 @@ Dispatch requires `provider` and `model` after `agent/request`. An explicit `rea
The inbox is the delivery vocabulary — two ordered pending-message lists the agent owns as a durable projection:
```ts type-equiv
/** Agent-owned access to pending work; concrete storage belongs to the driver. */
interface Inbox {
/** Prompts awaiting individual turns. */
readonly nextTurn: readonly UserMessage[]
/** Input awaiting the next step boundary. */
readonly nextStep: readonly UserMessage[]
/** Durably cancel all pending input, clearing next-step before next-turn. */
clear(): void
/**
* Append one message to a pending list.
* @param target - pending list to extend.
* @param message - message to append.
*/
append(target: InboxTarget, message: UserMessage): void
/**
* Prepend one message to a pending list.
* @param target - pending list to extend.
* @param message - message to prepend.
*/
prepend(target: InboxTarget, message: UserMessage): void
/**
* Replace one pending message in place.
* @param messageId - identity of the pending message to replace.
* @param newMessage - replacement message.
* @returns whether the message was still pending.
*/
replace(messageId: MessageId, newMessage: UserMessage): boolean
/**
* Remove one pending message.
* @param messageId - identity of the pending message to remove.
* @returns whether the message was still pending.
*/
remove(messageId: MessageId): boolean
/**
* Apply standard splice semantics and durably record the normalized result.
* @param target - pending list to mutate.
* @param start - splice position.
* @param deleteCount - maximum number of messages to remove.
* @param inserted - messages to insert at the resolved position.
* @returns messages removed by the splice.
*/
splice(
target: InboxTarget,
start: number,
deleteCount: number,
inserted: UserMessage[],
): UserMessage[]
}
```
```ts type-equiv
/** One of the two ordered pending-message lists owned by an agent. */
type InboxTarget = 'next-turn' | 'next-step'
```
Every pending occurrence is its `UserMessage`; `MessageId` is the sole identity. `Inbox.append`, `prepend`, `replace`, `remove`, `clear`, `splice`, and `claim` record normalized durable `agent/inbox/spliced` mutations and reject duplicate pending ids. `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists; replacement may change identity and emits the old message as discarded followed by the new message as inserted. Ordinary removals and `clear()` are cancellations. `claim(target)` removes the proposed step batch — all `next-step` input plus, at a turn boundary, one `next-turn` message — through pure deletion splices without emitting discarded notifications, and the loop separately emits per-message claimed notifications. Whole-queue consumers such as UI projections reconstruct `nextTurn` and `nextStep` from the durable splices, while consumers following one message use the exact `agent/inbox/inserted`, `claimed`, and `discarded` notifications.
Every pending occurrence is its `UserMessage`; `MessageId` is the sole identity. The structural `Inbox` methods record normalized durable `agent/inbox/spliced` mutations and reject duplicate pending ids. `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists; replacement may change identity and emits the old message as discarded followed by the new message as inserted. Ordinary removals and `clear()` are cancellations. At a step boundary, dsh-agent-loop's package-internal `ReactLoopInbox` removes the proposed batch — all `next-step` input plus, at a turn boundary, one `next-turn` message — through pure deletion splices without discarded notifications, then emits per-message claimed notifications. Loop-only pending detection and claiming are not part of `Agent.inbox`. Each `ReactLoopInbox` constructor contributes the standard `inbox` projection from its agent scope; the registry shares that definition across agents by reference count, and its cell is the sole live state while the same fold serves cold consumers. The fold rejects unsafe or out-of-range splice coordinates and duplicate identities across both lists, identifying malformed durable history by event seq. Consumers following one message use the exact `agent/inbox/inserted`, `claimed`, and `discarded` notifications.
Cancellation:
+59 -2
View File
@@ -69,7 +69,7 @@ interface Agent {
readonly options: AgentOptions
/** The live session this agent drives; its log is the durable source of truth. */
readonly session: Session
/** The agent-owned projection of durable pending work. */
/** Agent-owned access to durable pending work. */
readonly inbox: Inbox
/** The current lifecycle state, mirrored on every `agent/status` transition. */
readonly status: AgentStatus
@@ -214,12 +214,69 @@ interface AgentOptions {
inbox 即投递词汇——agent 以持久投影形式拥有的两条有序待处理消息列表:
```ts type-equiv
/** Agent-owned access to pending work; concrete storage belongs to the driver. */
interface Inbox {
/** Prompts awaiting individual turns. */
readonly nextTurn: readonly UserMessage[]
/** Input awaiting the next step boundary. */
readonly nextStep: readonly UserMessage[]
/** Durably cancel all pending input, clearing next-step before next-turn. */
clear(): void
/**
* Append one message to a pending list.
* @param target - pending list to extend.
* @param message - message to append.
*/
append(target: InboxTarget, message: UserMessage): void
/**
* Prepend one message to a pending list.
* @param target - pending list to extend.
* @param message - message to prepend.
*/
prepend(target: InboxTarget, message: UserMessage): void
/**
* Replace one pending message in place.
* @param messageId - identity of the pending message to replace.
* @param newMessage - replacement message.
* @returns whether the message was still pending.
*/
replace(messageId: MessageId, newMessage: UserMessage): boolean
/**
* Remove one pending message.
* @param messageId - identity of the pending message to remove.
* @returns whether the message was still pending.
*/
remove(messageId: MessageId): boolean
/**
* Apply standard splice semantics and durably record the normalized result.
* @param target - pending list to mutate.
* @param start - splice position.
* @param deleteCount - maximum number of messages to remove.
* @param inserted - messages to insert at the resolved position.
* @returns messages removed by the splice.
*/
splice(
target: InboxTarget,
start: number,
deleteCount: number,
inserted: UserMessage[],
): UserMessage[]
}
```
```ts type-equiv
/** One of the two ordered pending-message lists owned by an agent. */
type InboxTarget = 'next-turn' | 'next-step'
```
每个待处理入队项就是其 `UserMessage``MessageId` 是唯一标识。`Inbox.append`、`prepend`、`replace`、`remove`、`clear`、`splice` 与 `claim` 会记录规范化的持久 `agent/inbox/spliced` 变更,并拒绝重复的待处理 id。`replace(messageId, newMessage)` 与 `remove(messageId)` 通过 `MessageId` 跨两份列表定位待处理消息;替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。普通删除和 `clear()` 都表示取消。`claim(target)` 通过纯删除 splice 移除拟进入步骤的批次——全部 `next-step` 输入,外加轮次边界上的一条 `next-turn` 消息——且不发出 discarded 通知;循环另行逐条发出 claimed 通知。UI 投影等整体队列消费方通过持久 splice 重建 `nextTurn` 与 `nextStep`,而跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。
每个待处理入队项就是其 `UserMessage``MessageId` 是唯一标识。结构化 `Inbox` 方法会记录规范化的持久 `agent/inbox/spliced` 变更,并拒绝重复的待处理 id。`replace(messageId, newMessage)` 与 `remove(messageId)` 通过 `MessageId` 跨两份列表定位待处理消息;替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。普通删除和 `clear()` 都表示取消。在步骤边界,dsh-agent-loop 包内部的 `ReactLoopInbox` 通过纯删除 splice 移除拟进入步骤的批次——全部 `next-step` 输入,外加轮次边界上的一条 `next-turn` 消息——且不发出 discarded 通知,随后逐条发出 claimed 通知。仅供循环使用的待处理检测与领取操作不属于 `Agent.inbox`。每个 `ReactLoopInbox` 构造函数都从其 agent 作用域贡献标准 `inbox` 投影;注册表通过引用计数在多个 agent 之间共享该定义,其 cell 是唯一 live 状态,同一份折叠也服务于冷消费方。该 fold 会拒绝不安全或越界的 splice 坐标,以及跨两份列表重复的标识,并通过事件 seq 指出格式错误的持久历史。跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。
取消:
-5
View File
@@ -23,7 +23,6 @@ import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, St
import { type GenerateOptions, LlmAdapter, ReasoningEffortId, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
import * as AcpPlugin from '../src/index.ts'
@@ -232,10 +231,6 @@ export async function makeBridgeHarness(options: {
const ownsPersistenceRoot = options.persistenceRoot === undefined
const persistenceRoot = options.persistenceRoot ?? await mkdtemp(join(tmpdir(), 'dsh-acp-test-'))
await mountAgentLoopTestDependencies(ctx, { systemPrompt: { personaPrefix: options.persona ?? '' } })
// The agent loop and the composed approval/permission services declare
// sessionProjections a required injection: mount the registry (and with it
// the loop's turnBoundary unit) before the loop activates.
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(JsonlSessionPersistence, { root: persistenceRoot, compression: 'none' })
await ctx.plugin(TokenMeter)
if (options.attachments !== false) await ctx.plugin(MemoryAttachmentStore)
@@ -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 packages/api/session-controller/README.md
README.md: a2639ebd5fb648ed193d7c3575a649891ebf76b9
README.zh.md: 7bed20d3e47855b045bb838b354328f1769273e2
README.md: 6b56c4976b5ca66fbdbdb274bdf5bf8f38d52b1d
README.zh.md: a9c53d37371e3280c5bc0ae21a93a511036a810d
+1 -1
View File
@@ -27,7 +27,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent.
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. For each inbox change, the Host publishes the projection frame first and derives the queue replacement from that same validated post-fold value, so listener registration order cannot produce a stale queue frame.Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
+1 -1
View File
@@ -27,7 +27,7 @@ kind: "package-reference"
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace``prepend``append``settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`,即轮次跳转加载器按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq``nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered framereplacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message``assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。Client Agent context 提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace``prepend``append``settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq``nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered framereplacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message``assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 inbox 变更时,Host 会先发布 projection frame,再从同一份已校验的折叠后值派生 queue replacement,因此监听器注册顺序不会产生陈旧的 queue frame。Client Agent context 提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript``queued``steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。
@@ -118,6 +118,8 @@
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-default-model": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
+20 -26
View File
@@ -1,11 +1,11 @@
/** Live Session queue, jobs, and projection state with reconnect baselines. */
import type { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Agent, InboxState } from '@deepseek-ai/dsh-agent'
import { Deque } from '@deepseek-ai/dsh-deque'
import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
import type {
Session, SessionEvent, SessionEventMap, SessionId, UserMessage,
Session, SessionId, UserMessage,
} from '@deepseek-ai/dsh-session'
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
import type {
@@ -23,7 +23,6 @@ export class SessionControlController {
/** @param ctx - Host context carrying live Agent, projection, and jobs services. */
constructor(private readonly ctx: Context) {
ctx.on('session/event', (session, event) => { this.onSessionEvent(session, event) })
ctx.sessionProjections.onChanged((session, key, value, seq) => {
this.broadcast({
type: 'projection',
@@ -32,6 +31,14 @@ export class SessionControlController {
value: value as JsonValue,
seq,
})
if (key !== 'inbox') return
const agent = this.ctx.agents.get(session.id)
if (agent?.session !== session) return
this.broadcast({
type: 'queue',
sessionId: session.id,
items: queueItemsFromInbox(value as InboxState),
})
})
ctx.inject(['jobs'], (jobsCtx) => {
jobsCtx.jobs.onJobsChanged((owner) => { this.onJobsChanged(owner) })
@@ -95,17 +102,6 @@ export class SessionControlController {
return blocks
}
private onSessionEvent(session: Session, event: SessionEvent): void {
if (event.type !== 'agent/inbox/spliced') return
const agent = this.ctx.agents.get(session.id)
if (agent?.session !== session) return
this.broadcast({
type: 'queue',
sessionId: session.id,
items: queueItems(agent, event.data),
})
}
private onJobsChanged(owner: Agent | undefined): void {
if (owner !== undefined) {
this.broadcast({ type: 'jobs', sessionId: owner.id, jobs: this.jobsFor(owner) })
@@ -171,24 +167,22 @@ class ControlQueue {
}
}
function queueItems(
agent: Agent,
splice?: SessionEventMap['agent/inbox/spliced'],
): SessionQueuedItem[] {
const project = (target: 'next-turn' | 'next-step'): readonly UserMessage[] => {
const messages = target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep
return splice?.target === target
? messages.toSpliced(splice.start, splice.removedCount ?? 0, ...splice.inserted)
: messages
}
function queueItems(agent: Agent): SessionQueuedItem[] {
return queueItemsFromInbox({
'next-turn': agent.inbox.nextTurn,
'next-step': agent.inbox.nextStep,
})
}
function queueItemsFromInbox(inbox: InboxState): SessionQueuedItem[] {
return [
...project('next-turn').map(message => ({
...inbox['next-turn'].map(message => ({
id: message.id,
placement: 'queued' as const,
...promptRpcId(message),
message: { id: message.id, content: message.content as unknown as JsonValue[] },
})),
...project('next-step').map(message => ({
...inbox['next-step'].map(message => ({
id: message.id,
placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const,
...promptRpcId(message),
@@ -1,6 +1,6 @@
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, Inbox, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { createAssistantMessage, createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
@@ -13,6 +13,7 @@ import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/
import { describe, expect, it, vi } from 'vitest'
import { ApiSessionAgentController } from '../src/agent.ts'
import { SessionCommandController } from '../src/commands.ts'
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
async function commandHarness(
@@ -68,7 +69,7 @@ async function commandHarness(
provider: 1,
} as never)
}
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const inbox = createInboxStub()
const steer = vi.fn((message: UserMessage) => { inbox.append('next-step', message) })
const cancel = vi.fn()
const agent = {
@@ -1,5 +1,6 @@
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type {
@@ -38,7 +39,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{
const session = ctx.sessions.create(SESSION, {
meta: { cwd: '/workspace', ...(origin === undefined ? {} : { origin }) },
})
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const inbox = createInboxStub()
const followup = vi.fn()
const agent = {
id: session.id,
@@ -1,5 +1,5 @@
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { JobOutcome } from '@deepseek-ai/dsh-jobs'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
@@ -9,6 +9,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { describe, expect, it } from 'vitest'
import { SessionControlController } from '../src/control.ts'
import type { SessionControlFrame } from '../src/types.ts'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
type BaselineFrame = Extract<SessionControlFrame, { type: 'baseline' }>
type JobFrame = Extract<SessionControlFrame, { type: 'jobs' }>
@@ -36,20 +37,28 @@ async function harness(withJobs: boolean): Promise<{
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
if (withJobs) {
await ctx.plugin(LocalJobRegistry)
ctx.jobs.attachController('session-controller-test')
}
const session = ctx.sessions.create()
const agent = {
const agent: Agent = {
id: session.id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
inbox: unsupportedInbox(),
status: 'idle',
ctx,
} as Agent
send: () => {},
followup: () => {},
steer: () => {},
inject: () => {},
cancel: () => {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(agent)
const control = new SessionControlController(ctx)
await new Promise(resolve => setTimeout(resolve, 0))
@@ -1,11 +1,20 @@
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Agent, Inbox } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { describe, expect, it } from 'vitest'
import { SessionId } from '@deepseek-ai/dsh-session'
import { afterEach, describe, expect, it } from 'vitest'
import { SessionControlController } from '../src/control.ts'
import type { SessionControlFrame } from '../src/types.ts'
import {
mountAgentLoopTestDependencies,
mountAgentLoopTestHarness,
} from '@deepseek-ai/dsh-agent-loop-testkit'
const ownedContexts = new Set<Context>()
afterEach(async () => {
await Promise.all([...ownedContexts].map(ctx => ctx.fiber.dispose()))
ownedContexts.clear()
})
async function harness(): Promise<{
ctx: Context
@@ -14,14 +23,11 @@ async function harness(): Promise<{
inbox: Inbox
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionProjectionRegistry)
const session = ctx.sessions.create(SessionId('queue-session'))
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const agent = { id: session.id, session, inbox, status: 'running', ctx } as Agent
ctx.agents.register(agent)
return { ctx, control: new SessionControlController(ctx), agent, inbox }
ownedContexts.add(ctx)
await mountAgentLoopTestDependencies(ctx)
const loop = await mountAgentLoopTestHarness(ctx)
const agent = await loop.create(SessionId('queue-session'))
return { ctx, control: new SessionControlController(ctx), agent, inbox: agent.inbox }
}
function message(text: string, source: 'user' | 'plugin' = 'user') {
@@ -32,6 +38,17 @@ function message(text: string, source: 'user' | 'plugin' = 'user') {
}
describe('Session control queue projection', () => {
/** Consume frames until the next queue replacement (inbox projection frames interleave). */
async function nextQueueFrame(
iterator: AsyncIterator<SessionControlFrame>,
): Promise<Extract<SessionControlFrame, { type: 'queue' }>> {
for (;;) {
const next = await iterator.next()
if (next.done) throw new Error('stream ended before a queue frame')
if (next.value.type === 'queue') return next.value
}
}
it('projects both pending lists in baselines and live replacement frames', async () => {
const { control, inbox } = await harness()
const queued = message('queued')
@@ -59,13 +76,41 @@ describe('Session control queue projection', () => {
const replacement = message('replacement')
inbox.append('next-turn', replacement)
const replaced = await iterator.next()
if (replaced.done || replaced.value.type !== 'queue') throw new Error('missing queue replacement')
expect(replaced.value.items.map(item => item.id)).toContain(replacement.id)
const replaced = await nextQueueFrame(iterator)
expect(replaced.items.map(item => item.id)).toContain(replacement.id)
inbox.remove(steering.id)
const removed = await iterator.next()
if (removed.done || removed.value.type !== 'queue') throw new Error('missing queue replacement')
expect(removed.value.items.map(item => item.id)).not.toContain(steering.id)
const removed = await nextQueueFrame(iterator)
expect(removed.items.map(item => item.id)).not.toContain(steering.id)
abort.abort()
await iterator.next()
})
it('derives queue replacements from the completed projection regardless of registration order', async () => {
const ctx = new Context()
ownedContexts.add(ctx)
await mountAgentLoopTestDependencies(ctx)
const loop = await mountAgentLoopTestHarness(ctx)
const control = new SessionControlController(ctx)
const agent = await loop.create(SessionId('late-projection-queue'))
const { inbox } = agent
const abort = new AbortController()
const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
await iterator.next()
const pending = message('late projection')
inbox.append('next-turn', pending)
await expect(iterator.next()).resolves.toMatchObject({
value: {
type: 'projection',
key: 'inbox',
value: { 'next-turn': [{ id: pending.id }], 'next-step': [] },
},
})
await expect(nextQueueFrame(iterator)).resolves.toMatchObject({
items: [{ id: pending.id, placement: 'queued' }],
})
abort.abort()
await iterator.next()
@@ -133,14 +178,19 @@ describe('Session control queue projection', () => {
const { ctx, control, inbox } = await harness()
const iterator = control.control(new AbortController().signal)[Symbol.asyncIterator]()
await iterator.next()
inbox.append('next-turn', message('first'))
inbox.append('next-turn', message('second'))
const first = message('first')
const second = message('second')
inbox.append('next-turn', first)
inbox.append('next-turn', second)
const first = await iterator.next()
expect(first).toMatchObject({ done: false, value: { type: 'queue' } })
const queues: Extract<SessionControlFrame, { type: 'queue' }>[] = []
ownedContexts.delete(ctx)
await ctx.fiber.dispose()
const second = await iterator.next()
expect(second).toMatchObject({ done: false, value: { type: 'queue' } })
await expect(iterator.next()).resolves.toMatchObject({ done: true })
for (;;) {
const next = await iterator.next()
if (next.done) break
if (next.value.type === 'queue') queues.push(next.value)
}
expect(queues.map(queue => queue.items.map(item => item.id))).toEqual([[first.id], [first.id, second.id]])
})
})
@@ -8,14 +8,15 @@ import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek-
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SessionStore from '@deepseek-ai/dsh-session'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
import type { Agent, Inbox } from '@deepseek-ai/dsh-agent'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
import {
SessionPersistenceRevision,
@@ -42,8 +43,8 @@ function promptRequest(
}
}
function inboxFor(session: Session): Inbox {
return new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
function inboxFor(): Inbox {
return createInboxStub()
}
function header(id: string, createdAt: number, extra: Partial<SessionHeader> = {}): SessionHeader {
@@ -545,7 +546,7 @@ describe('subagent ownership fence', () => {
})
const followup = vi.fn()
const agent = {
id: session.id, session, inbox: inboxFor(session), status: 'idle', ctx, followup,
id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup,
} as unknown as Agent
ctx.agents.register(agent)
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
@@ -566,7 +567,7 @@ describe('subagent ownership fence', () => {
const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } })
const followup = vi.fn()
const agent = {
id: session.id, session, inbox: inboxFor(session), status: 'idle', ctx, followup,
id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup,
} as unknown as Agent
ctx.agents.register(agent)
const remote = createSessionTestRemote(ctx, {
@@ -687,7 +688,7 @@ describe('sessions.prompt synchronous rejection', () => {
ctx.agents.register({
id: session.id,
session,
inbox: inboxFor(session),
inbox: inboxFor(),
status: 'idle',
ctx,
followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
@@ -7,19 +7,18 @@
* pushed through the control stream.
*/
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import { z } from 'zod'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import SessionProjectionCache, { projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache'
@@ -27,8 +26,19 @@ import Storage from '@deepseek-ai/dsh-storage'
import * as StorageDomain from '@deepseek-ai/dsh-storage-domain'
import * as StorageJson from '@deepseek-ai/dsh-storage-json'
import type { SessionControlFrame, SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
import {
mountAgentLoopTestDependencies,
mountAgentLoopTestHarness,
} from '@deepseek-ai/dsh-agent-loop-testkit'
import { createSessionTestRemote, testSessionPersistence, type TestSessionRemote } from './test-remote.ts'
const ownedContexts = new Set<Context>()
afterEach(async () => {
await Promise.all([...ownedContexts].map(ctx => ctx.fiber.dispose()))
ownedContexts.clear()
})
let nextHarnessSession = 1
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
'test/last-user': LastUserState
@@ -108,15 +118,35 @@ const privatePromptUnit = () => ({
stateVersion: 1,
}) satisfies ProjectionDefinition<'test/private-prompt', string | null>
async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session }> {
async function harness(withRegistry: boolean): Promise<{
ctx: Context
session: Session
readonly claim: (target: 'next-turn' | 'next-step') => UserMessage[]
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
if (withRegistry) await ctx.plugin(SessionProjectionRegistry)
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
// The gateway reads both the session and durable inbox baseline.
ctx.agents.register({ id: session.id, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', ctx } as Agent)
return { ctx, session }
ownedContexts.add(ctx)
if (!withRegistry) {
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
return {
ctx,
session,
claim: () => { throw new Error('inbox is unavailable without the projection registry') },
}
}
await mountAgentLoopTestDependencies(ctx)
const loop = await mountAgentLoopTestHarness(ctx)
const agent = await loop.create(
SessionId(`session-projections-${String(nextHarnessSession++)}`),
{},
{ cwd: '/workspace' },
)
return {
ctx,
session: agent.session,
claim: target => loop.claim(agent, target, 1),
}
}
/** Append `count` user messages so the log has paginable message boundaries. */
@@ -205,6 +235,74 @@ describe('session.history projections block', () => {
expect(last?.event.seq).toBe(projections.asOfSeq)
})
it('reconstructs a cold persisted queue without publishing or resuming an Agent', async () => {
const { ctx } = await harness(true)
const coldId = SessionId('cold-persisted-queue')
const meta: SessionHeader = { version: SESSION_FORMAT_VERSION, id: coldId, createdAt: 1, cwd: '/tmp', isSeeded: false }
const message = createUserMessage({
content: [{ type: 'text', text: 'survive process restart' }],
source: { kind: 'user' },
})
const events: SessionEvent[] = [{
type: 'agent/inbox/spliced',
seq: SessionSeq(0),
time: 2,
data: { target: 'next-turn', start: 0, inserted: [message] },
}]
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events, inheritedEventCount: SessionLogOffset(0) }),
}) as never)
const snapshot = await opening(remote(ctx), coldId)
expect(snapshot.projections.values.inbox).toEqual({
'next-turn': [message],
'next-step': [],
})
expect(ctx.agents.get(coldId)).toBeUndefined()
expect(ctx.sessions.get(coldId)).toBeUndefined()
})
it('removes claimed steering from the pending Inbox projection immediately', async () => {
const { ctx, session, claim } = await harness(true)
const proxy = remote(ctx)
const message = createUserMessage({
content: [{ type: 'text', text: 'apply this now' }],
source: { kind: 'user' },
})
const agent = ctx.agents.get(session.id)
if (agent === undefined) throw new Error('missing Agent')
agent.inbox.append('next-step', message)
claim('next-step')
const during = await opening(proxy, session.id)
expect(during.projections.values.inbox).toEqual({
'next-turn': [],
'next-step': [],
})
session.append('user/message', message, { surfaceOp: 'append' })
const settled = await opening(proxy, session.id)
expect(settled.projections.values.inbox).toEqual({
'next-turn': [],
'next-step': [],
})
const rejected = createUserMessage({
content: [{ type: 'text', text: 'reject this pre-step' }],
source: { kind: 'user' },
})
session.append('turn/start', { turn: 1 })
agent.inbox.append('next-step', rejected)
claim('next-step')
session.append('turn/end', { turn: 1, reason: { kind: 'blocked' } })
const closed = await opening(proxy, session.id)
expect(closed.projections.values.inbox).toEqual({
'next-turn': [],
'next-step': [],
})
})
it('returns a complete current replacement cut on each follow generation', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(lastUserUnit())
+2
View File
@@ -59,6 +59,8 @@
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-default-model": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^"
}
@@ -2,12 +2,14 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, AssistantStreamFrame, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import AgentDefaultModelConfig from '@deepseek-ai/dsh-agent-default-model'
import { LlmAttemptId, createAssistantMessage, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
import { apply, Config, internals } from '../src/index.ts'
const originalInternals = { ...internals }
@@ -82,6 +84,7 @@ async function bench(script: Script): Promise<{
let err = ''
const order: string[] = []
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentDefaultModelConfig, { provider: 'test-provider', model: 'test-model' })
ctx.agents.setFactory({
@@ -89,16 +92,15 @@ async function bench(script: Script): Promise<{
const session = ctx.sessions.create(options.sessionId, {
...options.meta === undefined ? {} : { meta: options.meta },
})
const inbox = createInboxStub()
let idle = Promise.resolve()
const agent = {} as Agent
const agentCtx = ownerCtx.extend({ agent })
Object.assign(agent, {
const agent: Agent = {
id: session.id,
options: options.agentOptions ?? {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
inbox,
status: 'idle',
ctx: agentCtx,
ctx: ownerCtx,
cancel: () => {},
runMaintenance: () => Promise.reject(new Error('not used')),
send: () => {},
@@ -109,7 +111,11 @@ async function bench(script: Script): Promise<{
steer: () => {},
inject: () => {},
whenIdle: () => idle,
} satisfies Partial<Agent>)
}
const agentCtx = ownerCtx.extend({ agent })
Object.assign(agent, {
ctx: agentCtx,
})
await options.setup?.(agentCtx)
script.before?.(session)
ctx.agents.register(agent)
@@ -158,6 +164,25 @@ describe('headless runner', () => {
await test.ctx.fiber.dispose()
})
it('ignores durable inbox events before the first owned turn', async () => {
const test = await bench({
afterPrompt(session, message) {
session.append('agent/inbox/spliced', {
target: 'next-turn',
start: 0,
inserted: [message],
})
appendTurn(session, 1, message, 'answer after inbox activity', true)
},
})
expect(await test.run()).toMatchObject({
code: 0,
out: 'answer after inbox activity\n',
err: '',
})
await test.ctx.fiber.dispose()
})
it('waits for asynchronously appended events instead of racing Agent idleness', async () => {
const test = await bench({
afterPrompt: async (session, message) => {
@@ -336,16 +361,20 @@ describe('headless runner', () => {
})
it('fails when an event below the captured Session length cannot be read', async () => {
let capturedLength = 0
const test = await bench({
afterPrompt(session, message) {
appendTurn(session, 1, message, 'unreachable', true)
capturedLength = session.seq
Object.defineProperty(session, 'eventAt', { value: () => undefined })
},
})
expect(await test.run()).toMatchObject({
const result = await test.run()
expect(capturedLength).toBeGreaterThan(0)
expect(result).toMatchObject({
code: 1,
out: '',
err: 'dsh: headless summary cannot read seq 0 below captured length 7\n',
err: `dsh: headless summary cannot read seq 0 below captured length ${String(capturedLength)}\n`,
})
await test.ctx.fiber.dispose()
})
@@ -13,7 +13,6 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
import * as LlmRetry from '@deepseek-ai/dsh-llm-retry'
import { Session, SessionId, type SessionEvent, type SurfaceEvent } from '@deepseek-ai/dsh-session'
@@ -151,9 +150,6 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await mountInvariants(ctx)
// AgentLoop and TokenMeter both declare the registry as a required
// injection; mount it before either activates.
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeter)
ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps))
@@ -317,7 +313,6 @@ describe('context-overflow recovery across the real loop and compaction-basic',
const adapter = new OverflowRecoveryAdapter(delivery)
await mountAgentLoopTestDependencies(ctx)
await mountInvariants(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeter)
ctx.llm.registerAdapter(['mock'], adapter)
@@ -396,7 +391,6 @@ describe('context-overflow recovery across the real loop and compaction-basic',
const adapter = new OverflowRecoveryAdapter('thrown', true)
await mountAgentLoopTestDependencies(ctx)
await mountInvariants(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(LlmRetry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeter)
@@ -104,7 +104,6 @@ async function loopHarness(): Promise<LoopHarness> {
await ctx.plugin(AgentInvariant)
await ctx.plugin(AgentLoopInvariant)
await ctx.plugin(CompactionInvariant)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeter)
const adapter = new TextAdapter()
@@ -45,6 +45,7 @@
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-home-paths": "workspace:^",
@@ -4,14 +4,10 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import LlmRuntime from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import * as WorkspaceContext from '@deepseek-ai/dsh-agent-instructions'
import { candidateScopeKey } from '../src/render.ts'
@@ -38,12 +34,9 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> {
await mkdir(join(workdir, '.git'), { recursive: true })
await writeFile(join(workdir, 'AGENTS.md'), `If the user asks for the workspace context handshake, reply with exactly this string and nothing else: ${PROBE}.\n`)
ctx = new Context()
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SystemPrompt, { personaPrefix: 'Answer the user exactly and concisely.' })
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
await mountAgentLoopTestDependencies(ctx, {
systemPrompt: { personaPrefix: 'Answer the user exactly and concisely.' },
})
await ctx.plugin(LocalFileSystem, { cwd: '/' })
await ctx.plugin(ToolFs)
await ctx.plugin(WorkspaceContext, { maxBytes: 65536 })
File diff suppressed because it is too large Load Diff
@@ -4,11 +4,11 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
import { createUserMessage, ToolCallId, 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, Inbox, type Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { unsupportedInbox, mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as timeContext from '@deepseek-ai/dsh-time-context'
import type { Config } from '@deepseek-ai/dsh-time-context'
@@ -38,11 +38,11 @@ async function mount(config: Config = {}) {
}
function sessionAgent(session: Session, id = 'agent'): Agent {
return {
const agent: Agent = {
id: SessionId(id),
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
inbox: unsupportedInbox(),
status: 'running',
ctx: new Context(),
send: () => {},
@@ -53,6 +53,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
return agent
}
function openMessageTurn(session: Session, turn: number, clientTimeZone?: string): void {
@@ -139,7 +140,6 @@ class ScriptedAdapter extends LlmAdapter {
async function loopHarness(adapter: ScriptedAdapter, config: Config = {}): Promise<Context> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(timeContext, config)
ctx.llm.registerAdapter(['mock'], adapter)
@@ -39,6 +39,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-shell": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
@@ -1,13 +1,14 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { ShellExecutor } from '@deepseek-ai/dsh-shell'
import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'
import * as tmuxContext from '@deepseek-ai/dsh-tmux-context'
import type { Config } from '@deepseek-ai/dsh-tmux-context'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
const SIGNAL = new AbortController().signal
@@ -94,11 +95,11 @@ async function mount(
}
function sessionAgent(session: Session, id = 'agent'): Agent {
return {
const agent: Agent = {
id: SessionId(id),
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
inbox: unsupportedInbox(),
status: 'running',
ctx: new Context(),
send: () => {},
@@ -109,6 +110,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
return agent
}
function openMessageTurn(session: Session, turn: number): void {
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md
README.md: 55966a4b0eed0c1cc3484314e809de79341072de
README.zh.md: 1d27bf3743f54d8fe66a58e75565fc85d94ae1ed
README.md: b24151c8fb512b144b15c017b662b0b2a57532fc
README.zh.md: d4c3b511426ce06c955d36d3f0f3852fe459ce0b
+4 -1
View File
@@ -68,6 +68,8 @@ const handle = await ctx.agents.create({
})
```
Every inbox mutation commits one normalized `agent/inbox/spliced` event. The projection registry folds that event synchronously, so the live projection reflects the splice when `Session.append()` returns. Insertions, edits, removals, claiming, and cancellation replay through the same standard splice coordinates. Ordinary removals carry `outcome: 'canceled'` and emit `agent/inbox/discarded { message }`; claiming uses pure deletions with no outcome and emits `agent/inbox/claimed`. Every insertion emits `agent/inbox/inserted { message }`. `MessageId` stays unique across both pending lists. Consumers that need a removed message use the claimed or discarded notification instead of depending on a pre-splice `session/event` view.
### What a step does
Each step sends the agent's rendered system prompt, its visible tool schemas, and the session's derived history; the model's tool calls run through the guarded tool pipeline and every accepted fact is appended to the session log before the next step derives from it. Parallel-safe calls may overlap up to `maxParallelToolCalls`; exclusive calls run alone as ordering barriers. Cancellation is cooperative: `agent.cancel()` aborts the current activity and, unless `keepInbox` is set, clears pending work; a cancelled stream finalizes the text already delivered to the user.
@@ -98,6 +100,7 @@ The loop deep-freezes each derived message identity on its first request and reu
|---|---|
| [`src/index.ts`](src/index.ts) | Plugin entry: `AgentLoop` service, config schema, declarative agent startup, factory registration |
| [`src/agent.ts`](src/agent.ts) | The concrete `ReactLoopAgent` driver: inbox, turn/step machine, cancellation |
| [`src/inbox.ts`](src/inbox.ts) | Package-internal `ReactLoopInbox`: durable projection, structural commands, and loop-only claim state |
| [`src/tool-calls.ts`](src/tool-calls.ts) | Tool scheduling: exclusive barriers and the bounded parallel pool |
| [`src/runtime-context.ts`](src/runtime-context.ts) | Per-step runtime-context snapshot handling |
| [`src/constants.ts`](src/constants.ts) | `DEFAULT_MAX_PARALLEL_TOOL_CALLS` |
@@ -113,7 +116,7 @@ The loop is the production acquisition point for session write handles. When `ct
### Turn and step flow
The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. At a turn boundary it opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. `agent/pre-step` decides what enters the step. An entered decision appends its complete `user/message` batch before the driver can claim again, while a rejected decision appends none. Each model attempt emits one process-local `start`, emits every `chunk` only after the matching durable `assistant/chunk`, and emits exactly one terminal `end`; final assembly or message-append failure settles it as `aborted`, while `committed` follows the durable `assistant/message`. Each successful model call appends one message anchor citing its chunk seqs, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results, and result context remain model-ordered.
The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. Its package-internal `ReactLoopInbox` constructor registers the standard `inbox` projection on the agent scope, then uses that projection for structural commands and loop-only claims. Registry reference counting keeps the shared key active until the last agent scope unloads. At a turn boundary the driver opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. `agent/pre-step` decides what enters the step. An entered decision appends its complete `user/message` batch before the driver can claim again, while a rejected decision appends none. Each model attempt emits one process-local `start`, emits every `chunk` only after the matching durable `assistant/chunk`, and emits exactly one terminal `end`; final assembly or message-append failure settles it as `aborted`, while `committed` follows the durable `assistant/message`. Each successful model call appends one message anchor citing its chunk seqs, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results, and result context remain model-ordered.
### Failure and cancellation
+4 -1
View File
@@ -68,6 +68,8 @@ const handle = await ctx.agents.create({
})
```
每次 inbox 变更都会提交一条规范化的 `agent/inbox/spliced` 事件。投影注册表会同步折叠该事件,因此 `Session.append()` 返回时,实时投影已经反映该 splice。插入、编辑、移除、领取与取消都通过同一组标准 splice 坐标回放。普通删除携带 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`;领取使用不带 outcome 的纯删除,并发出 `agent/inbox/claimed`。每次插入都会发出 `agent/inbox/inserted { message }``MessageId` 在两个待处理列表之间保持唯一。需要被移除消息的消费方应使用 claimed 或 discarded 通知,而不依赖 splice 前的 `session/event` 投影视图。
### 一个步骤做什么
每个步骤都会发送该 agent 渲染后的系统提示词、其可见工具 schema 与会话的派生历史;模型的工具调用经过受守卫的工具流水线,每个被接纳的事实都会在下一步据此派生之前追加到会话日志。并行安全调用最多可重叠 `maxParallelToolCalls` 个;独占调用单独运行并构成排序屏障。取消是协作式的:`agent.cancel()` 中止当前活动,并在未设置 `keepInbox` 时清除待处理工作;被取消的流会终结已送达用户的文本。
@@ -98,6 +100,7 @@ const handle = await ctx.agents.create({
|---|---|
| [`src/index.ts`](src/index.ts) | 插件入口:`AgentLoop` 服务、配置 schema、声明式 agent 启动、工厂注册 |
| [`src/agent.ts`](src/agent.ts) | 具体 `ReactLoopAgent` 驱动器:收件箱、轮次/步骤状态机、取消 |
| [`src/inbox.ts`](src/inbox.ts) | 包内部的 `ReactLoopInbox`:持久投影、结构化命令与仅供循环使用的领取状态 |
| [`src/tool-calls.ts`](src/tool-calls.ts) | 工具调度:独占屏障与有界并行池 |
| [`src/runtime-context.ts`](src/runtime-context.ts) | 每步骤 runtime-context 快照处理 |
| [`src/constants.ts`](src/constants.ts) | `DEFAULT_MAX_PARALLEL_TOOL_CALLS` |
@@ -113,7 +116,7 @@ const handle = await ctx.agents.create({
### 轮次与步骤流程
驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。在轮次边界,先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。`agent/pre-step` 决定什么进入该步骤。进入步骤的决定会在驱动器再次领取消息前追加完整的 `user/message` 批次,被拒绝的决定则不追加任何消息。每次模型尝试会发出一个进程本地 `start`,仅在匹配的持久 `assistant/chunk` 之后发出各个 `chunk`,并恰好发出一个终态 `end`;最终组装或消息追加失败时以 `aborted` 结算,`committed` 则出现在持久 `assistant/message` 之后。每次成功的模型调用都恰好追加一个引用其分片 seq 的 message 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。
驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。其包内部 `ReactLoopInbox` 构造函数在 agent 作用域上注册标准 `inbox` 投影,随后将该投影用于结构化命令与仅供 loop 使用的领取操作。注册表引用计数会使共享 key 持续有效,直至最后一个 agent 作用域卸载。在轮次边界,驱动器先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。`agent/pre-step` 决定什么进入该步骤。进入步骤的决定会在驱动器再次领取消息前追加完整的 `user/message` 批次,被拒绝的决定则不追加任何消息。每次模型尝试会发出一个进程本地 `start`,仅在匹配的持久 `assistant/chunk` 之后发出各个 `chunk`,并恰好发出一个终态 `end`;最终组装或消息追加失败时以 `aborted` 结算,`committed` 则出现在持久 `assistant/message` 之后。每次成功的模型调用都恰好追加一个引用其分片 seq 的 message 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。
### 失败与取消
+6 -9
View File
@@ -15,7 +15,7 @@ import type {
PreStepDecision,
RequestErrorAction,
} from '@deepseek-ai/dsh-agent'
import { Inbox, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm'
import {
LlmError,
@@ -32,6 +32,7 @@ import { joinContextSections, renderContextSections, renderPrompt } from '@deeps
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-session-projection'
import type { Context } from '@deepseek-ai/cordis'
import { ReactLoopInbox } from './inbox.ts'
import { RuntimeContextProjection } from './runtime-context.ts'
import { AssistantStreamAttempt } from './assistant-stream.ts'
import { executeToolCalls } from './tool-calls.ts'
@@ -68,7 +69,7 @@ function requestProposal(header: EpochHeader): LlmCallConfig {
/** Drives one session through turn and step boundaries. */
export class ReactLoopAgent implements Agent {
readonly inbox: Inbox
readonly inbox: ReactLoopInbox
private phase: Phase
private activityDone: Promise<void> = Promise.resolve()
@@ -97,16 +98,12 @@ export class ReactLoopAgent implements Agent {
public readonly session: Session,
) {
this.dispatch = agentEvents(loopCtx, this)
this.inbox = new Inbox(session, {
inserted: (message) => { this.dispatch.emit('agent/inbox/inserted', { message }) },
discarded: (message) => { this.dispatch.emit('agent/inbox/discarded', { message }) },
claimed: (message, turn) => { this.dispatch.emit('agent/inbox/claimed', { message, turn }) },
})
this.scope = createScope(loopCtx, this)
this.ctx = this.scope.ctx.extend({ agent: this })
this.inbox = new ReactLoopInbox(this.ctx.sessionProjections, session, this.dispatch)
/* v8 ignore next -- the loop registers its own turnBoundary unit, so the key is always present */
const lastTurn = this.loopCtx.sessionProjections.stateOf(session, 'turnBoundary')?.lastTurn ?? 0
this.phase = { kind: 'idle', lastTurn }
this.scope = createScope(loopCtx, this)
this.ctx = this.scope.ctx.extend({ agent: this })
this.runtimeContext = new RuntimeContextProjection(this.ctx, session)
}
+247
View File
@@ -0,0 +1,247 @@
/**
* Driver-owned durable agent inbox projection and command facade.
*
* @module @deepseek-ai/dsh-agent-loop/inbox
*/
import type { MessageId } from '@deepseek-ai/dsh-llm'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import type SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session'
import type {
AgentEventDispatch,
Inbox as InboxContract,
InboxState,
InboxTarget,
InboxWireState,
} from '@deepseek-ai/dsh-agent'
import { z } from 'zod'
/** Wire validation for pending agent input reconstructed from durable inbox splices. */
export const inboxProjectionSchema = z.object({
'next-turn': z.array(z.custom<UserMessage>()).readonly(),
'next-step': z.array(z.custom<UserMessage>()).readonly(),
}).readonly()
/** Standard fold that reconstructs pending input and rejects invalid durable splice history. */
export const inboxProjectionDefinition = {
key: 'inbox',
stateSchema: inboxProjectionSchema,
init: (): InboxState => ({ 'next-turn': [], 'next-step': [] }),
apply(state: InboxState, event) {
if (event.type !== 'agent/inbox/spliced') return state
const splice = event.data
try {
const inbox = state[splice.target]
const removedCount = splice.removedCount ?? 0
if (!Number.isSafeInteger(splice.start) || splice.start < 0 || splice.start > inbox.length
|| !Number.isSafeInteger(removedCount) || removedCount < 0
|| splice.start + removedCount > inbox.length) {
throw new Error('invalid inbox splice')
}
const next = inbox.toSpliced(splice.start, removedCount, ...splice.inserted)
const ids = new Set<string>()
for (const message of splice.target === 'next-turn'
? [...next, ...state['next-step']]
: [...state['next-turn'], ...next]) {
if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`)
ids.add(message.id)
}
return splice.target === 'next-turn'
? { 'next-turn': next, 'next-step': state['next-step'] }
: { 'next-turn': state['next-turn'], 'next-step': next }
} catch (error: unknown) {
throw new Error(`invalid persisted inbox splice at session seq ${event.seq}`, { cause: error })
}
},
wire: {
// The wire value is the fold state itself: every pending message already
// round-trips the session log as lossless JSON. Only the static type
// narrows to the JSON-safe projection table entry.
viewSchema: inboxProjectionSchema as unknown as z.ZodType<InboxWireState>,
view: (state: InboxState) => state as unknown as InboxWireState,
},
stateVersion: 1,
} satisfies ProjectionDefinition<'inbox', InboxState>
/**
* Driver-owned durable Inbox implementation used by ReactLoopAgent and focused
* provider tests.
* @param projections - registry that owns the standard Inbox projection.
* @param session - session whose durable events store pending input.
* @param dispatch - agent-scoped notifications for Inbox lifecycle events.
*/
export class ReactLoopInbox implements InboxContract {
constructor(
private readonly projections: SessionProjectionRegistry,
private readonly session: Session,
private readonly dispatch: AgentEventDispatch,
) {
this.projections.register(inboxProjectionDefinition)
}
/** Prompts awaiting individual turns. */
get nextTurn(): readonly UserMessage[] {
return this.current()['next-turn']
}
/** Input awaiting the next step boundary. */
get nextStep(): readonly UserMessage[] {
return this.current()['next-step']
}
/** Whether either pending-message list contains work. */
get hasPending(): boolean {
const state = this.current()
return state['next-turn'].length > 0 || state['next-step'].length > 0
}
/** Durably cancel all pending input, clearing next-step before next-turn. */
clear(): void {
this.splice('next-step', 0, this.nextStep.length, [])
this.splice('next-turn', 0, this.nextTurn.length, [])
}
/**
* Remove and return the complete batch proposed for one step.
* @param target - whether this boundary also consumes one queued turn.
* @param turn - turn that will own the claimed batch.
* @returns next-step input followed by the queued turn, when requested.
*/
claim(target: InboxTarget, turn: number): UserMessage[] {
const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false)
if (target === 'next-turn') claimed.push(...this.mutate('next-turn', 0, 1, [], false))
for (const message of claimed) this.dispatch.emit('agent/inbox/claimed', { message, turn })
return claimed
}
/**
* Append one message to a pending list.
* @param target - pending list to extend.
* @param message - message to append.
*/
append(target: InboxTarget, message: UserMessage): void {
this.splice(target, this.current()[target].length, 0, [message])
}
/**
* Prepend one message to a pending list.
* @param target - pending list to extend.
* @param message - message to prepend.
*/
prepend(target: InboxTarget, message: UserMessage): void {
this.splice(target, 0, 0, [message])
}
/**
* Replace one pending message in place.
* @param messageId - identity of the pending message to replace.
* @param newMessage - replacement message.
* @returns whether the message was still pending.
*/
replace(messageId: MessageId, newMessage: UserMessage): boolean {
const location = this.locate(messageId)
if (location === undefined) return false
this.splice(location.target, location.index, 1, [newMessage])
return true
}
/**
* Remove one pending message.
* @param messageId - identity of the pending message to remove.
* @returns whether the message was still pending.
*/
remove(messageId: MessageId): boolean {
const location = this.locate(messageId)
if (location === undefined) return false
this.splice(location.target, location.index, 1, [])
return true
}
/**
* Apply standard splice semantics and durably record the normalized result.
* @param target - pending list to mutate.
* @param start - splice position.
* @param deleteCount - maximum number of messages to remove.
* @param inserted - messages to insert at the resolved position.
* @returns messages removed by the splice.
*/
splice(
target: InboxTarget,
start: number,
deleteCount: number,
inserted: UserMessage[],
): UserMessage[] {
return this.mutate(target, start, deleteCount, inserted, true)
}
/** Locate one pending identity across both owned lists. */
private locate(messageId: MessageId): { target: InboxTarget; index: number } | undefined {
const state = this.current()
for (const target of ['next-turn', 'next-step'] as const) {
const index = state[target].findIndex(message => message.id === messageId)
if (index >= 0) return { target, index }
}
return undefined
}
/** Read the current durable projection state. */
private current(): InboxState {
const state = this.projections.stateOf(this.session, 'inbox')
/* v8 ignore next -- the constructor registers this key before any read */
if (state === undefined) {
throw new Error(
`agent "${this.session.id}" cannot read inbox state: its projection registration is not active`,
)
}
return state
}
/** Commit one normalized mutation and publish its live events. */
private mutate(
target: InboxTarget,
start: number,
deleteCount: number,
inserted: UserMessage[],
discardRemoved: boolean,
): UserMessage[] {
const state = this.current()
const inbox = state[target]
const truncatedStart = Math.trunc(start)
const offset = Number.isNaN(truncatedStart) ? 0 : truncatedStart
const actualStart = offset < 0
? Math.max(inbox.length + offset, 0)
: Math.min(offset, inbox.length)
const truncatedDeleteCount = Math.trunc(deleteCount)
const actualDeleteCount = Math.min(
Math.max(Number.isNaN(truncatedDeleteCount) ? 0 : truncatedDeleteCount, 0),
inbox.length - actualStart,
)
if (actualDeleteCount === 0 && inserted.length === 0) return []
const candidate = inbox.toSpliced(actualStart, actualDeleteCount, ...inserted)
const ids = new Set<string>()
for (const message of target === 'next-turn'
? [...candidate, ...state['next-step']]
: [...state['next-turn'], ...candidate]) {
if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`)
ids.add(message.id)
}
const outcome = discardRemoved && actualDeleteCount > 0 ? 'canceled' as const : undefined
const splice: SessionEventMap['agent/inbox/spliced'] = {
target,
start: actualStart,
...(actualDeleteCount === 0 ? {} : { removedCount: actualDeleteCount }),
inserted,
...(outcome === undefined ? {} : { outcome }),
}
const removed = inbox.slice(actualStart, actualStart + actualDeleteCount)
const event = this.session.append('agent/inbox/spliced', splice)
if (discardRemoved) {
for (const message of removed) this.dispatch.emit('agent/inbox/discarded', { message })
}
for (const message of event.data.inserted) {
this.dispatch.emit('agent/inbox/inserted', { message })
}
return removed
}
}
+17 -8
View File
@@ -584,7 +584,9 @@ export class AgentLoop extends Service implements AgentFactory {
// Disposal IS a disposed-cause cancel followed by quiescence. New work
// sent after this point is the sender's bug — the registries are about
// to drop the agent, so nothing should still hold it.
/* v8 ignore next -- Cordis effect teardown waits for synchronous setup before observing the machine slot. */
if (machine === undefined) await machineReady.promise
/* v8 ignore next -- setup failure untracks this disposer before resolving without a machine. */
if (machine !== undefined) {
machine.cancel({ kind: 'disposed' })
await machine.whenIdle()
@@ -617,15 +619,21 @@ export class AgentLoop extends Service implements AgentFactory {
const untrack = this.ownership.track(dispose)
let unfollowOwner: () => Promise<void> | void
try {
unfollowOwner = ownerCtx.effect(() => () => {
// Owner disposal owns the same quiescence boundary. Its teardown skips
// unregistering this already-running owner effect from inside itself.
if (disposing !== undefined) return
abort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
return dispose(true)
unfollowOwner = ownerCtx.effect(function* () {
machine = new ReactLoopAgent(loopCtx, id, options, session)
machineReady.resolve()
yield machine.scope.rawDispose
yield () => {
// Owner disposal owns the same quiescence boundary. Its teardown skips
// unregistering this already-running owner effect from inside itself.
if (disposing !== undefined) return
abort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
return dispose(true)
}
}, `agentLoop.lifecycle(${id})`)
/* v8 ignore start -- ctx.effect throws only on an inactive fiber, which assertActive() above already rejected */
} catch (error: unknown) {
machineReady.resolve()
untrack()
callerSignal?.removeEventListener('abort', onCallerAbort)
this.ownership.signal.removeEventListener('abort', onFactoryTeardown)
@@ -642,8 +650,9 @@ export class AgentLoop extends Service implements AgentFactory {
throw abort.signal.reason instanceof Error ? abort.signal.reason : new Error(String(abort.signal.reason))
}
try {
const agent = machine = new ReactLoopAgent(loopCtx, id, options, session)
machineReady.resolve()
/* v8 ignore next -- a synchronous effect exhausts the generator before returning */
if (machine === undefined) throw new Error(`agent "${id}" lifecycle did not construct its driver`)
const agent = machine
assertLive()
return {
@@ -7,7 +7,6 @@ import ToolRuntime, { defineContentToolFixture, type PostToolDecision } from '@d
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { ReactLoopAgent } from '../src/agent.ts'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
@@ -249,7 +248,11 @@ describe('abort during tool execution ends the turn', () => {
? [event.data.content]
: []))
.toEqual([])
expect(agent.inbox.nextStep.map(inboxText))
expect(agent.session.snapshotEvents()
.flatMap(event => event.type === 'agent/inbox/spliced' && event.data.target === 'next-step'
? [event.data.inserted.map(inboxText)]
: [])
.at(-1))
.toEqual(['accepted result context during disposal'])
expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start'))
.toHaveLength(1)
@@ -583,10 +586,11 @@ describe('turn numbering continues across seeded sessions', () => {
await ctx2.plugin(AgentLoop, { agents: [] })
ctx2.llm.registerAdapter(['mock'], second)
const seeded = ctx2.sessions.create(SessionId('forked'), { seed: agent.session.snapshotEvents() })
const forked = new ReactLoopAgent(
ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded,
)
const { agent: forked } = await ctx2.agents.create({
sessionId: SessionId('forked'),
seed: agent.session.snapshotEvents(),
agentOptions: { provider: 'mock', model: 'mock' },
})
const turns: number[] = []
ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
@@ -0,0 +1,266 @@
import { Context } from '@deepseek-ai/cordis'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { describe, expect, it } from 'vitest'
import { ReactLoopInbox } from '../src/inbox.ts'
function unsupportedInbox(): Agent['inbox'] {
const rejectMutation = (): never => {
throw new Error('this test Agent does not support Inbox mutations')
}
return {
nextTurn: [], nextStep: [], clear: rejectMutation, append: rejectMutation,
prepend: rejectMutation, replace: rejectMutation, remove: rejectMutation, splice: rejectMutation,
}
}
function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
const id = SessionId(rawId)
const session = overrides.session ?? Session.create(id)
const ctx = overrides.ctx ?? new Context()
return {
id,
options: {},
session,
inbox: unsupportedInbox(),
status: 'idle',
ctx,
send: () => {},
followup: () => {},
steer: () => {},
inject: () => {},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
...overrides,
}
}
async function inboxAgent(rawId: string): Promise<{
ctx: Context
session: Session
agent: Agent
inbox: ReactLoopInbox
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
const session = ctx.sessions.create(SessionId(rawId))
const agent = stubAgent(rawId, { ctx, session })
const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent))
Object.assign(agent, { inbox })
return { ctx, session, agent, inbox }
}
async function reconstructPersistedInbox(
rawId: string,
populate: (session: Session) => void,
): Promise<Error> {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId(rawId))
populate(session)
await ctx.plugin(SessionProjectionRegistry)
const agent = stubAgent(rawId, { ctx, session })
const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent))
try {
void inbox.nextTurn
} catch (error: unknown) {
if (error instanceof Error) return error
throw error
}
throw new Error('persisted inbox reconstruction unexpectedly succeeded')
}
describe('ReactLoopInbox', () => {
it('registers the durable projection in its constructor', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
const session = ctx.sessions.create(SessionId('inbox-projection'))
const pending = createUserMessage({
content: [{ type: 'text', text: 'pending' }],
source: { kind: 'user' },
})
session.append('agent/inbox/spliced', {
target: 'next-turn', start: 0, inserted: [pending],
})
const agent = stubAgent('inbox-projection', { ctx, session })
const dispatch = agentEvents(ctx, agent)
const first = new ReactLoopInbox(ctx.sessionProjections, session, dispatch)
const second = new ReactLoopInbox(ctx.sessionProjections, session, dispatch)
expect(first.nextTurn).toEqual([pending])
expect(second.nextTurn).toEqual([pending])
expect(ctx.sessionProjections.snapshot(session).values.inbox).toEqual({
'next-turn': [pending],
'next-step': [],
})
})
it('rejects invalid durable coordinates and duplicate identities during reconstruction', async () => {
const outOfRange = await reconstructPersistedInbox('invalid-inbox-range', (session) => {
session.append('agent/inbox/spliced', {
target: 'next-turn', start: 0, removedCount: 1, inserted: [],
})
})
expect(outOfRange.message).toBe('invalid persisted inbox splice at session seq 0')
expect((outOfRange.cause as Error).message).toBe('invalid inbox splice')
const pending = createUserMessage({
content: [{ type: 'text', text: 'duplicate' }],
source: { kind: 'user' },
})
const duplicate = await reconstructPersistedInbox('invalid-inbox-duplicate', (session) => {
session.append('agent/inbox/spliced', {
target: 'next-turn', start: 0, inserted: [pending],
})
session.append('agent/inbox/spliced', {
target: 'next-step', start: 0, inserted: [pending],
})
})
expect(duplicate.message).toBe('invalid persisted inbox splice at session seq 1')
expect((duplicate.cause as Error).message).toBe(`message "${pending.id}" is already pending`)
})
it('projects inherited inbox events in a forked session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
const parent = ctx.sessions.create(SessionId('inbox-fork-parent'))
const parentAgent = stubAgent('inbox-fork-parent', { ctx, session: parent })
const parentInbox = new ReactLoopInbox(ctx.sessionProjections, parent, agentEvents(ctx, parentAgent))
const inherited = createUserMessage({
content: [{ type: 'text', text: 'parent pending' }],
source: { kind: 'user' },
})
parentInbox.append('next-turn', inherited)
const child = ctx.sessions.fork(parent, undefined, SessionId('inbox-fork-child'))
const childAgent = stubAgent('inbox-fork-child', { ctx, session: child })
const childInbox = new ReactLoopInbox(ctx.sessionProjections, child, agentEvents(ctx, childAgent))
expect(child.inheritedEventCount).toBe(parent.snapshotEvents().length)
expect(childInbox.nextTurn).toEqual([inherited])
const own = createUserMessage({
content: [{ type: 'text', text: 'child pending' }],
source: { kind: 'user' },
})
childInbox.append('next-turn', own)
expect(childInbox.nextTurn).toEqual([inherited, own])
})
it('updates the projection cell before session observers run', async () => {
const { ctx, session, inbox } = await inboxAgent('inbox-live-projection')
const pending = createUserMessage({
content: [{ type: 'text', text: 'direct' }],
source: { kind: 'user' },
})
let observed: readonly UserMessage[] | undefined
ctx.on('session/event', (subject, event) => {
if (subject === session && event.type === 'agent/inbox/spliced') {
observed = ctx.sessionProjections.stateOf(session, 'inbox')?.['next-turn']
}
})
inbox.append('next-turn', pending)
expect(observed).toEqual([pending])
expect(ctx.sessionProjections.snapshot(session).values.inbox).toEqual({
'next-turn': [pending], 'next-step': [],
})
})
it('replaces a pending message by identity across both lists', async () => {
const { ctx, agent } = await inboxAgent('replace-inbox')
const inserted: UserMessage[] = []
const discarded: UserMessage[] = []
ctx.on('agent/inbox/inserted', ({ message }) => void inserted.push(message))
ctx.on('agent/inbox/discarded', ({ message }) => void discarded.push(message))
const original = createUserMessage({
content: [{ type: 'text', text: 'original' }],
source: { kind: 'user' },
})
const nextStep = createUserMessage({
content: [{ type: 'text', text: 'step' }],
source: { kind: 'user' },
})
const replacement = createUserMessage({
content: [{ type: 'text', text: 'replacement' }],
source: { kind: 'user' },
})
const editedStep = freezeMessage({
...nextStep,
content: [{ type: 'text', text: 'edited step' }],
})
agent.inbox.append('next-turn', original)
agent.inbox.append('next-step', nextStep)
expect(agent.inbox.replace(createUserMessage({
content: [{ type: 'text', text: 'missing' }],
source: { kind: 'user' },
}).id, replacement)).toBe(false)
expect(agent.inbox.replace(original.id, replacement)).toBe(true)
expect(agent.inbox.replace(nextStep.id, editedStep)).toBe(true)
expect(agent.inbox.nextTurn).toEqual([replacement])
expect(agent.inbox.nextStep).toEqual([editedStep])
expect(discarded).toEqual([original, nextStep])
expect(inserted).toEqual([original, nextStep, replacement, editedStep])
expect(() => { agent.inbox.replace(editedStep.id, replacement) })
.toThrow(`message "${replacement.id}" is already pending`)
})
it('normalizes splice coordinates, rejects duplicate identities, and reports missing removals', async () => {
const { agent } = await inboxAgent('splice-inbox')
const first = createUserMessage({
content: [{ type: 'text', text: 'first' }],
source: { kind: 'user' },
})
const second = createUserMessage({
content: [{ type: 'text', text: 'second' }],
source: { kind: 'user' },
})
const prefixed = createUserMessage({
content: [{ type: 'text', text: 'prefixed' }],
source: { kind: 'user' },
})
agent.inbox.splice('next-turn', Number.NaN, Number.NaN, [first, second])
expect(agent.inbox.nextTurn).toEqual([first, second])
expect(agent.inbox.splice('next-turn', -1, 1, [])).toEqual([second])
agent.inbox.prepend('next-turn', prefixed)
expect(agent.inbox.nextTurn).toEqual([prefixed, first])
expect(agent.inbox.remove(second.id)).toBe(false)
expect(() => { agent.inbox.append('next-step', first) }).toThrow(`message "${first.id}" is already pending`)
})
it('clears both pending lists as durable cancellations', async () => {
const { ctx, session, agent } = await inboxAgent('clear-inbox')
const discarded: UserMessage[] = []
ctx.on('agent/inbox/discarded', ({ message }) => void discarded.push(message))
const nextTurn = createUserMessage({ content: [{ type: 'text', text: 'turn' }], source: { kind: 'user' } })
const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } })
agent.inbox.append('next-turn', nextTurn)
agent.inbox.append('next-step', nextStep)
const beforeClear = session.snapshotEvents().length
agent.inbox.clear()
expect(agent.inbox.nextTurn).toEqual([])
expect(agent.inbox.nextStep).toEqual([])
expect(discarded).toEqual([nextStep, nextTurn])
expect(session.snapshotEvents().slice(beforeClear).map(event => event.type === 'agent/inbox/spliced'
? event.data
: event.type)).toEqual([
{ target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' },
{ target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' },
])
agent.inbox.clear()
expect(session.snapshotEvents()).toHaveLength(beforeClear + 2)
})
})
@@ -292,7 +292,8 @@ describe('agent/pre-step', () => {
decision.resolve({ kind: 'enter', messages: claimed })
await idle
expect(agent.inbox.hasPending).toBe(false)
expect(agent.inbox.nextTurn).toEqual([])
expect(agent.inbox.nextStep).toEqual([])
const staged = events(agent).filter(event =>
event.type === 'turn/start' || event.type === 'user/message')
@@ -472,7 +473,8 @@ describe('agent/pre-step', () => {
send(agent, 'blocked prompt')
}).toThrow('append unavailable')
expect(events(agent)).toEqual([])
expect(agent.inbox.hasPending).toBe(false)
expect(agent.inbox.nextTurn).toEqual([])
expect(agent.inbox.nextStep).toEqual([])
expect(agent.status).toBe('idle')
})
@@ -8,7 +8,6 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
import { createAssistantMessage, createUserMessage, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, ToolSchema } from '@deepseek-ai/dsh-llm'
import { Session, SessionId, SessionLogOffset, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import * as values from '@deepseek-ai/dsh-util-values'
import { ReactLoopAgent } from '../src/agent.ts'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -23,14 +22,13 @@ afterEach(async () => {
}
})
async function harness(adapter?: MockAdapter): Promise<Context> {
async function harness(adapter?: MockAdapter): Promise<{ ctx: Context; loopCtx: Context }> {
const ctx = new Context()
cleanups.push(() => ctx.fiber.dispose())
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
if (adapter) ctx.effect(() => ctx.llm.registerAdapter(['mock'], adapter))
return ctx
return { ctx, loopCtx: loopFiber.ctx }
}
async function send(agent: Agent, text: string): Promise<void> {
@@ -46,7 +44,7 @@ function expectFrozen(value: unknown): void {
describe('loop-owned request freezing', () => {
it('adopts restored identities, freezes nested messages at dispatch, and leaves event wrappers mutable', async () => {
const ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three'), textResponse('four')]))
const { ctx, loopCtx } = await harness(new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three'), textResponse('four')]))
const id = SessionId('restored-freeze')
const seed = Session.create(id)
seed.append('user/message', createUserMessage({
@@ -75,7 +73,7 @@ describe('loop-owned request freezing', () => {
expect(Object.isFrozen(userEvent.data.content)).toBe(false)
expect(Object.isFrozen(assistantEvent.data.message)).toBe(false)
ctx.effect(() => ctx.sessions.enter(session))
const agent = new ReactLoopAgent(ctx, id, { provider: 'mock', model: 'mock' }, session)
const agent = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session)
cleanups.push(async () => {
agent.cancel({ kind: 'disposed' })
await agent.whenIdle()
@@ -124,7 +122,7 @@ describe('loop-owned request freezing', () => {
expect(Object.isFrozen(session.deriveMessages())).toBe(false)
expect(freeze.mock.calls.filter(([value]) => value === userEvent.data)).toHaveLength(1)
expect(freeze.mock.calls.filter(([value]) => value === replacement.data)).toHaveLength(1)
const resumed = new ReactLoopAgent(ctx, id, { provider: 'mock', model: 'mock' }, session)
const resumed = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session)
cleanups.push(async () => {
resumed.cancel({ kind: 'disposed' })
await resumed.whenIdle()
@@ -136,7 +134,7 @@ describe('loop-owned request freezing', () => {
})
it('retries freezing an identity whose previous traversal failed', async () => {
const ctx = await harness(new MockAdapter([textResponse('done')]))
const { ctx } = await harness(new MockAdapter([textResponse('done')]))
const agent = await ctx.agentLoop.create(SessionId('freeze-failure'), { provider: 'mock', model: 'mock' })
const message = agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'history' }], source: { kind: 'user' },
@@ -163,7 +161,7 @@ describe('loop-owned request freezing', () => {
it.each([true, false])('freezes each local header with an adapter present: %s', async (registered) => {
const adapter = registered ? new MockAdapter([textResponse('one'), textResponse('two')]) : undefined
const ctx = await harness(adapter)
const { ctx } = await harness(adapter)
const schemas: ToolSchema[][] = []
const stops: string[][] = []
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
@@ -206,7 +204,7 @@ describe('loop-owned request freezing', () => {
})
it('keeps the live request signal mutable and observes cancellation after dispatch', async () => {
const ctx = await harness(new MockAdapter(['hang']))
const { ctx } = await harness(new MockAdapter(['hang']))
const agent = await ctx.agentLoop.create(SessionId('cancel-freeze'), { provider: 'mock', model: 'mock' })
const started = Promise.withResolvers<GenerateOptions>()
ctx.on('llm/stream', (request, next) => { started.resolve(request); return next() })
@@ -195,6 +195,33 @@ describe('agent scope lifecycle', () => {
expect(after.sections.find(s => s.name === 'deployment:persona-prefix')?.text).toBe('You are the deployment.')
})
it('keeps the inbox projection until the last owning agent fiber unloads', async () => {
const ctx = await harness()
let first!: Awaited<ReturnType<typeof ctx.agents.create>>
let second!: Awaited<ReturnType<typeof ctx.agents.create>>
const firstOwner = await ctx.plugin(Object.assign(async (inner: Context) => {
first = await inner.agents.create({
sessionId: SessionId('projection-owner-first'),
agentOptions: { provider: 'mock', model: 'mock' },
})
}, { inject: ['agents'] }))
const secondOwner = await ctx.plugin(Object.assign(async (inner: Context) => {
second = await inner.agents.create({
sessionId: SessionId('projection-owner-second'),
agentOptions: { provider: 'mock', model: 'mock' },
})
}, { inject: ['agents'] }))
expect(ctx.sessionProjections.stateOf(first.agent.session, 'inbox')).toBeDefined()
await firstOwner.dispose()
expect(ctx.sessionProjections.stateOf(second.agent.session, 'inbox')).toBeDefined()
await secondOwner.dispose()
expect(ctx.sessionProjections.stateOf(second.agent.session, 'inbox')).toBeUndefined()
await Promise.all([first.dispose(), second.dispose()])
await ctx.fiber.dispose()
})
it('agent.ctx listeners hear only their own agent (scoped dispatch end to end)', async () => {
const ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two')]))
const a = await ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent/README.md
README.md: f413abd855be17fde2e38997df8269d1ca2670b5
README.zh.md: 74797894177994cb3098aa67e969f45ced357634
README.md: b1333e96c1eaf83c4a5598a23bdae068e227c25f
README.zh.md: d15ce22b8f845e268fb935382ff16c7fd20b9c67
+8 -3
View File
@@ -84,14 +84,19 @@ The package is built on one separation: the public `Agent` surface and registry
`PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages, startsRequestSeries? }`. The enter branch contains the complete identified, frozen message batch. `startsRequestSeries: true` declares a distinct model-message series; a wrapping listener preserves that declaration and the batch unless it intentionally replaces either one. Claiming removes offered messages from the inbox, while messages inserted after the claim remain pending for a later boundary.
### Durable inbox
`Agent.inbox` exposes only the structural `Inbox` interface and the projection vocabulary stays in this package. dsh-agent-loop owns the package-internal `ReactLoopInbox` and the standard `inbox` projection; constructing its concrete inbox ensures that the projection registry owns one registration for the durable `agent/inbox/spliced` fold. The registry remains the sole owner of the live `{ 'next-turn', 'next-step' }` state. Reconstruction rejects unsafe or out-of-range splice coordinates and duplicate `MessageId` values across both pending lists and reports the offending event seq.
`Inbox` exposes pending `nextTurn` and `nextStep` messages and mutates them through `append`, `prepend`, `replace`, `remove`, `clear`, and `splice`. Ordinary removals and `clear()` are durable cancellations. At a step boundary, the loop's internal implementation claims pending input through pure deletion splices. Live notifications are deliberately per-message and minimal: `agent/inbox/inserted { message }`, `agent/inbox/claimed { message, turn }`, and `agent/inbox/discarded { message }`.
### Source map
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | Plugin entry: `AgentRegistry`, factory slot, initiator scope, `CreateAgentOptions`/`ResumeAgentOptions` |
| [`src/runtime-types.ts`](src/runtime-types.ts) | `Agent`, `AgentStatus`, and the `agent/*` event declarations |
| [`src/types.ts`](src/types.ts) | `AgentOptions`, cancellation causes, and inbox vocabulary |
| [`src/inbox.ts`](src/inbox.ts) | The `Inbox` projection over durable `agent/inbox/spliced` events |
| [`src/runtime-types.ts`](src/runtime-types.ts) | `Agent`, structural `Inbox`, `AgentStatus`, and the `agent/*` event declarations |
| [`src/types.ts`](src/types.ts) | `AgentOptions`, cancellation causes, and inbox projection vocabulary |
| [`src/dispatch.ts`](src/dispatch.ts) | `agentEvents` fused dispatcher and `assembleContextFor(agent)` |
| [`src/consumed-work.ts`](src/consumed-work.ts) | `foldConsumedWork(events)`: what the log's consumed work became |
| [`src/model-selection.ts`](src/model-selection.ts) | `installModelSelection`: coupling one selection to assembly and routing |
+8 -3
View File
@@ -84,14 +84,19 @@ await handle.agent.whenIdle()
`PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages, startsRequestSeries? }`。enter 分支包含完整、带标识且冻结的消息批次。`startsRequestSeries: true` 声明一个独立的模型消息序列;包装下游 enter 的监听器会保留该声明与批次,除非有意替换其中一项。领取会从 inbox 移除候选消息,领取后插入的消息则等待后续边界。
### 持久 inbox
`Agent.inbox` 只暴露结构化 `Inbox` 接口,投影词汇仍位于本包。dsh-agent-loop 持有包内部的 `ReactLoopInbox` 与标准 `inbox` 投影;构造具体 inbox 时会确保投影注册表为持久 `agent/inbox/spliced` fold 持有一份注册。注册表继续作为实时 `{ 'next-turn', 'next-step' }` 状态的唯一所有者。重建过程会拒绝不安全或越界的 splice 坐标,以及跨两份待处理列表重复的 `MessageId`,并报告出错事件的 seq。
`Inbox` 暴露待处理的 `nextTurn``nextStep` 消息,并通过 `append``prepend``replace``remove``clear``splice` 变更它们。普通删除和 `clear()` 都是持久取消。在步骤边界,循环的内部实现会通过纯删除 splice 领取待处理输入。实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserted { message }``agent/inbox/claimed { message, turn }``agent/inbox/discarded { message }`
### 源码地图
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | 插件入口:`AgentRegistry`、工厂槽位、发起方作用域、`CreateAgentOptions`/`ResumeAgentOptions` |
| [`src/runtime-types.ts`](src/runtime-types.ts) | `Agent``AgentStatus``agent/*` 事件声明 |
| [`src/types.ts`](src/types.ts) | `AgentOptions`、取消原因与收件箱词汇 |
| [`src/inbox.ts`](src/inbox.ts) | 持久 `agent/inbox/spliced` 事件之上的 `Inbox` 投影 |
| [`src/runtime-types.ts`](src/runtime-types.ts) | `Agent`结构化 `Inbox``AgentStatus``agent/*` 事件声明 |
| [`src/types.ts`](src/types.ts) | `AgentOptions`、取消原因与收件箱投影词汇 |
| [`src/dispatch.ts`](src/dispatch.ts) | `agentEvents` 融合分发器与 `assembleContextFor(agent)` |
| [`src/consumed-work.ts`](src/consumed-work.ts) | `foldConsumedWork(events)`:日志消费掉的工作最终怎样了 |
| [`src/model-selection.ts`](src/model-selection.ts) | `installModelSelection`:把一个选择耦合到组装与路由 |
+6 -4
View File
@@ -41,20 +41,22 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^"
"@deepseek-ai/dsh-util-values": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^"
"@deepseek-ai/dsh-util-values": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}
-220
View File
@@ -1,220 +0,0 @@
/**
* Incremental projection of durable agent inbox events.
*
* @module @deepseek-ai/dsh-agent/inbox
*/
import type { MessageId } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session'
import type { InboxTarget } from './types.ts'
/** Mutable state privately owned by an {@link Inbox}. */
type InboxState = Record<InboxTarget, UserMessage[]>
/** Live notifications committed by inbox mutations. */
export interface InboxNotifications {
/** Publish one inserted message. */
inserted(message: UserMessage): void
/** Publish one discarded message. */
discarded(message: UserMessage): void
/** Publish one claimed message inside its owning turn. */
claimed(message: UserMessage, turn: number): void
}
/** A replay-once projection that incrementally consumes later inbox splices. */
export class Inbox {
private readonly state: InboxState = { 'next-turn': [], 'next-step': [] }
constructor(
private readonly session: Session,
private readonly notifications: InboxNotifications,
) {
for (const event of session.ownEvents()) {
if (event.type !== 'agent/inbox/spliced') continue
try {
this.apply(event.data)
} catch (error: unknown) {
throw new Error(`invalid persisted inbox splice at session seq ${event.seq}`, { cause: error })
}
}
}
/** Prompts awaiting individual turns. */
get nextTurn(): readonly UserMessage[] {
return this.state['next-turn']
}
/** Input awaiting the next step boundary. */
get nextStep(): readonly UserMessage[] {
return this.state['next-step']
}
/** Whether either pending-message list contains work. */
get hasPending(): boolean {
return this.nextTurn.length > 0 || this.nextStep.length > 0
}
/** Durably cancel all pending input, clearing next-step before next-turn. */
clear(): void {
this.splice('next-step', 0, this.nextStep.length, [])
this.splice('next-turn', 0, this.nextTurn.length, [])
}
/**
* Remove and return the complete batch proposed for one step, publishing
* each claimed message. The durable splices are pure deletions.
* @param target - whether this boundary also consumes one queued turn.
* @param turn - turn that will own the claimed batch.
* @returns next-step input followed by the queued turn, when requested.
* @internal - The agent loop's step-boundary operation, not a plugin extension point.
*/
claim(target: InboxTarget, turn: number): UserMessage[] {
const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false)
if (target === 'next-turn') {
claimed.push(...this.mutate('next-turn', 0, 1, [], false))
}
for (const message of claimed) this.notifications.claimed(message, turn)
return claimed
}
/**
* Append one message to a pending list and durably record the insertion.
* @param target - pending list to extend.
* @param message - message to append.
* @throws if the message identity is already pending.
*/
append(target: InboxTarget, message: UserMessage): void {
this.splice(target, this.state[target].length, 0, [message])
}
/**
* Prepend one message to a pending list and durably record the insertion.
* @param target - pending list to extend.
* @param message - message to prepend.
* @throws if the message identity is already pending.
*/
prepend(target: InboxTarget, message: UserMessage): void {
this.splice(target, 0, 0, [message])
}
/**
* Replace one pending message in place, possibly changing its identity. A
* successful replacement publishes the old message as discarded and the new
* message as inserted.
* @param messageId - identity of the pending message to replace.
* @param newMessage - replacement message.
* @returns whether the message was still pending.
* @throws if the replacement duplicates another pending message identity.
*/
replace(messageId: MessageId, newMessage: UserMessage): boolean {
const location = this.locate(messageId)
if (location === undefined) return false
this.splice(location.target, location.index, 1, [newMessage])
return true
}
/**
* Remove one pending message and durably record its cancellation.
* @param messageId - identity of the pending message to remove.
* @returns whether the message was still pending.
*/
remove(messageId: MessageId): boolean {
const location = this.locate(messageId)
if (location === undefined) return false
this.splice(location.target, location.index, 1, [])
return true
}
/**
* Apply standard splice semantics and durably record the normalized result.
* The durable event commits before the live projection mutates, so synchronous
* `session/event` observers see the pre-splice lists and can reconstruct the
* removed messages from the normalized coordinates.
* @param target - pending list to mutate.
* @param start - splice position.
* @param deleteCount - maximum number of messages to remove.
* @param inserted - messages to insert at the resolved position.
* @returns messages removed by the splice.
*/
splice(
target: InboxTarget,
start: number,
deleteCount: number,
inserted: UserMessage[],
): UserMessage[] {
return this.mutate(target, start, deleteCount, inserted, true)
}
/** Locate one pending identity across both owned lists. */
private locate(messageId: MessageId): { target: InboxTarget; index: number } | undefined {
for (const target of ['next-turn', 'next-step'] as const) {
const index = this.state[target].findIndex(message => message.id === messageId)
if (index >= 0) return { target, index }
}
return undefined
}
/** Commit one normalized mutation and publish its live notifications. */
private mutate(
target: InboxTarget,
start: number,
deleteCount: number,
inserted: UserMessage[],
discardRemoved: boolean,
): UserMessage[] {
const inbox = this.state[target]
const truncatedStart = Math.trunc(start)
const offset = Number.isNaN(truncatedStart) ? 0 : truncatedStart
const actualStart = offset < 0
? Math.max(inbox.length + offset, 0)
: Math.min(offset, inbox.length)
const truncatedDeleteCount = Math.trunc(deleteCount)
const actualDeleteCount = Math.min(
Math.max(Number.isNaN(truncatedDeleteCount) ? 0 : truncatedDeleteCount, 0),
inbox.length - actualStart,
)
if (actualDeleteCount === 0 && inserted.length === 0) return []
const outcome = discardRemoved && actualDeleteCount > 0 ? 'canceled' as const : undefined
const splice = {
target,
start: actualStart,
...(actualDeleteCount === 0 ? {} : { removedCount: actualDeleteCount }),
inserted,
...(outcome === undefined ? {} : { outcome }),
}
this.validate(splice)
const event = this.session.append('agent/inbox/spliced', splice)
const removed = inbox.splice(actualStart, actualDeleteCount, ...event.data.inserted)
if (discardRemoved) {
for (const message of removed) this.notifications.discarded(message)
}
for (const message of event.data.inserted) this.notifications.inserted(message)
return removed
}
/** Apply one normalized durable splice to the projection. */
private apply(splice: SessionEventMap['agent/inbox/spliced']): UserMessage[] {
this.validate(splice)
const inbox = this.state[splice.target]
return inbox.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted)
}
/** Validate one normalized splice against the current projection. */
private validate(splice: SessionEventMap['agent/inbox/spliced']): void {
const inbox = this.state[splice.target]
const removedCount = splice.removedCount ?? 0
if (!Number.isSafeInteger(splice.start) || splice.start < 0 || splice.start > inbox.length
|| !Number.isSafeInteger(removedCount) || removedCount < 0
|| splice.start + removedCount > inbox.length) {
throw new Error('invalid inbox splice')
}
const candidate = inbox.toSpliced(splice.start, removedCount, ...splice.inserted)
const ids = new Set<string>()
for (const message of splice.target === 'next-turn'
? [...candidate, ...this.nextStep]
: [...this.nextTurn, ...candidate]) {
if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`)
ids.add(message.id)
}
}
}
-1
View File
@@ -18,7 +18,6 @@ import type { AgentOptions } from './runtime-types.ts'
export * from './runtime-types.ts'
export * from './types.ts'
export type * from './projection.ts'
export * from './inbox.ts'
export * from './consumed-work.ts'
export * from './model-selection.ts'
export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts'
+58 -4
View File
@@ -8,12 +8,11 @@
import type { Context } from '@deepseek-ai/cordis'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type {
LlmAttemptId, LlmCallConfig, LlmFailure, ReasoningEffortId, ResolvedRetryPolicy, StreamChunk,
LlmAttemptId, LlmCallConfig, LlmFailure, MessageId, ReasoningEffortId, ResolvedRetryPolicy, StreamChunk,
} from '@deepseek-ai/dsh-llm'
import type { AgentCancelCause, Session, SessionSeq, UserMessage } from '@deepseek-ai/dsh-session'
export type { AgentCancelCause } from '@deepseek-ai/dsh-session'
import type { Inbox } from './inbox.ts'
import type { Agent } from './types.ts'
import type { Agent, InboxTarget } from './types.ts'
export type { Agent } from './types.ts'
import type {} from '@deepseek-ai/dsh-system-prompt'
declare module '@deepseek-ai/dsh-system-prompt' {
@@ -45,6 +44,61 @@ export interface CancelOptions {
keepInbox?: boolean | undefined
}
/** Agent-owned access to pending work; concrete storage belongs to the driver. */
export interface Inbox {
/** Prompts awaiting individual turns. */
readonly nextTurn: readonly UserMessage[]
/** Input awaiting the next step boundary. */
readonly nextStep: readonly UserMessage[]
/** Durably cancel all pending input, clearing next-step before next-turn. */
clear(): void
/**
* Append one message to a pending list.
* @param target - pending list to extend.
* @param message - message to append.
*/
append(target: InboxTarget, message: UserMessage): void
/**
* Prepend one message to a pending list.
* @param target - pending list to extend.
* @param message - message to prepend.
*/
prepend(target: InboxTarget, message: UserMessage): void
/**
* Replace one pending message in place.
* @param messageId - identity of the pending message to replace.
* @param newMessage - replacement message.
* @returns whether the message was still pending.
*/
replace(messageId: MessageId, newMessage: UserMessage): boolean
/**
* Remove one pending message.
* @param messageId - identity of the pending message to remove.
* @returns whether the message was still pending.
*/
remove(messageId: MessageId): boolean
/**
* Apply standard splice semantics and durably record the normalized result.
* @param target - pending list to mutate.
* @param start - splice position.
* @param deleteCount - maximum number of messages to remove.
* @param inserted - messages to insert at the resolved position.
* @returns messages removed by the splice.
*/
splice(
target: InboxTarget,
start: number,
deleteCount: number,
inserted: UserMessage[],
): UserMessage[]
}
/**
* An agent's lifecycle state, emitted on every transition as `agent/status`:
* `idle` means no driver is active; `running` begins when waking input starts
@@ -112,7 +166,7 @@ declare module './types.ts' {
readonly options: AgentOptions
/** The live session this agent drives; its log is the durable source of truth. */
readonly session: Session
/** The agent-owned projection of durable pending work. */
/** Agent-owned access to durable pending work. */
readonly inbox: Inbox
/** The current lifecycle state, mirrored on every `agent/status` transition. */
readonly status: AgentStatus
+31 -2
View File
@@ -7,6 +7,7 @@
import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
import type { OptionalSessionSeq, SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types'
import type { TypertContext, TypertLookup } from '@deepseek-ai/dsh-typert-protocol'
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
/** Public live-agent handle; the runtime face augments its live capabilities. */
export interface Agent {
@@ -28,6 +29,34 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
/** One of the two ordered pending-message lists owned by an agent. */
export type InboxTarget = 'next-turn' | 'next-step'
/** Complete pending Inbox value reconstructed from durable splices. */
export interface InboxState {
readonly 'next-turn': readonly UserMessage[]
readonly 'next-step': readonly UserMessage[]
}
/**
* Wire-JSON pending Inbox value. Each message round-trips the session log
* losslessly, but the fold state's full `UserMessage` type cannot cross a
* typert Remote boundary (its source union carries an `unknown` replay
* field), so the typed projection table keeps this JSON-safe form.
*/
export interface InboxWireState {
readonly 'next-turn': readonly JsonValue[]
readonly 'next-step': readonly JsonValue[]
}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
/** Pending agent input reconstructed from durable inbox splices. */
inbox: InboxState
}
interface SessionProjectionMap {
/** Pending agent input reconstructed from durable inbox splices. */
inbox: InboxWireState
}
}
/**
* Turn and step boundaries folded from one agent session log.
*
@@ -52,8 +81,8 @@ declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
/**
* One normalized mutation of an agent's durable pending-message lists.
* Live dispatch precedes projection mutation, so synchronous observers may
* read the pre-splice inbox to recover the removed messages.
* The session-projection registry applies the committed event before
* `Session.append()` returns; Inbox live notifications follow that commit.
*/
'agent/inbox/spliced': {
target: InboxTarget
+10 -114
View File
@@ -1,11 +1,7 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context, Service, symbols } from '@deepseek-ai/cordis'
import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
import AgentRegistry, {
agentEvents,
Inbox,
} from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import type {
@@ -19,14 +15,17 @@ import type {
function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
const id = SessionId(rawId)
const session = Session.create(id)
const session = overrides.session ?? Session.create(id)
const ctx = overrides.ctx ?? new Context()
const agent: Agent = {
id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
inbox: {
nextTurn: [], nextStep: [],
} as never,
status: 'idle',
ctx: new Context(),
ctx,
send: () => {},
followup: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
@@ -34,114 +33,11 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
...overrides,
}
return Object.assign(agent, overrides)
return agent
}
describe('Inbox', () => {
it('rejects an invalid durable splice during reconstruction', () => {
const session = Session.create(SessionId('invalid-inbox-replay'))
session.append('agent/inbox/spliced', {
target: 'next-turn',
start: 1,
inserted: [],
})
expect(() => new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }))
.toThrow('invalid persisted inbox splice at session seq 0')
})
it('replaces a pending message by identity across both lists', () => {
const session = Session.create(SessionId('replace-inbox'))
const inserted: UserMessage[] = []
const discarded: UserMessage[] = []
const inbox = new Inbox(session, {
claimed: () => {},
inserted: message => void inserted.push(message),
discarded: message => void discarded.push(message),
})
const original = createUserMessage({
content: [{ type: 'text', text: 'original' }],
source: { kind: 'user' },
})
const nextStep = createUserMessage({
content: [{ type: 'text', text: 'step' }],
source: { kind: 'user' },
})
const replacement = createUserMessage({
content: [{ type: 'text', text: 'replacement' }],
source: { kind: 'user' },
})
const editedStep = freezeMessage({
...nextStep,
content: [{ type: 'text', text: 'edited step' }],
})
inbox.append('next-turn', original)
inbox.append('next-step', nextStep)
expect(inbox.replace(createUserMessage({
content: [{ type: 'text', text: 'missing' }],
source: { kind: 'user' },
}).id, replacement)).toBe(false)
expect(inbox.replace(original.id, replacement)).toBe(true)
expect(inbox.replace(nextStep.id, editedStep)).toBe(true)
expect(inbox.nextTurn).toEqual([replacement])
expect(inbox.nextStep).toEqual([editedStep])
expect(discarded).toEqual([original, nextStep])
expect(inserted).toEqual([original, nextStep, replacement, editedStep])
expect(() => { inbox.replace(editedStep.id, replacement) })
.toThrow(`message "${replacement.id}" is already pending`)
})
it('normalizes splice coordinates, rejects duplicate identities, and reports missing removals', () => {
const session = Session.create(SessionId('splice-inbox'))
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const first = createUserMessage({
content: [{ type: 'text', text: 'first' }],
source: { kind: 'user' },
})
const second = createUserMessage({
content: [{ type: 'text', text: 'second' }],
source: { kind: 'user' },
})
inbox.splice('next-turn', Number.NaN, Number.NaN, [first, second])
expect(inbox.nextTurn).toEqual([first, second])
expect(inbox.splice('next-turn', -1, 1, [])).toEqual([second])
expect(inbox.remove(second.id)).toBe(false)
expect(() => { inbox.append('next-step', first) }).toThrow(`message "${first.id}" is already pending`)
})
it('clears both pending lists as durable cancellations', () => {
const session = Session.create(SessionId('clear-inbox'))
const discarded: UserMessage[] = []
const inbox = new Inbox(session, {
claimed: () => {},
inserted: () => {},
discarded: message => void discarded.push(message),
})
const nextTurn = createUserMessage({ content: [{ type: 'text', text: 'turn' }], source: { kind: 'user' } })
const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } })
inbox.append('next-turn', nextTurn)
inbox.append('next-step', nextStep)
const beforeClear = session.snapshotEvents().length
inbox.clear()
expect(inbox.hasPending).toBe(false)
expect(discarded).toEqual([nextStep, nextTurn])
expect(session.snapshotEvents().slice(beforeClear).map(event => event.type === 'agent/inbox/spliced'
? event.data
: event.type)).toEqual([
{ target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' },
{ target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' },
])
inbox.clear()
expect(session.snapshotEvents()).toHaveLength(beforeClear + 2)
})
})
describe('AgentRegistry', () => {
it('contributes Agent lookup and scoped Context providers while Typert is live', async () => {
const ctx = new Context()
+3 -3
View File
@@ -23,15 +23,15 @@
{
"path": "../../core/session"
},
{
"path": "../../session/session-projection"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../runtime-diagnostics/invariants"
},
{
"path": "../../session/session-projection"
},
{
"path": "../../typert/protocol"
}
+1
View File
@@ -37,6 +37,7 @@
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-fs-e2b": "workspace:^",
+2 -2
View File
@@ -3,7 +3,6 @@ import { join, posix } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
import {
@@ -17,6 +16,7 @@ import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import E2BSubprocessRuntime from '@deepseek-ai/dsh-subprocess-e2b'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
const fixtureRoot = fileURLToPath(new URL('./fixtures/composition/', import.meta.url))
const binScript = join(fixtureRoot, 'bin.ts')
@@ -87,7 +87,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
id: ownerId,
options: {},
session: ownerSession,
inbox: new Inbox(ownerSession, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
inbox: unsupportedInbox(),
status: 'idle',
ctx,
send() {},
+14 -3
View File
@@ -1,8 +1,7 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { boot } from '@deepseek-ai/dsh-app-boot'
import { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { type Agent } from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-fs-e2b'
import type {} from '@deepseek-ai/dsh-bash-local'
@@ -16,11 +15,23 @@ const ctx = await boot('e2b-composition', resolve(configPath))
const ownerFiber = ctx.plugin(() => {})
const ownerId = SessionId('e2b-live-owner')
const session = Session.create(ownerId)
const unsupportedInboxMutation = (): never => {
throw new Error('the E2B composition owner does not support Inbox mutations')
}
const owner: Agent = {
id: ownerId,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
inbox: {
nextTurn: [],
nextStep: [],
clear: unsupportedInboxMutation,
append: unsupportedInboxMutation,
prepend: unsupportedInboxMutation,
replace: unsupportedInboxMutation,
remove: unsupportedInboxMutation,
splice: unsupportedInboxMutation,
},
status: 'idle',
ctx: ownerFiber.ctx,
send() {},
@@ -8,9 +8,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SubagentService, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
@@ -102,7 +100,6 @@ async function stack(
const ctx = new Context()
contexts.add(ctx)
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
await backend.mount(ctx, root)
await ctx.plugin(TestSessionQuery)
await ctx.plugin(AgentLoop, { agents: [] })
@@ -8,7 +8,6 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionLogOffset, SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SubagentService from '@deepseek-ai/dsh-subagent'
import { deliverSubagentPrompt, type HostPromptDeliverer } from '@deepseek-ai/dsh-subagent/internal'
@@ -62,7 +61,6 @@ async function setup(
) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-'))
roots.push(storageRoot)
await ctx.plugin(JsonlSessionPersistence, { root: storageRoot })
@@ -170,7 +168,6 @@ describe('Team identity and provisioning', () => {
it('supports direct-constructor defaults and recovers roots that already exist', async () => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-direct-'))
roots.push(storageRoot)
await ctx.plugin(JsonlSessionPersistence, { root: storageRoot })
@@ -1411,7 +1408,6 @@ describe('Team mailbox and waiting', () => {
it('waits for one change, supports cancellation, times out, and releases waiters on HMR disposal', async () => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-wait-'))
roots.push(storageRoot)
await ctx.plugin(JsonlSessionPersistence, { root: storageRoot })
@@ -9,7 +9,6 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { scopeOf } from '@deepseek-ai/dsh-scope'
import { SessionId } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
import SubagentService from '@deepseek-ai/dsh-subagent'
@@ -56,7 +55,6 @@ afterEach(() => {
async function setup(script: ConstructorParameters<typeof MockAdapter>[0], legacyControl = false) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-tool-team-'))
roots.push(storageRoot)
await ctx.plugin(JsonlSessionPersistence, { root: storageRoot })
@@ -36,6 +36,7 @@
"@deepseek-ai/cordis-plugin-include": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
@@ -1,11 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import CommandRuntime from '@deepseek-ai/dsh-commands'
import SessionStore, { foldSurface, Session, SessionId } from '@deepseek-ai/dsh-session'
import * as commandFeedback from '@deepseek-ai/dsh-command-feedback'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
const { USER_ID, getOrCreateAnonymousUserId } = vi.hoisted(() => {
const USER_ID = '01234567-89ab-4cde-8f01-23456789abcd'
@@ -28,13 +29,12 @@ interface Harness {
/** Build a live idle agent over a store-owned session, as an app's spine does. */
function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } {
const session = ctx.sessions.create(SessionId(id))
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
let status: AgentStatus = 'idle'
const agent: Agent = {
id: session.id,
options: {},
session,
inbox,
inbox: unsupportedInbox(),
ctx: new Context(),
get status() { return status },
send: () => {},
@@ -6,12 +6,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import CommandRuntime from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import * as CommandFeedback from '@deepseek-ai/dsh-command-feedback'
import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
let root: string | undefined
let context: Context | undefined
@@ -29,13 +30,12 @@ function agent(ctx: Context): Agent {
const scope = ctx.plugin(() => {})
const id = SessionId('feedback-loader-agent')
const session = ctx.sessions.create(id)
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
let status: AgentStatus = 'idle'
const value: Agent = {
id,
options: {},
session,
inbox,
inbox: unsupportedInbox(),
ctx: scope.ctx,
get status() { return status },
send: () => {},
-2
View File
@@ -1,7 +1,6 @@
import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy'
@@ -15,7 +14,6 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
*/
export async function fsHarness(fsCwd: string, persona = ''): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
await mountAgentLoopTestDependencies(ctx, { systemPrompt: { personaPrefix: persona } })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek)
@@ -37,6 +37,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-observation-policy": "workspace:^",
@@ -6,7 +6,7 @@ import { Context } from '@deepseek-ai/cordis'
import { FsVersion } from '@deepseek-ai/dsh-fs'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy'
@@ -16,6 +16,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
const contexts: Context[] = []
const roots: string[] = []
@@ -36,7 +37,7 @@ function agent(ctx: Context, cwd: string): Agent {
id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
inbox: unsupportedInbox(),
status: 'idle',
ctx: scope.ctx,
send: () => {},
+2
View File
@@ -35,6 +35,8 @@
"devDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import CommandRuntime from '@deepseek-ai/dsh-commands'
import GoalService from '@deepseek-ai/dsh-goal'
@@ -9,6 +9,7 @@ import type { GoalRef } from '@deepseek-ai/dsh-goal'
import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
interface Harness {
readonly ctx: Context
@@ -21,7 +22,7 @@ interface Harness {
function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } {
// Store-created: the command executor durably logs lifecycle events on it.
const session = ctx.sessions.create(SessionId(id))
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const inbox = createInboxStub()
let status: AgentStatus = 'idle'
const agent: Agent = {
id: session.id,
@@ -33,7 +34,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session }
send: () => {},
followup: () => {},
steer: () => {},
inject(input) { inbox.append('next-step', input) },
inject(input) { this.inbox.append('next-step', input) },
cancel() { status = 'idle' },
runMaintenance: task => task(new AbortController().signal),
whenIdle() { return Promise.resolve() },
@@ -45,9 +46,9 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session }
async function harness(): Promise<Harness> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(CommandRuntime)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(GoalService)
const plugin = await ctx.plugin(commandGoal)
const { agent, session } = stubAgent(ctx, `command-goal-${Math.random()}`)
@@ -10,7 +10,6 @@ import { createUserMessage, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import * as goalSession from '../src/index.ts'
type ScriptEntry = StreamChunk[] | Error | 'hang' | ((options: GenerateOptions) => StreamChunk[])
@@ -90,7 +89,6 @@ async function harness(script: ScriptEntry[]): Promise<Harness> {
const ctx = new Context()
contexts.push(ctx)
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(GoalService)
const driver = await ctx.plugin(goalSession)
await ctx.plugin(AgentLoop, { agents: [] })
@@ -217,7 +215,6 @@ describe('same-session goal driving', () => {
const ctx = new Context()
contexts.push(ctx)
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(GoalService)
await ctx.plugin(AgentLoop, { agents: [] })
const adapter = new ScriptedAdapter([textResponse('after resume')])
+2
View File
@@ -69,6 +69,8 @@
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
+65 -18
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
@@ -12,12 +12,19 @@ import GoalService, {
foldGoal,
} from '@deepseek-ai/dsh-goal'
import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
interface StubAgent {
agent: Agent
session: Session
}
const isolatedInboxCtx = new Context()
await isolatedInboxCtx.plugin(SessionStore)
await isolatedInboxCtx.plugin(SessionProjectionRegistry)
await isolatedInboxCtx.plugin(AgentRegistry)
const sessionStubs = new WeakMap<Session, StubAgent>()
/** Number the next balanced test-fixture turn. */
function nextTurn(session: Session): number {
return session.snapshotEvents().reduce((max, event) => event.type === 'turn/start' ? Math.max(max, event.data.turn) : max, 0) + 1
@@ -25,46 +32,61 @@ function nextTurn(session: Session): number {
/** Mirror the public Agent.inject contract for domain tests. */
function appendInjection(session: Session, input: UserMessage): void {
new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }).append('next-step', input)
stubAgentForSession(session).agent.inbox.append('next-step', input)
}
/** Build a registry-compatible agent around one concrete session. */
function stubAgentForSession(session: Session): StubAgent {
function stubAgentForSession(session: Session, suppliedCtx?: Context): StubAgent {
const existing = sessionStubs.get(session)
if (existing !== undefined) return existing
const id = session.id
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const agentCtx = suppliedCtx ?? isolatedInboxCtx
if (suppliedCtx === undefined) {
agentCtx.sessions.enter(session)
}
const inbox = createInboxStub()
const agent: Agent = {
id,
options: {},
session,
inbox,
ctx: new Context(),
ctx: agentCtx,
status: 'idle',
send: () => {},
followup: () => {},
steer: () => {},
inject(input) { inbox.append('next-step', input) },
inject(input) { this.inbox.append('next-step', input) },
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle() { return Promise.resolve() },
}
return {
const stub = {
agent,
session,
}
sessionStubs.set(session, stub)
return stub
}
/** Build a registry-compatible agent around a fresh session. */
function stubAgent(rawId: string, seed?: readonly import('@deepseek-ai/dsh-session').SessionEvent[]): StubAgent {
return stubAgentForSession(Session.create(SessionId(rawId), seed))
function stubAgent(
rawId: string,
seed?: readonly import('@deepseek-ai/dsh-session').SessionEvent[],
ctx?: Context,
): StubAgent {
const session = ctx === undefined
? Session.create(SessionId(rawId), seed)
: ctx.sessions.create(SessionId(rawId), { ...(seed === undefined ? {} : { seed }) })
return stubAgentForSession(session, ctx)
}
async function harness(config: { defaultMaxGoalRounds?: number } = {}) {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService, config)
const stub = stubAgentForSession(ctx.sessions.create(SessionId(`goal-test-${Math.random()}`)))
const stub = stubAgent(`goal-test-${Math.random()}`, undefined, ctx)
ctx.agents.register(stub.agent)
return { ctx, ...stub }
}
@@ -182,15 +204,15 @@ describe('GoalService creation and replay', () => {
it('inherits the completed-turn goal prefix through SessionStore.fork with child activation disarmed', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
const parent = stubAgentForSession(ctx.sessions.create(SessionId('goal-fork-parent')))
const parent = stubAgentForSession(ctx.sessions.create(SessionId('goal-fork-parent')), ctx)
ctx.agents.register(parent.agent)
const goal = ctx.goals.create(parent.agent, { objective: 'inherit through fork', maxGoalRounds: 5 })
appendRound(parent.session, goal, 1)
const child = stubAgentForSession(ctx.sessions.fork(parent.session))
const child = stubAgentForSession(ctx.sessions.fork(parent.session), ctx)
ctx.agents.register(child.agent)
expect(ctx.goals.get(child.agent)).toMatchObject({
id: goal.id,
@@ -252,7 +274,7 @@ describe('GoalService creation and replay', () => {
const { ctx, agent } = await harness()
// A same-id agent backed by a different session object — the live-instance
// check must reject it even though the ids match.
const impostor = stubAgentForSession(Session.create(agent.id)).agent
const impostor = { ...agent, session: Session.create(agent.id) } as Agent
expect(() => ctx.goals.get(impostor)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' }))
expect(() => ctx.goals.create(impostor, { objective: 'no' })).toThrow(expect.objectContaining({
code: 'GOAL_AGENT_NOT_LIVE',
@@ -439,10 +461,10 @@ describe('GoalService mutations', () => {
it('publishes a mutation consistently to a reentrant session observer', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
const stub = stubAgentForSession(ctx.sessions.create(SessionId('goal-reentrant-observer')))
const stub = stubAgentForSession(ctx.sessions.create(SessionId('goal-reentrant-observer')), ctx)
ctx.agents.register(stub.agent)
let observed: ReturnType<GoalService['get']>
ctx.on('session/event', (session, event) => {
@@ -501,6 +523,31 @@ describe('GoalService mutations', () => {
})
})
it('rejects a corrupt append while preserving the valid prefix', async () => {
const { ctx, agent, session } = await harness()
expect(ctx.goals.get(agent)).toBeUndefined()
const change: GoalSnapshotChangeMeta = {
kind: 'goal/change',
version: 1,
operation: 'create',
goal: {
id: GoalId('goal-valid-prefix'),
revision: 1,
objective: 'valid prefix',
phase: 'active',
maxGoalRounds: 4,
},
roundsStarted: 0,
createdAt: 12,
updatedAt: 12,
}
session.append('goal/change', change)
expect(() => {
session.append('goal/change', { ...change, operation: 'edit', extra: true } as never)
}).toThrow('snapshot change must have exactly')
expect(ctx.goals.get(agent)).toMatchObject({ id: change.goal.id, objective: 'valid prefix' })
})
})
describe('goal replay validation', () => {
@@ -566,7 +613,7 @@ describe('goal replay validation', () => {
content: [{ type: 'text', text: 'unrelated pending context' }],
source: { kind: 'plugin', plugin: 'test' },
})
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const inbox = stubAgentForSession(session).agent.inbox
inbox.append('next-step', message)
expect(inbox.remove(message.id)).toBe(true)
expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { id: change.goal.id, revision: 1 } })
+14 -13
View File
@@ -10,15 +10,15 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import GoalService, { GoalId, applyGoalProjection, foldGoal, goalProjectionDefinition } from '@deepseek-ai/dsh-goal'
import type { GoalProjection, GoalProjectionState, GoalRef } from '@deepseek-ai/dsh-goal'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
interface Bench {
ctx: Context
@@ -31,20 +31,17 @@ interface Bench {
/** Register a minimal registry-compatible live agent over a store session. */
function liveAgent(ctx: Context, session: Session): Agent {
const status: AgentStatus = 'idle'
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const agent: Agent = {
id: session.id,
options: {},
session,
inbox,
inbox: unsupportedInbox(),
ctx,
get status() { return status },
send: () => {},
followup: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject(input: UserMessage) {
inbox.append('next-step', input)
},
inject: () => { throw new Error('goal projection tests do not inject model context') },
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle() { return Promise.resolve() },
@@ -82,7 +79,7 @@ describe('goal projection unit', () => {
it('serves null before the first create', async () => {
const bench = await harness(true)
seedMessage(bench.session)
expect(bench.tailValues()).toEqual({ goal: null })
expect(bench.tailValues().goal).toBeNull()
expect(bench.tailAsOfSeq()).toBe(bench.session.seq - 1)
})
@@ -130,10 +127,14 @@ describe('goal projection unit', () => {
const created = bench.ctx.goals.create(bench.agent, { objective: 'stay cleared' })
bench.ctx.goals.clear(bench.agent, created)
bench.agent.inbox.prepend('next-step', createUserMessage({
content: [{ type: 'text', text: 'unrelated pending context' }],
source: { kind: 'plugin', plugin: 'test' },
}))
bench.session.append('agent/inbox/spliced', {
target: 'next-step',
start: 0,
inserted: [createUserMessage({
content: [{ type: 'text', text: 'unrelated pending context' }],
source: { kind: 'plugin', plugin: 'test' },
})],
})
expect(bench.tailValues().goal).toBeNull()
expect(foldGoal(bench.session.snapshotEvents()).goal).toBeUndefined()
@@ -240,7 +241,7 @@ describe('goal projection unit', () => {
const bench = await harness(false)
seedMessage(bench.session)
const fiber = await bench.ctx.plugin(GoalService)
expect(bench.tailValues()).toEqual({ goal: null })
expect(bench.tailValues().goal).toBeNull()
await fiber.dispose()
expect('goal' in (bench.tailValues() ?? {})).toBe(false)
})
+1
View File
@@ -42,6 +42,7 @@
"devDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
+29 -14
View File
@@ -1,44 +1,58 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus, Inbox } from '@deepseek-ai/dsh-agent'
import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
import GoalService, { GoalId } from '@deepseek-ai/dsh-goal'
import type { GoalRef } from '@deepseek-ai/dsh-goal'
import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import {
import SessionStore, {
SESSION_FORMAT_VERSION,
Session,
SessionId,
SessionLogOffset,
} from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import * as toolGoal from '@deepseek-ai/dsh-tool-goal'
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
const testToolSignal = new AbortController().signal
interface StubAgent {
readonly agent: Agent
readonly session: Session
readonly inbox: Inbox
setStatus(status: AgentStatus): void
}
/** Build one registry-compatible live agent whose injections enter the durable inbox. */
function stubAgent(rawId: string, supplied?: Session): StubAgent {
const session = supplied ?? Session.create(SessionId(rawId))
const isolatedInboxCtx = new Context()
await isolatedInboxCtx.plugin(SessionStore)
await isolatedInboxCtx.plugin(SessionProjectionRegistry)
await isolatedInboxCtx.plugin(AgentRegistry)
/** Build one registry-compatible live agent whose injections enter its test Inbox. */
function stubAgent(rawId: string, supplied?: Session, suppliedCtx?: Context): StubAgent {
const agentCtx = suppliedCtx ?? isolatedInboxCtx
const session = supplied ?? (suppliedCtx === undefined
? agentCtx.sessions.create(SessionId(rawId))
: suppliedCtx.sessions.create(SessionId(rawId)))
if (suppliedCtx === undefined) {
if (agentCtx.sessions.get(session.id) !== session) agentCtx.sessions.enter(session)
}
const inbox = createInboxStub()
let status: AgentStatus = 'running'
const agent: Agent = {
id: session.id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
inbox,
get status() { return status },
ctx: new Context(),
ctx: agentCtx,
send: () => {},
followup: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
@@ -49,7 +63,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
runMaintenance: task => task(new AbortController().signal),
whenIdle() { return Promise.resolve() },
}
return { agent, session, setStatus(value) { status = value } }
return { agent, session, inbox, setStatus(value) { status = value } }
}
/** Open one message-triggered turn with its accepted model-visible input. */
@@ -62,7 +76,7 @@ function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): numb
source,
})
stub.agent.inbox.append('next-turn', message)
const claimed = stub.agent.inbox.claim('next-turn', turn)
const claimed = stub.inbox.splice('next-turn', 0, 1, [])
if (claimed.length === 0) throw new Error('expected queued turn input')
stub.session.append('turn/start', { turn })
for (const admitted of claimed) {
@@ -78,14 +92,15 @@ function closeTurn(stub: StubAgent, turn: number): void {
async function harness(config: toolGoal.Config = {}) {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SystemPrompt)
await ctx.plugin(AgentRegistry)
await ctx.plugin(ToolRuntime)
await ctx.plugin(SessionProjectionRegistry)
ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
await ctx.plugin(GoalService)
const fiber = await ctx.plugin(toolGoal, config)
const root = stubAgent(`goal-tool-root-${Math.random()}`)
const root = stubAgent(`goal-tool-root-${Math.random()}`, undefined, ctx)
ctx.agents.register(root.agent)
return { ctx, fiber, root }
}
@@ -252,7 +267,7 @@ describe('goal tool execution authority', () => {
openTurn(root, { kind: 'user' })
// A distinct agent object over root's exact session: same id, not the live
// registered instance, so the executor must reject it.
const stale = stubAgent('goal-tool-stale', root.agent.session).agent
const stale = stubAgent('goal-tool-stale', root.agent.session, ctx).agent
const staleResult = await execute(ctx, 'get_goal', {}, stale, stale)
expect(staleResult.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
@@ -6,7 +6,6 @@ import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-reminder'
import type { Config } from '@deepseek-ai/dsh-repeat-tool-reminder'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -25,8 +24,6 @@ const testToolSignal = new AbortController().signal
async function harness(config: Config = {}): Promise<Context> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
// AgentLoop declares the registry as a required injection.
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(RepeatToolGuard, config)
ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'p', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
@@ -373,7 +370,6 @@ describe('config validation fails loud', () => {
async function spine(): Promise<Context> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
return ctx
}
@@ -15,7 +15,6 @@ import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import SubagentRuntime, { SubagentRunId } from '@deepseek-ai/dsh-subagent'
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude-code'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
/**
@@ -58,7 +57,6 @@ async function harnessWithFiber(
): Promise<{ ctx: Context; hooks: Fiber }> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
@@ -356,7 +354,6 @@ describe('hooks-claude-code bridge — load resilience', () => {
const adapter = new MockAdapter([textResponse('fine')])
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
@@ -417,7 +414,6 @@ describe('hooks-claude-code bridge — load resilience', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
@@ -15,7 +15,6 @@ import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import SubagentRuntime, { SubagentRunId } from '@deepseek-ai/dsh-subagent'
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude-code'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
const testToolSignal = new AbortController().signal
@@ -42,7 +41,6 @@ type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxC
async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise<Context> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
if (opts.sessionRoot !== undefined) await ctx.plugin(JsonlSessionPersistence, { root: opts.sessionRoot })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessRuntime)
@@ -363,7 +361,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
@@ -663,7 +660,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
// Executor default cwd = serverDir (deliberately NOT the session cwd).
await ctx.plugin(LocalSubprocessRuntime)
@@ -693,7 +689,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'cat > stoppayload.tmp; mv stoppayload.tmp stoppayload; pwd > stopwhere' }] }] })
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
// Executor default cwd = serverDir (deliberately NOT the child session cwd).
await ctx.plugin(LocalSubprocessRuntime)
@@ -13,7 +13,6 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
/**
@@ -43,7 +42,6 @@ function writeHooks(dir: string, hooks: unknown): void {
async function harness(dir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise<Context> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
@@ -184,7 +182,6 @@ describe('hooks-codex bridge', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
@@ -208,7 +205,6 @@ describe('hooks-codex bridge', () => {
writeHooks(dir, { SessionStart: [{ hooks: [{ type: 'command', command: slow }] }] })
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
@@ -13,7 +13,6 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
const testToolSignal = new AbortController().signal
@@ -32,7 +31,6 @@ type HarnessOpts = { stderrSummaryMaxChars?: number; sessionRoot?: string }
async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise<Context> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
if (opts.sessionRoot !== undefined) await ctx.plugin(JsonlSessionPersistence, { root: opts.sessionRoot })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessRuntime)
@@ -309,7 +307,6 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([textResponse('ok')])
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
@@ -620,7 +617,6 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
+1
View File
@@ -40,6 +40,7 @@
"@deepseek-ai/cordis-plugin-include": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
+4 -3
View File
@@ -1,13 +1,14 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
import { JobId } from '@deepseek-ai/dsh-jobs'
import type { JobHooks, JobKind, JobOutcome, JobSnapshot, JobStart } from '@deepseek-ai/dsh-jobs'
import LocalJobRegistry, { type Config as JobsConfig } from '@deepseek-ai/dsh-jobs-local'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
declare module '@deepseek-ai/dsh-jobs' {
interface JobKindMap {
@@ -34,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent {
id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
inbox: unsupportedInbox(),
status: 'idle' as const,
ctx: agentCtx,
send: () => {},
@@ -44,7 +45,7 @@ function stubAgent(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent {
cancel() {},
runMaintenance: <T>(job: (signal: AbortSignal) => Promise<T>) => job(new AbortController().signal),
whenIdle() { return Promise.resolve() },
}
} satisfies Agent
agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() })
return agent
}
+1
View File
@@ -61,6 +61,7 @@
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-mock-server": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
@@ -11,7 +11,6 @@ import type { MockLlmBehavior, MockLlmServer } from '@deepseek-ai/dsh-llm-mock-s
import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import * as Retry from '../src/index.ts'
let context: Context | undefined
@@ -39,7 +38,6 @@ async function harness(
vi.stubEnv('DEEPSEEK_API_KEY', 'mock-key')
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(LlmDeepSeek, {
baseURL,
streamIdleTimeoutMs: options.streamIdleTimeoutMs ?? 1_000,
@@ -2,11 +2,11 @@ import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import LlmRuntime, { createUserMessage, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import PlanModeController from '@deepseek-ai/dsh-plan-mode'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -64,7 +64,7 @@ function commitPlanMode(session: Session, active: boolean, turn: number): void {
describe('plan projection unit', () => {
it('serves inactive/not-pending for the empty log', async () => {
const bench = await harness(true)
expect(bench.values()).toEqual({ plan: { active: false, pending: false } })
expect(bench.values().plan).toEqual({ active: false, pending: false })
})
it('a logged /plan selection reads pending until plan/mode records it', async () => {
@@ -5,9 +5,9 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import LlmRuntime from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import AgentRegistry, { assembleContextFor } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
@@ -28,10 +28,10 @@ async function harness(roster: Partial<Config> = {}): Promise<Context> {
ctx.loader.builtins.include = Include
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SystemPrompt, { personaPrefix: '' })
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeShippedRoot: false, includeUserRoot: false, ...roster })
await ctx.plugin(InvariantRegistry)
@@ -8,9 +8,9 @@ import Include from '@deepseek-ai/cordis-plugin-include'
import Group from '@deepseek-ai/cordis-plugin-group'
import LlmRuntime from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -51,10 +51,10 @@ async function harness(roster: Config = { default: 'standard', roots: ROOTS, inc
ctx.loader.builtins.group = Group
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SystemPrompt, { personaPrefix: '' })
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(AgentPresets, roster)
return ctx
@@ -459,10 +459,10 @@ describe('the preset file is an input, never a persistence target', () => {
scoped.loader.builtins.group = Group
await scoped.plugin(LlmRuntime)
await scoped.plugin(SessionStore)
await scoped.plugin(SessionProjectionRegistry)
await scoped.plugin(SystemPrompt, { personaPrefix: '' })
await scoped.plugin(ToolRuntime)
await scoped.plugin(AgentRegistry)
await scoped.plugin(SessionProjectionRegistry)
await scoped.plugin(AgentLoop, { agents: [] })
await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false })
@@ -648,10 +648,10 @@ describe('replacing a composition', () => {
scoped.loader.builtins.group = Group
await scoped.plugin(LlmRuntime)
await scoped.plugin(SessionStore)
await scoped.plugin(SessionProjectionRegistry)
await scoped.plugin(SystemPrompt, { personaPrefix: '' })
await scoped.plugin(ToolRuntime)
await scoped.plugin(AgentRegistry)
await scoped.plugin(SessionProjectionRegistry)
await scoped.plugin(AgentLoop, { agents: [] })
await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false })
const handle = await scoped.agents.create({
@@ -79,10 +79,10 @@ async function harness(
ctx.loader.builtins.include = Include
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SystemPrompt, { personaPrefix: '' })
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(AgentPresets, roster)
return ctx
@@ -7,7 +7,6 @@ import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
@@ -52,7 +51,6 @@ async function mountRuntime(root: string, adapter: RecordingAdapter): Promise<Co
const ctx = new Context()
contexts.push(ctx)
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -4,7 +4,6 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { SessionLogOffset, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
@@ -76,7 +75,6 @@ class PersistenceProbe extends SessionPersistence {
async function harness(): Promise<Context> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(PersistenceProbe)
ctx.on('session/flush', () => {})
await ctx.plugin(AgentLoop, { agents: [] })
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentCancelCause, InboxTarget } from '@deepseek-ai/dsh-agent'
import type { UserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -11,6 +11,7 @@ import {
foldScheduleEvents,
} from '../src/domain.ts'
import { MAX_TIMER_DELAY_MS, ScheduleRuntime } from '../src/runtime.ts'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
const contexts: Context[] = []
const runtimes: ScheduleRuntime[] = []
@@ -57,12 +58,11 @@ async function harness(): Promise<RuntimeHarness> {
onFollowup: undefined as (() => void) | undefined,
idle: Promise.withResolvers<undefined>(),
}
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const agent: Agent = {
id: session.id,
options: {},
session,
inbox,
inbox: unsupportedInbox(),
status: 'idle',
ctx: new Context(),
send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {},

Some files were not shown because too many files have changed in this diff Show More