diff --git a/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.i18n.yaml new file mode 100644 index 0000000000..78b8b2b683 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md +2026-08-07-session-projection-mandatory-seam.md: 6348ae9afdb1689cd4885e658651d1e5a1f44a4b +2026-08-07-session-projection-mandatory-seam.zh.md: 95f3489275f67fc18a030345e120e2cb8c88c191 diff --git a/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md b/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md new file mode 100644 index 0000000000..6348ae9afd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md @@ -0,0 +1,32 @@ +# Agent Note: Session projections as a required seam + +Status: implemented + +English | [中文](2026-08-07-session-projection-mandatory-seam.zh.md) + +## Problem + +An optional projection registry lets contributors and readers activate without the service, silently dropping host state, client read models, or subagent catalog fields. Batch-only reads also materialize every client view when a consumer needs one host value. + +## Decision + +This decision builds on the split between host projection state and client views in [Session projection state and client views](2026-08-19-session-projection-state-and-client-views.md). + +`sessionProjections` is a required injection for every plugin that contributes or reads a projection unit. Official compositions mount the registry before those plugins. `ApiProxyService` follows the same rule; the lower-level `createApiProxy` factory remains tolerant for isolated tests and diagnostics. + +The registry provides `stateOf(session, key)` for one typed host state and keeps `snapshot()` for batch carriers. Client views contain only consumed fields; host readers use `stateOf` for richer state. + +`onChanged` publishes client-visible value changes only. Unit registration and removal remain effect-scoped registry lifecycle; they do not create a second Host event stream or client tombstone protocol. A later authoritative history or list baseline reflects the active key set. + +## Alternatives considered + +- **Keep the registry optional.** This preserves more partial compositions but makes missing read models indistinguishable from valid absence. Rejected because official profiles already mount the registry and configuration errors should fail at load. +- **Use `snapshot()` for every read.** This keeps one method but computes unrelated wire views and encourages consumers to depend on batch transport data for host logic. Rejected in favor of typed single-key state reads. +- **Send full host values to clients.** This avoids separate view types but exposes provenance and policy knobs that no client consumes. Rejected in favor of explicit cropped views. +- **Broadcast registry additions and removals across Host and mux streams.** The streams have no shared ordering, so clients need tombstones, buffered frames, and baseline retries to reconcile them. Rejected because plugin-key churn does not justify a second synchronization protocol. + +## Consequences + +- Missing projection composition fails during plugin activation. +- Host consumers avoid repeated whole-registry snapshots and log scans. +- Wire payloads exclude host-only fields and per-key watermark wrappers; ordinary baselines communicate the active key set. diff --git a/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.zh.md b/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.zh.md new file mode 100644 index 0000000000..95f3489275 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.zh.md @@ -0,0 +1,32 @@ +# Agent Note: 会话投影作为必需 seam + +Status: implemented + +[English](2026-08-07-session-projection-mandatory-seam.md) | 中文 + +## 问题 + +可选的投影注册表会让贡献方和读取方在缺少该服务时仍然激活,并静默丢失 host 状态、客户端读模型或 subagent 目录字段。只有批量读取也会在消费方只需要一个 host 值时物化每个客户端 view。 + +## 决策 + +本决策建立在[会话投影的 host 状态与客户端视图](2026-08-19-session-projection-state-and-client-views.md)所定义的拆分之上。 + +每个贡献或读取投影单元的插件都把 `sessionProjections` 作为必需注入。正式组合在这些插件之前挂载注册表。`ApiProxyService` 遵循同一规则;较低层的 `createApiProxy` factory 对隔离测试和诊断保持容错。 + +注册表提供 `stateOf(session, key)` 来读取一个类型化 host 状态,并为批量 carrier 保留 `snapshot()`。客户端 view 只包含消费方使用的字段;host 读取方通过 `stateOf` 取得更丰富的状态。 + +`onChanged` 只发布客户端可见值的变化。单元注册和移除仍是绑定 effect 的注册表生命周期;它们不创建第二条 Host 事件流或客户端 tombstone 协议。后续权威 history 或 list 基线会反映活跃 key 集。 + +## 考虑过的替代方案 + +- **让注册表保持可选。** 这会保留更多不完整组合,但缺失读模型将无法与合法缺席区分。之所以否决,是因为正式 profile 已挂载注册表,且配置错误应在加载时失败。 +- **每次读取都使用 `snapshot()`。** 这只保留一个方法,但会计算无关 wire view,并鼓励消费方让 host 逻辑依赖批量传输数据。改用类型化单 key 状态读取。 +- **向客户端发送完整 host 值。** 这避免单独的 view 类型,但会暴露客户端不消费的来源信息和策略旋钮。改用显式裁剪的 view。 +- **跨 Host 和 mux stream 广播注册表新增和移除。** 两条 stream 没有共享顺序,客户端因此需要 tombstone、缓冲帧和基线重试来协调。插件 key 变化不值得引入第二套同步协议。 + +## 后果 + +- 缺少投影组合会在插件激活期间失败。 +- host 消费方避免重复的全注册表快照和日志扫描。 +- 协议负载排除 host 内部字段和逐 key 水位包装;普通基线会传达活跃 key 集。 diff --git a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts index cdc4dbb6d3..ea9a85cc13 100644 --- a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts +++ b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts @@ -1,5 +1,4 @@ import { fileURLToPath } from 'node:url' -import { Context } from '@deepseek-ai/cordis' import { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' @@ -20,7 +19,7 @@ try { const agentId = SessionId('dsh-badge-snapshot') const session = ctx.sessions.create(agentId, { meta: { cwd: process.cwd() } }) const agent: Agent = { - ctx: new Context(), + ctx, id: agentId, options: {}, session, diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index f4d299db18..33c813ae3f 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 2a51992a4f99d1827259c0a2ca2daa309f0ddd95 -architecture.zh.md: d1629a088ed8d2fe61a7e20ab2ab1fd3709abd50 +architecture.md: 3ad9dec77f59af84594057e74602a1cc6c31dd18 +architecture.zh.md: e3e2128a71fd9b3abe915e9af2bfe1322f450661 diff --git a/docs/architecture.md b/docs/architecture.md index 2a51992a4f..3ad9dec77f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -95,6 +95,8 @@ The session log is the source of the context the model sees. `deriveMessages()` **Model-visible means logged.** Anything that reaches a model request must be reconstructable from the log, and a runtime invariant asserts it. This is why a new model-visible input requires a new session event: extend `SessionEventMap` and render from the log. +**Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, host consumers read one typed state with `stateOf()`, and carriers batch cropped client views with `snapshot()`. Projection contributors and readers require this service, so an incomplete composition fails during activation. The agent loop registers shared `turnBoundary` state instead of making each consumer scan the log ([decision](../.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md)). + ## Capability seams A **seam** is a swappable capability with three roles: a **Service Definition** declaring the interface, a **Service Provider** implementing it, and a **Consumer** using it, commonly a model-facing tool. A package may combine roles, but one role alone is not a seam; adding a capability means designing all three ([capability graph](capability-seams.md)). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index d1629a088e..e3e2128a71 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -99,6 +99,8 @@ turn/end **模型可见即已记录。** 抵达模型请求的一切都必须能从日志重建,并由一项运行时不变量断言这一点。因此,新增一项模型可见输入就需要新增一个会话事件:扩展 `SessionEventMap` 并从日志渲染。 +**投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,host 消费方通过 `stateOf()` 读取单个类型化状态,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。投影贡献方和读取方都要求该服务,因此不完整的组合会在激活时失败。agent loop 注册共享的 `turnBoundary` 状态,避免每个消费方各自扫描日志([决策](../.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md))。 + ## 能力 seam 一个 **seam** 是一项可替换能力,包含三种角色:声明接口的 **Service Definition**、实现它的 **Service Provider**,以及使用它的 **Consumer**(通常是面向模型的工具)。一个包可以合并承担多个角色,但单一角色本身不是 seam;添加一项能力意味着把三者一并设计([能力图](capability-seams.md))。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 59263b95ec..0552465ffe 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 3f15ddbe310caf96a5c84b52628f9ba44f6e3637 -config-catalog.zh.md: 403db954334506e8023c6ed5f5dbad74eec58c6e +config-catalog.md: 1a3699d4c2f8d0611811ce8532892ca59b29d466 +config-catalog.zh.md: 6535c09adeb80d59639d47121dd8bd33e367f4bb diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3f15ddbe31..1a3699d4c2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -106,6 +106,8 @@ Source: [`packages/core/agent-default-model/src/index.ts:41`](../packages/core/a ## `@deepseek-ai/dsh-agent-instructions` +Requires: `sessionProjections` + ```ts config-catalog /** User-facing workspace instruction loader configuration. */ export interface Config { @@ -136,7 +138,7 @@ Source: [`packages/context/agent-instructions/src/config.ts:18`](../packages/con ## `@deepseek-ai/dsh-agent-loop` -Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` +Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections` ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -162,7 +164,7 @@ export interface Config { Depends on: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md) -Source: [`packages/core/agent-loop/src/index.ts:255`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:314`](../packages/core/agent-loop/src/index.ts) @@ -292,7 +294,7 @@ export interface GoalConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`JobsConfig`](#deepseek-aidsh-jobs-local) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillFileSystem`](../packages/skill/skill-filesystem/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/shell/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`toolJobs`](../packages/jobs/tool-jobs/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`workspaceContext`](../packages/context/agent-instructions/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:92`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:93`](../packages/examples/agent-spine-demo/src/index.ts) @@ -630,7 +632,7 @@ Source: [`packages/fs/fs-sandbox/src/index.ts:49`](../packages/fs/fs-sandbox/src ## `@deepseek-ai/dsh-goal` -Requires: `agents` +Requires: `agents` · `sessionProjections` ```ts config-catalog /** Deployment defaults for goal creation. */ @@ -640,7 +642,7 @@ export interface Config { } ``` -Source: [`packages/goal/goal/src/index.ts:116`](../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:115`](../packages/goal/goal/src/index.ts) @@ -662,7 +664,7 @@ Source: [`packages/bundle/headless/src/index.ts:31`](../packages/bundle/headless ## `@deepseek-ai/dsh-hooks-claude-code` -Requires: `shell` +Requires: `shell` · `sessionProjections` ```ts config-catalog /** Plugin config: where the CC hook config lives + substitution roots. */ @@ -694,13 +696,13 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-claude-code/src/index.ts:45`](../packages/hooks/hooks-claude-code/src/index.ts) +Source: [`packages/hooks/hooks-claude-code/src/index.ts:46`](../packages/hooks/hooks-claude-code/src/index.ts) ## `@deepseek-ai/dsh-hooks-codex` -Requires: `shell` +Requires: `shell` · `sessionProjections` ```ts config-catalog /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ @@ -721,13 +723,13 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-codex/src/index.ts) +Source: [`packages/hooks/hooks-codex/src/index.ts:45`](../packages/hooks/hooks-codex/src/index.ts) ## `@deepseek-ai/dsh-host-apiproxy` -Requires: `agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userQuestions` · `workspaceRegistry` +Requires: `agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `sessionProjections` · `subagents` · `sessionQuery` · `tools` · `userQuestions` · `workspaceRegistry` ```ts config-catalog /** Gateway plugin configuration. */ @@ -1162,14 +1164,14 @@ Source: [`packages/test-support/llm-replay/src/index.ts:776`](../packages/test-s ## `@deepseek-ai/dsh-llm-retry` -Requires: `agents` +Requires: `agents` · `sessionProjections` ```ts config-catalog /** This policy executor has no config; providers own `retryPolicy`. */ export type Config = Readonly> ``` -Source: [`packages/llm/llm-retry/src/index.ts:24`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:25`](../packages/llm/llm-retry/src/index.ts) @@ -1306,7 +1308,7 @@ Source: [`packages/feedback/message-feedback/src/index.ts:49`](../packages/feedb ## `@deepseek-ai/dsh-permission-presets` -Requires: `shell` · `approval` · `sessions` +Requires: `shell` · `approval` · `sessions` · `sessionProjections` ```ts config-catalog /** The {@link PermissionPresetService} config: preset table and composition default. */ @@ -1339,7 +1341,7 @@ export interface PresetSpec { Depends on: [`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsystems/sandbox.md) -Source: [`packages/interaction/permission-presets/src/index.ts:156`](../packages/interaction/permission-presets/src/index.ts) +Source: [`packages/interaction/permission-presets/src/index.ts:116`](../packages/interaction/permission-presets/src/index.ts) @@ -1369,7 +1371,7 @@ Source: [`packages/preset/persona/src/index.ts:34`](../packages/preset/persona/s ## `@deepseek-ai/dsh-plan-mode` -Requires: `tools` · `systemPrompt` +Requires: `tools` · `systemPrompt` · `sessionProjections` ```ts config-catalog /** Deployment-owned plan guidance. */ @@ -1379,7 +1381,7 @@ export interface PlanModeConfig { } ``` -Source: [`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:65`](../packages/plan/plan-mode/src/index.ts) @@ -1506,6 +1508,8 @@ Source: [`packages/sandbox/sandbox-local/src/index.ts:44`](../packages/sandbox/s ## `@deepseek-ai/dsh-sandbox-policy` +Requires: `sessionProjections` + ```ts config-catalog /** * Plugin config: the deployment's sandbox default. All optional — `Config` @@ -1527,7 +1531,7 @@ export interface Config { Depends on: [`SandboxMode`](subsystems/sandbox.md) -Source: [`packages/sandbox/sandbox-policy/src/index.ts:67`](../packages/sandbox/sandbox-policy/src/index.ts) +Source: [`packages/sandbox/sandbox-policy/src/index.ts:68`](../packages/sandbox/sandbox-policy/src/index.ts) @@ -1664,7 +1668,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:42`](../package ## `@deepseek-ai/dsh-session-query-sqlite` -Requires: `sessions` +Requires: `sessions` · `sessionProjections` ```ts config-catalog /** Combined session-query configuration backed by SQLite full-text search. */ @@ -1776,7 +1780,7 @@ Source: [`packages/session/session-telemetry-otel/src/index.ts:91`](../packages/ ## `@deepseek-ai/dsh-session-title` -Requires: `sessions` +Requires: `sessions` · `sessionProjections` ```ts config-catalog /** Required deterministic fallback and accepted-title limits. */ @@ -1790,7 +1794,7 @@ export interface Config { } ``` -Source: [`packages/session/session-title/src/index.ts:79`](../packages/session/session-title/src/index.ts) +Source: [`packages/session/session-title/src/index.ts:54`](../packages/session/session-title/src/index.ts) @@ -2303,7 +2307,7 @@ Source: [`packages/experimental/team/src/types.ts:125`](../packages/experimental ## `@deepseek-ai/dsh-terminal-bash` -Requires: `terminals` · `sandboxPolicy` · `subprocess` +Requires: `terminals` · `sandboxPolicy` · `sessionProjections` · `subprocess` ```ts config-catalog /** Public plugin configuration. */ @@ -2348,7 +2352,7 @@ Source: [`packages/terminal/terminal-bash/src/config.ts:6`](../packages/terminal ## `@deepseek-ai/dsh-time-context` -Requires: `agents` +Requires: `agents` · `sessionProjections` ```ts config-catalog /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ @@ -2360,13 +2364,13 @@ export interface Config { } ``` -Source: [`packages/context/time-context/src/index.ts:27`](../packages/context/time-context/src/index.ts) +Source: [`packages/context/time-context/src/index.ts:50`](../packages/context/time-context/src/index.ts) ## `@deepseek-ai/dsh-tmux-context` -Requires: `agents` +Requires: `agents` · `sessionProjections` ```ts config-catalog /** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ @@ -2376,12 +2380,14 @@ export interface Config { } ``` -Source: [`packages/context/tmux-context/src/index.ts:34`](../packages/context/tmux-context/src/index.ts) +Source: [`packages/context/tmux-context/src/index.ts:36`](../packages/context/tmux-context/src/index.ts) ## `@deepseek-ai/dsh-token-meter` +Requires: `sessionProjections` + ```ts config-catalog /** Token-meter plugin configuration; the fixed estimator has no settings. */ export type TokenMeterConfig = Record @@ -2488,7 +2494,7 @@ Source: [`packages/fs/tool-fs-search/src/index.ts:73`](../packages/fs/tool-fs-se ## `@deepseek-ai/dsh-tool-goal` -Requires: `agents` · `goals` · `tools` · `systemPrompt` +Requires: `agents` · `goals` · `tools` · `systemPrompt` · `sessionProjections` ```ts config-catalog /** Model policy and hard lower bounds for goal-state updates. */ @@ -2596,7 +2602,7 @@ Source: [`packages/workflow/tool-ralph/src/index.ts:23`](../packages/workflow/to ## `@deepseek-ai/dsh-tool-session-query` -Requires: `tools` · `systemPrompt` · `sessionQuery` +Requires: `tools` · `systemPrompt` · `sessionQuery` · `sessionProjections` ```ts config-catalog /** Deployment-owned search count and timeout bounds. */ @@ -2614,7 +2620,7 @@ Source: [`packages/session-query/tool-session-query/src/index.ts:29`](../package ## `@deepseek-ai/dsh-tool-skill` -Requires: `agents` · `tools` · `skills` +Requires: `agents` · `tools` · `skills` · `sessionProjections` ```ts config-catalog /** Model-facing skill catalog configuration. */ @@ -2624,7 +2630,7 @@ export interface Config { } ``` -Source: [`packages/skill/tool-skill/src/index.ts:61`](../packages/skill/tool-skill/src/index.ts) +Source: [`packages/skill/tool-skill/src/index.ts:63`](../packages/skill/tool-skill/src/index.ts) @@ -2771,7 +2777,7 @@ Source: [`packages/terminal/tool-terminal/src/index.ts:35`](../packages/terminal ## `@deepseek-ai/dsh-tool-todo` -Requires: `tools` +Requires: `tools` · `sessionProjections` ```ts config-catalog /** Model-facing todo tool configuration. */ @@ -2787,7 +2793,7 @@ export interface Config { } ``` -Source: [`packages/todo/tool-todo/src/index.ts:29`](../packages/todo/tool-todo/src/index.ts) +Source: [`packages/todo/tool-todo/src/index.ts:28`](../packages/todo/tool-todo/src/index.ts) @@ -2891,6 +2897,8 @@ Source: [`packages/typert/loader/src/index.ts:47`](../packages/typert/loader/src ## `@deepseek-ai/dsh-user-approval` +Requires: `sessionProjections` + ```ts config-catalog /** Plugin config. All optional — `static Config` supplies the defaults. */ export interface Config { @@ -2916,7 +2924,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/interaction/user-approval/src/index.ts:177`](../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:175`](../packages/interaction/user-approval/src/index.ts) @@ -3158,7 +3166,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session-stats` — requires `sessionProjections` ([`packages/session/session-stats/src/index.ts`](../packages/session/session-stats/src/index.ts)) - `@deepseek-ai/dsh-skill-badge` — requires `skills` ([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) -- `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) +- `@deepseek-ai/dsh-subagent` — requires `sessionProjections` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) - `@deepseek-ai/dsh-terminal` ([`packages/terminal/terminal/src/index.ts`](../packages/terminal/terminal/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userQuestions` ([`packages/interaction/tool-ask-user/src/index.ts`](../packages/interaction/tool-ask-user/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 403db95433..6535c09ade 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -108,6 +108,8 @@ export interface Config { ## `@deepseek-ai/dsh-agent-instructions` +需要:`sessionProjections` + ```ts config-catalog /** User-facing workspace instruction loader configuration. */ export interface Config { @@ -138,7 +140,7 @@ export interface Config { ## `@deepseek-ai/dsh-agent-loop` -需要:`agents` · `sessions` · `llm` · `tools` · `systemPrompt` +需要:`agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections` ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -164,7 +166,7 @@ export interface Config { 依赖:[`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md) -来源:[`packages/core/agent-loop/src/index.ts:255`](../packages/core/agent-loop/src/index.ts) +来源:[`packages/core/agent-loop/src/index.ts:314`](../packages/core/agent-loop/src/index.ts) @@ -294,7 +296,7 @@ export interface GoalConfig { 依赖:[`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`JobsConfig`](#deepseek-aidsh-jobs-local) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillFileSystem`](../packages/skill/skill-filesystem/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/shell/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`toolJobs`](../packages/jobs/tool-jobs/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`workspaceContext`](../packages/context/agent-instructions/src/index.ts) -来源:[`packages/examples/agent-spine-demo/src/index.ts:92`](../packages/examples/agent-spine-demo/src/index.ts) +来源:[`packages/examples/agent-spine-demo/src/index.ts:93`](../packages/examples/agent-spine-demo/src/index.ts) @@ -420,7 +422,7 @@ export interface ConnectionConfig { ## `@deepseek-ai/dsh-client-hmr` -需要:`clientModuleHost` · `webServer` +需要:`clientModules` · `webServer` ```ts config-catalog /** Plugin config, validated by the same-named schemastery schema. */ @@ -632,7 +634,7 @@ export type Config = LocalConfig ## `@deepseek-ai/dsh-goal` -需要:`agents` +需要:`agents` · `sessionProjections` ```ts config-catalog /** Deployment defaults for goal creation. */ @@ -642,7 +644,7 @@ export interface Config { } ``` -来源:[`packages/goal/goal/src/index.ts:116`](../packages/goal/goal/src/index.ts) +来源:[`packages/goal/goal/src/index.ts:115`](../packages/goal/goal/src/index.ts) @@ -664,7 +666,7 @@ export interface Config { ## `@deepseek-ai/dsh-hooks-claude-code` -需要:`bash` +需要:`shell` · `sessionProjections` ```ts config-catalog /** Plugin config: where the CC hook config lives + substitution roots. */ @@ -696,13 +698,13 @@ export interface Config { } ``` -来源:[`packages/hooks/hooks-claude-code/src/index.ts:45`](../packages/hooks/hooks-claude-code/src/index.ts) +来源:[`packages/hooks/hooks-claude-code/src/index.ts:46`](../packages/hooks/hooks-claude-code/src/index.ts) ## `@deepseek-ai/dsh-hooks-codex` -需要:`bash` +需要:`shell` · `sessionProjections` ```ts config-catalog /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ @@ -723,13 +725,13 @@ export interface Config { } ``` -来源:[`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-codex/src/index.ts) +来源:[`packages/hooks/hooks-codex/src/index.ts:45`](../packages/hooks/hooks-codex/src/index.ts) ## `@deepseek-ai/dsh-host-apiproxy` -需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace` +需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `sessionProjections` · `subagents` · `sessionQuery` · `tools` · `userQuestions` · `workspaceRegistry` ```ts config-catalog /** Gateway plugin configuration. */ @@ -1164,14 +1166,14 @@ export interface ReplayModelConfig { ## `@deepseek-ai/dsh-llm-retry` -需要:`agents` +需要:`agents` · `sessionProjections` ```ts config-catalog /** This policy executor has no config; providers own `retryPolicy`. */ export type Config = Readonly> ``` -来源:[`packages/llm/llm-retry/src/index.ts:24`](../packages/llm/llm-retry/src/index.ts) +来源:[`packages/llm/llm-retry/src/index.ts:25`](../packages/llm/llm-retry/src/index.ts) @@ -1308,7 +1310,7 @@ export interface Config { ## `@deepseek-ai/dsh-permission-presets` -需要:`bash` · `approval` · `sessions` +需要:`shell` · `approval` · `sessions` · `sessionProjections` ```ts config-catalog /** The {@link PermissionPresetService} config: preset table and composition default. */ @@ -1341,7 +1343,7 @@ export interface PresetSpec { 依赖:[`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsystems/sandbox.md) -来源:[`packages/interaction/permission-presets/src/index.ts:156`](../packages/interaction/permission-presets/src/index.ts) +来源:[`packages/interaction/permission-presets/src/index.ts:116`](../packages/interaction/permission-presets/src/index.ts) @@ -1371,7 +1373,7 @@ export interface Config { ## `@deepseek-ai/dsh-plan-mode` -需要:`tools` · `systemPrompt` +需要:`tools` · `systemPrompt` · `sessionProjections` ```ts config-catalog /** Deployment-owned plan guidance. */ @@ -1381,7 +1383,7 @@ export interface PlanModeConfig { } ``` -来源:[`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts) +来源:[`packages/plan/plan-mode/src/index.ts:65`](../packages/plan/plan-mode/src/index.ts) @@ -1508,6 +1510,8 @@ export interface Config { ## `@deepseek-ai/dsh-sandbox-policy` +需要:`sessionProjections` + ```ts config-catalog /** * Plugin config: the deployment's sandbox default. All optional — `Config` @@ -1529,7 +1533,7 @@ export interface Config { 依赖:[`SandboxMode`](subsystems/sandbox.md) -来源:[`packages/sandbox/sandbox-policy/src/index.ts:67`](../packages/sandbox/sandbox-policy/src/index.ts) +来源:[`packages/sandbox/sandbox-policy/src/index.ts:68`](../packages/sandbox/sandbox-policy/src/index.ts) @@ -1553,7 +1557,7 @@ export interface JsonRpcConfig { 依赖:`Readable`(`node:stream`)· `Writable`(`node:stream`) -来源:[`packages/sdk/server/src/index.ts:29`](../packages/sdk/server/src/index.ts) +来源:[`packages/sdk/server/src/index.ts:25`](../packages/sdk/server/src/index.ts) @@ -1666,7 +1670,7 @@ export interface Config { ## `@deepseek-ai/dsh-session-query-sqlite` -需要:`sessions` +需要:`sessions` · `sessionProjections` ```ts config-catalog /** Combined session-query configuration backed by SQLite full-text search. */ @@ -1778,7 +1782,7 @@ export enum SessionTelemetryMode { ## `@deepseek-ai/dsh-session-title` -需要:`sessions` +需要:`sessions` · `sessionProjections` ```ts config-catalog /** Required deterministic fallback and accepted-title limits. */ @@ -1792,7 +1796,7 @@ export interface Config { } ``` -来源:[`packages/session/session-title/src/index.ts:79`](../packages/session/session-title/src/index.ts) +来源:[`packages/session/session-title/src/index.ts:54`](../packages/session/session-title/src/index.ts) @@ -2306,7 +2310,7 @@ export interface Config { ## `@deepseek-ai/dsh-terminal-bash` -需要:`pty` · `sandboxPolicy` · `subprocess` +需要:`terminals` · `sandboxPolicy` · `sessionProjections` · `subprocess` ```ts config-catalog /** Public plugin configuration. */ @@ -2351,7 +2355,7 @@ export interface Config { ## `@deepseek-ai/dsh-time-context` -需要:`agents` +需要:`agents` · `sessionProjections` ```ts config-catalog /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ @@ -2363,13 +2367,13 @@ export interface Config { } ``` -来源:[`packages/context/time-context/src/index.ts:27`](../packages/context/time-context/src/index.ts) +来源:[`packages/context/time-context/src/index.ts:50`](../packages/context/time-context/src/index.ts) ## `@deepseek-ai/dsh-tmux-context` -需要:`agents` +需要:`agents` · `sessionProjections` ```ts config-catalog /** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ @@ -2379,12 +2383,14 @@ export interface Config { } ``` -来源:[`packages/context/tmux-context/src/index.ts:34`](../packages/context/tmux-context/src/index.ts) +来源:[`packages/context/tmux-context/src/index.ts:36`](../packages/context/tmux-context/src/index.ts) ## `@deepseek-ai/dsh-token-meter` +需要:`sessionProjections` + ```ts config-catalog /** Token-meter plugin configuration; the fixed estimator has no settings. */ export type TokenMeterConfig = Record @@ -2396,7 +2402,7 @@ export type TokenMeterConfig = Record ## `@deepseek-ai/dsh-tool-bash` -需要:`tools` · `bash` · `systemPrompt` · `bashEnv` +需要:`tools` · `shell` · `systemPrompt` · `shellEnv` ```ts config-catalog /** Configuration for the bash tool. */ @@ -2412,7 +2418,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash-persistent` -需要:`tools` · `pty` +需要:`tools` · `terminals` ```ts config-catalog /** Configuration for the persistent Bash tool. */ @@ -2491,7 +2497,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-goal` -需要:`agents` · `goals` · `tools` · `systemPrompt` +需要:`agents` · `goals` · `tools` · `systemPrompt` · `sessionProjections` ```ts config-catalog /** Model policy and hard lower bounds for goal-state updates. */ @@ -2507,7 +2513,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-jobs` -需要:`tools` · `tasks` · `systemPrompt` +需要:`tools` · `jobs` · `systemPrompt` ```ts config-catalog /** Configures bounded `job_output` waits and completion-notice delivery. */ @@ -2561,7 +2567,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-pwsh` -需要:`tools` · `bash` · `systemPrompt` · `bashEnv` +需要:`tools` · `shell` · `systemPrompt` · `shellEnv` ```ts config-catalog /** Configuration for the pwsh tool. */ @@ -2577,7 +2583,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-ralph` -需要:`tools` · `workflows` · `subagents` · `systemPrompt` +需要:`tools` · `workflowEngine` · `subagents` · `systemPrompt` ```ts config-catalog /** Deployment policy for the fixed Ralph workflow. */ @@ -2599,7 +2605,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-session-query` -需要:`tools` · `systemPrompt` · `sessionQuery` +需要:`tools` · `systemPrompt` · `sessionQuery` · `sessionProjections` ```ts config-catalog /** Deployment-owned search count and timeout bounds. */ @@ -2617,7 +2623,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-skill` -需要:`agents` · `tools` · `skills` +需要:`agents` · `tools` · `skills` · `sessionProjections` ```ts config-catalog /** Model-facing skill catalog configuration. */ @@ -2627,7 +2633,7 @@ export interface Config { } ``` -来源:[`packages/skill/tool-skill/src/index.ts:61`](../packages/skill/tool-skill/src/index.ts) +来源:[`packages/skill/tool-skill/src/index.ts:63`](../packages/skill/tool-skill/src/index.ts) @@ -2757,7 +2763,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-terminal` -需要:`pty` · `tools` · `systemPrompt` +需要:`terminals` · `tools` · `systemPrompt` ```ts config-catalog /** Model-facing terminal tool configuration. */ @@ -2775,7 +2781,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-todo` -需要:`tools` +需要:`tools` · `sessionProjections` ```ts config-catalog /** Model-facing todo tool configuration. */ @@ -2791,7 +2797,7 @@ export interface Config { } ``` -来源:[`packages/todo/tool-todo/src/index.ts:29`](../packages/todo/tool-todo/src/index.ts) +来源:[`packages/todo/tool-todo/src/index.ts:28`](../packages/todo/tool-todo/src/index.ts) @@ -2825,7 +2831,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-workflow` -需要:`tools` · `workflows` · `systemPrompt` +需要:`tools` · `workflowEngine` · `systemPrompt` ```ts config-catalog /** Config: the model-facing tool name plus result rendering caps. */ @@ -2895,6 +2901,8 @@ export interface Config { ## `@deepseek-ai/dsh-user-approval` +需要:`sessionProjections` + ```ts config-catalog /** Plugin config. All optional — `static Config` supplies the defaults. */ export interface Config { @@ -2920,7 +2928,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -来源:[`packages/interaction/user-approval/src/index.ts:177`](../packages/interaction/user-approval/src/index.ts) +来源:[`packages/interaction/user-approval/src/index.ts:175`](../packages/interaction/user-approval/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index d933130f59..2e2f60e056 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: c906ee6329fac66e7391c266213dd150dd5b8e09 -event-producer-consumer.zh.md: 77bf401b7215bd263c0d84f04e0eabe6b28b7915 +event-producer-consumer.md: 699c84c9f84c486bdb090aec07cca63349e67f75 +event-producer-consumer.zh.md: 977d1909ec0370e81e90344ae0acef17dea5d842 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c906ee6329..699c84c9f8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:242`](../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:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `apiproxy` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), [`tool-team`](../packages/experimental/tool-team) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-team`](../packages/experimental/tool-team) | @@ -21,7 +21,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`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), [`team`](../packages/experimental/team) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, [`team`](../packages/experimental/team) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../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) | -| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | +| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:32`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:379`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:385`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 77bf401b72..977d1909ec 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -9,7 +9,7 @@ | 事件 | 模式 | 声明位置 | 派发方 | 监听方 | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:242`](../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:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `apiproxy` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), [`tool-team`](../packages/experimental/tool-team) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-team`](../packages/experimental/tool-team) | @@ -23,7 +23,7 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`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), [`team`](../packages/experimental/team) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, [`team`](../packages/experimental/team) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../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) | -| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | +| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:32`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:379`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:385`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 1b0e8fad9c..d41746acf1 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 0bd5f80534ba65e0d483bd04228cdac321e082bc -module-graph.zh.md: 6239520b7d819eb14c2b859afa44a0c987545200 +module-graph.md: 8f9be27dbc41d9caf88f54ce9003dee598da8958 +module-graph.zh.md: 1054c845b4ecfb3cadda57572f09ead8ad4563fd diff --git a/docs/module-graph.md b/docs/module-graph.md index 0bd5f80534..8f9be27dbc 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -436,12 +436,6 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_agent --> pkg_invariants - pkg_agent --> pkg_llm - pkg_agent --> pkg_scope - pkg_agent --> pkg_session - pkg_agent --> pkg_system_prompt - pkg_agent --> pkg_typert_protocol pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill pkg_web_fetch_http --> pkg_invariants @@ -478,40 +472,19 @@ flowchart TD pkg_session_projection --> pkg_session pkg_acp_snapshot --> pkg_invariants pkg_acp_snapshot --> pkg_session - pkg_llm_retry --> pkg_agent - pkg_llm_retry --> pkg_brand - pkg_llm_retry --> pkg_invariants - pkg_llm_retry --> pkg_llm - pkg_llm_retry --> pkg_session - pkg_llm_retry --> pkg_timeout - pkg_agent_default_model --> pkg_agent - pkg_agent_default_model --> pkg_invariants - pkg_agent_default_model --> pkg_llm - pkg_agent_default_model --> pkg_settings - pkg_goal --> pkg_agent - pkg_goal --> pkg_brand - pkg_goal --> pkg_invariants - pkg_goal --> pkg_llm - pkg_goal --> pkg_scope - pkg_goal --> pkg_session - pkg_goal --> pkg_session_projection - pkg_goal --> pkg_typert_protocol + pkg_agent --> pkg_invariants + pkg_agent --> pkg_llm + pkg_agent --> pkg_scope + pkg_agent --> pkg_session + pkg_agent --> pkg_session_projection + pkg_agent --> pkg_system_prompt + pkg_agent --> pkg_typert_protocol pkg_fs --> pkg_brand pkg_fs --> pkg_invariants pkg_fs --> pkg_llm pkg_fs --> pkg_sandbox - pkg_web_search_deepseek --> pkg_agent - pkg_web_search_deepseek --> pkg_credentials - pkg_web_search_deepseek --> pkg_invariants - pkg_web_search_deepseek --> pkg_launch_environment - pkg_web_search_deepseek --> pkg_session - pkg_web_search_deepseek --> pkg_settings - pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill - pkg_time_context --> pkg_agent - pkg_time_context --> pkg_invariants - pkg_time_context --> pkg_session pkg_message_feedback --> pkg_brand pkg_message_feedback --> pkg_invariants pkg_message_feedback --> pkg_llm @@ -519,45 +492,10 @@ flowchart TD pkg_message_feedback --> pkg_session_persistence pkg_message_feedback --> pkg_storage_domain pkg_message_feedback --> pkg_typert_protocol - pkg_commands --> pkg_agent - pkg_commands --> pkg_attachment - pkg_commands --> pkg_brand - pkg_commands --> pkg_invariants - pkg_commands --> pkg_llm - pkg_commands --> pkg_scope - pkg_commands --> pkg_session - pkg_commands --> pkg_typert_protocol - pkg_user_approval --> pkg_agent - pkg_user_approval --> pkg_brand - pkg_user_approval --> pkg_invariants - pkg_user_approval --> pkg_llm - pkg_user_approval --> pkg_scope - pkg_user_approval --> pkg_session - pkg_user_approval --> pkg_system_prompt - pkg_user_questions --> pkg_agent - pkg_user_questions --> pkg_invariants - pkg_user_questions --> pkg_llm - pkg_jobs --> pkg_agent - pkg_jobs --> pkg_brand - pkg_jobs --> pkg_invariants - pkg_jobs --> pkg_session - pkg_agent_presets --> pkg_agent - pkg_agent_presets --> pkg_atomic_write - pkg_agent_presets --> pkg_home_paths - pkg_agent_presets --> pkg_invariants - pkg_agent_presets --> pkg_scope - pkg_agent_presets --> pkg_session - pkg_agent_presets --> pkg_settings - pkg_agent_presets --> pkg_system_prompt pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox pkg_sandbox_local --> pkg_session - pkg_sandbox_policy --> pkg_agent - pkg_sandbox_policy --> pkg_invariants - pkg_sandbox_policy --> pkg_sandbox - pkg_sandbox_policy --> pkg_session - pkg_sandbox_policy --> pkg_system_prompt pkg_session_persistence_jsonl --> pkg_invariants pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence @@ -573,18 +511,128 @@ flowchart TD pkg_session_stats --> pkg_llm pkg_session_stats --> pkg_session pkg_session_stats --> pkg_session_projection + pkg_shell --> pkg_invariants + pkg_shell --> pkg_sandbox + pkg_shell --> pkg_settings + pkg_shell --> pkg_subprocess + pkg_workspace --> pkg_brand + pkg_workspace --> pkg_invariants + pkg_workspace --> pkg_session + pkg_workspace --> pkg_session_persistence + pkg_workspace --> pkg_storage + pkg_workspace --> pkg_storage_domain + pkg_llm_retry --> pkg_agent + pkg_llm_retry --> pkg_brand + pkg_llm_retry --> pkg_invariants + pkg_llm_retry --> pkg_llm + pkg_llm_retry --> pkg_session + pkg_llm_retry --> pkg_session_projection + pkg_llm_retry --> pkg_timeout + pkg_agent_default_model --> pkg_agent + pkg_agent_default_model --> pkg_invariants + pkg_agent_default_model --> pkg_llm + pkg_agent_default_model --> pkg_settings + pkg_goal --> pkg_agent + pkg_goal --> pkg_brand + pkg_goal --> pkg_invariants + pkg_goal --> pkg_llm + pkg_goal --> pkg_scope + pkg_goal --> pkg_session + pkg_goal --> pkg_session_projection + pkg_goal --> pkg_typert_protocol + pkg_fs_local --> pkg_fs + pkg_fs_local --> pkg_invariants + pkg_fs_observation_policy --> pkg_fs + pkg_fs_observation_policy --> pkg_invariants + pkg_skill_filesystem --> pkg_fs + pkg_skill_filesystem --> pkg_home_paths + pkg_skill_filesystem --> pkg_invariants + pkg_skill_filesystem --> pkg_skill + pkg_web_search_deepseek --> pkg_agent + pkg_web_search_deepseek --> pkg_credentials + pkg_web_search_deepseek --> pkg_invariants + pkg_web_search_deepseek --> pkg_launch_environment + pkg_web_search_deepseek --> pkg_session + pkg_web_search_deepseek --> pkg_settings + pkg_web_search_deepseek --> pkg_web + pkg_hook_protocol --> pkg_invariants + pkg_hook_protocol --> pkg_session + pkg_hook_protocol --> pkg_shell + pkg_time_context --> pkg_agent + pkg_time_context --> pkg_invariants + pkg_time_context --> pkg_session + pkg_time_context --> pkg_session_projection + pkg_tmux_context --> pkg_agent + pkg_tmux_context --> pkg_invariants + pkg_tmux_context --> pkg_session + pkg_tmux_context --> pkg_session_projection + pkg_tmux_context --> pkg_shell + pkg_fs_e2b --> pkg_e2b + pkg_fs_e2b --> pkg_fs + pkg_fs_e2b --> pkg_invariants + pkg_commands --> pkg_agent + pkg_commands --> pkg_attachment + pkg_commands --> pkg_brand + pkg_commands --> pkg_invariants + pkg_commands --> pkg_llm + pkg_commands --> pkg_scope + pkg_commands --> pkg_session + pkg_commands --> pkg_typert_protocol + pkg_user_approval --> pkg_agent + pkg_user_approval --> pkg_brand + pkg_user_approval --> pkg_invariants + pkg_user_approval --> pkg_llm + pkg_user_approval --> pkg_scope + pkg_user_approval --> pkg_session + pkg_user_approval --> pkg_session_projection + pkg_user_approval --> pkg_system_prompt + pkg_user_questions --> pkg_agent + pkg_user_questions --> pkg_invariants + pkg_user_questions --> pkg_llm + pkg_jobs --> pkg_agent + pkg_jobs --> pkg_brand + pkg_jobs --> pkg_invariants + pkg_jobs --> pkg_session + pkg_lsp_stdio --> pkg_brand + pkg_lsp_stdio --> pkg_fs + pkg_lsp_stdio --> pkg_invariants + pkg_lsp_stdio --> pkg_llm + pkg_lsp_stdio --> pkg_lsp + pkg_lsp_stdio --> pkg_subprocess + pkg_lsp_stdio --> pkg_timeout + pkg_agent_presets --> pkg_agent + pkg_agent_presets --> pkg_atomic_write + pkg_agent_presets --> pkg_home_paths + pkg_agent_presets --> pkg_invariants + pkg_agent_presets --> pkg_scope + pkg_agent_presets --> pkg_session + pkg_agent_presets --> pkg_settings + pkg_agent_presets --> pkg_system_prompt + pkg_sandbox_policy --> pkg_agent + pkg_sandbox_policy --> pkg_invariants + pkg_sandbox_policy --> pkg_sandbox + pkg_sandbox_policy --> pkg_session + pkg_sandbox_policy --> pkg_session_projection + pkg_sandbox_policy --> pkg_system_prompt pkg_session_telemetry --> pkg_agent pkg_session_telemetry --> pkg_invariants pkg_session_telemetry --> pkg_session + pkg_session_title --> pkg_agent pkg_session_title --> pkg_brand pkg_session_title --> pkg_invariants pkg_session_title --> pkg_llm pkg_session_title --> pkg_session pkg_session_title --> pkg_session_projection - pkg_shell --> pkg_invariants - pkg_shell --> pkg_sandbox - pkg_shell --> pkg_settings - pkg_shell --> pkg_subprocess + pkg_bash_local --> pkg_invariants + pkg_bash_local --> pkg_settings + pkg_bash_local --> pkg_shell + pkg_bash_local --> pkg_subprocess + pkg_bash_local --> pkg_timeout + pkg_pwsh_local --> pkg_invariants + pkg_pwsh_local --> pkg_settings + pkg_pwsh_local --> pkg_shell + pkg_pwsh_local --> pkg_subprocess + pkg_pwsh_local --> pkg_timeout pkg_terminal --> pkg_agent pkg_terminal --> pkg_brand pkg_terminal --> pkg_invariants @@ -597,12 +645,6 @@ flowchart TD pkg_workflow --> pkg_invariants pkg_workflow --> pkg_llm pkg_workflow --> pkg_session - pkg_workspace --> pkg_brand - pkg_workspace --> pkg_invariants - pkg_workspace --> pkg_session - pkg_workspace --> pkg_session_persistence - pkg_workspace --> pkg_storage - pkg_workspace --> pkg_storage_domain pkg_tools --> pkg_agent pkg_tools --> pkg_code_runtime pkg_tools --> pkg_invariants @@ -620,17 +662,11 @@ flowchart TD pkg_goal_round_driver --> pkg_invariants pkg_goal_round_driver --> pkg_llm pkg_goal_round_driver --> pkg_session - pkg_fs_local --> pkg_fs - pkg_fs_local --> pkg_invariants - pkg_fs_observation_policy --> pkg_fs - pkg_fs_observation_policy --> pkg_invariants - pkg_skill_filesystem --> pkg_fs - pkg_skill_filesystem --> pkg_home_paths - pkg_skill_filesystem --> pkg_invariants - pkg_skill_filesystem --> pkg_skill - pkg_hook_protocol --> pkg_invariants - pkg_hook_protocol --> pkg_session - pkg_hook_protocol --> pkg_shell + pkg_fs_sandbox --> pkg_fs + pkg_fs_sandbox --> pkg_fs_local + pkg_fs_sandbox --> pkg_invariants + pkg_fs_sandbox --> pkg_sandbox + pkg_fs_sandbox --> pkg_sandbox_policy pkg_session_query --> pkg_brand pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm @@ -653,13 +689,6 @@ flowchart TD pkg_compaction --> pkg_invariants pkg_compaction --> pkg_llm pkg_compaction --> pkg_session - pkg_tmux_context --> pkg_agent - pkg_tmux_context --> pkg_invariants - pkg_tmux_context --> pkg_session - pkg_tmux_context --> pkg_shell - pkg_fs_e2b --> pkg_e2b - pkg_fs_e2b --> pkg_fs - pkg_fs_e2b --> pkg_invariants pkg_command_feedback --> pkg_anonymous_user_id pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants @@ -679,33 +708,27 @@ flowchart TD pkg_jobs_local --> pkg_jobs pkg_jobs_local --> pkg_scope pkg_jobs_local --> pkg_timeout - pkg_lsp_stdio --> pkg_brand - pkg_lsp_stdio --> pkg_fs - pkg_lsp_stdio --> pkg_invariants - pkg_lsp_stdio --> pkg_llm - pkg_lsp_stdio --> pkg_lsp - pkg_lsp_stdio --> pkg_subprocess - pkg_lsp_stdio --> pkg_timeout pkg_session_title_llm --> pkg_invariants pkg_session_title_llm --> pkg_llm pkg_session_title_llm --> pkg_session pkg_session_title_llm --> pkg_session_title pkg_session_title_llm --> pkg_timeout - pkg_bash_local --> pkg_invariants - pkg_bash_local --> pkg_settings - pkg_bash_local --> pkg_shell - pkg_bash_local --> pkg_subprocess - pkg_bash_local --> pkg_timeout - pkg_pwsh_local --> pkg_invariants - pkg_pwsh_local --> pkg_settings - pkg_pwsh_local --> pkg_shell - pkg_pwsh_local --> pkg_subprocess - pkg_pwsh_local --> pkg_timeout + pkg_bash_sandbox --> pkg_bash_local + pkg_bash_sandbox --> pkg_invariants + pkg_bash_sandbox --> pkg_sandbox + pkg_bash_sandbox --> pkg_sandbox_policy + pkg_bash_sandbox --> pkg_shell + pkg_pwsh_sandbox --> pkg_invariants + pkg_pwsh_sandbox --> pkg_pwsh_local + pkg_pwsh_sandbox --> pkg_sandbox + pkg_pwsh_sandbox --> pkg_sandbox_policy + pkg_pwsh_sandbox --> pkg_shell pkg_terminal_bash --> pkg_agent pkg_terminal_bash --> pkg_invariants pkg_terminal_bash --> pkg_sandbox pkg_terminal_bash --> pkg_sandbox_policy pkg_terminal_bash --> pkg_session + pkg_terminal_bash --> pkg_session_projection pkg_terminal_bash --> pkg_subprocess pkg_terminal_bash --> pkg_terminal pkg_token_meter --> pkg_compaction @@ -719,6 +742,7 @@ flowchart TD pkg_agent_loop --> pkg_scope pkg_agent_loop --> pkg_session pkg_agent_loop --> pkg_session_persistence + pkg_agent_loop --> pkg_session_projection pkg_agent_loop --> pkg_settings pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools @@ -729,13 +753,9 @@ flowchart TD pkg_tool_goal --> pkg_invariants pkg_tool_goal --> pkg_llm pkg_tool_goal --> pkg_session + pkg_tool_goal --> pkg_session_projection pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools - pkg_fs_sandbox --> pkg_fs - pkg_fs_sandbox --> pkg_fs_local - pkg_fs_sandbox --> pkg_invariants - pkg_fs_sandbox --> pkg_sandbox - pkg_fs_sandbox --> pkg_sandbox_policy pkg_tool_fs --> pkg_attachment pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants @@ -763,6 +783,7 @@ flowchart TD pkg_tool_skill --> pkg_agent pkg_tool_skill --> pkg_invariants pkg_tool_skill --> pkg_llm + pkg_tool_skill --> pkg_session_projection pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools pkg_subagent --> pkg_agent @@ -811,14 +832,17 @@ flowchart TD pkg_hooks_codex --> pkg_llm pkg_hooks_codex --> pkg_session pkg_hooks_codex --> pkg_session_persistence + pkg_hooks_codex --> pkg_session_projection pkg_hooks_codex --> pkg_tools pkg_session_query_sqlite --> pkg_invariants pkg_session_query_sqlite --> pkg_session pkg_session_query_sqlite --> pkg_session_persistence pkg_session_query_sqlite --> pkg_session_query + pkg_tool_session_query --> pkg_agent pkg_tool_session_query --> pkg_invariants pkg_tool_session_query --> pkg_llm pkg_tool_session_query --> pkg_session + pkg_tool_session_query --> pkg_session_projection pkg_tool_session_query --> pkg_session_query pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout @@ -832,6 +856,7 @@ flowchart TD pkg_agent_instructions --> pkg_invariants pkg_agent_instructions --> pkg_llm pkg_agent_instructions --> pkg_session + pkg_agent_instructions --> pkg_session_projection pkg_agent_instructions --> pkg_tools pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compaction @@ -907,16 +932,6 @@ flowchart TD pkg_session_title_first_prompt_llm --> pkg_session pkg_session_title_first_prompt_llm --> pkg_session_title pkg_session_title_first_prompt_llm --> pkg_session_title_llm - pkg_bash_sandbox --> pkg_bash_local - pkg_bash_sandbox --> pkg_invariants - pkg_bash_sandbox --> pkg_sandbox - pkg_bash_sandbox --> pkg_sandbox_policy - pkg_bash_sandbox --> pkg_shell - pkg_pwsh_sandbox --> pkg_invariants - pkg_pwsh_sandbox --> pkg_pwsh_local - pkg_pwsh_sandbox --> pkg_sandbox - pkg_pwsh_sandbox --> pkg_sandbox_policy - pkg_pwsh_sandbox --> pkg_shell pkg_shell_env --> pkg_home_paths pkg_shell_env --> pkg_invariants pkg_shell_env --> pkg_session_persistence @@ -1001,6 +1016,7 @@ flowchart TD pkg_hooks_claude_code --> pkg_llm pkg_hooks_claude_code --> pkg_session pkg_hooks_claude_code --> pkg_session_persistence + pkg_hooks_claude_code --> pkg_session_projection pkg_hooks_claude_code --> pkg_subagent pkg_hooks_claude_code --> pkg_tools pkg_web_app --> pkg_invariants @@ -1026,6 +1042,7 @@ flowchart TD pkg_tool_cordis --> pkg_session pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools + pkg_host_apiproxy --> pkg_agent_loop pkg_host_apiproxy --> pkg_agent_presets pkg_host_apiproxy --> pkg_cordis_host_runner pkg_host_apiproxy --> pkg_invariants @@ -1470,7 +1487,6 @@ flowchart TD | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | @@ -1483,72 +1499,75 @@ flowchart TD | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`acp-snapshot`](../packages/test-support/acp-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | -| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`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) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | -| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | -| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`session-title`](../packages/session/session-title) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | -| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | -| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | -| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | +| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | +| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | +| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | +| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`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) | +| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | +| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | +| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`session-title`](../packages/session/session-title) | `session` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`bash-local`](../packages/shell/bash-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | +| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | -| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) | | [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | -| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | -| [`bash-local`](../packages/shell/bash-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) | +| [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | +| [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | +| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | -| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | -| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | +| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session-projection`](../packages/session/session-projection), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`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), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | -| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | +| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | -| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | @@ -1562,8 +1581,6 @@ flowchart TD | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | -| [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`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) | @@ -1577,12 +1594,12 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`team`](../packages/experimental/team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`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), [`invariants`](../packages/runtime-diagnostics/invariants), [`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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 6239520b7d..1054c845b4 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -438,12 +438,6 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_agent --> pkg_invariants - pkg_agent --> pkg_llm - pkg_agent --> pkg_scope - pkg_agent --> pkg_session - pkg_agent --> pkg_system_prompt - pkg_agent --> pkg_typert_protocol pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill pkg_web_fetch_http --> pkg_invariants @@ -480,40 +474,19 @@ flowchart TD pkg_session_projection --> pkg_session pkg_acp_snapshot --> pkg_invariants pkg_acp_snapshot --> pkg_session - pkg_llm_retry --> pkg_agent - pkg_llm_retry --> pkg_brand - pkg_llm_retry --> pkg_invariants - pkg_llm_retry --> pkg_llm - pkg_llm_retry --> pkg_session - pkg_llm_retry --> pkg_timeout - pkg_agent_default_model --> pkg_agent - pkg_agent_default_model --> pkg_invariants - pkg_agent_default_model --> pkg_llm - pkg_agent_default_model --> pkg_settings - pkg_goal --> pkg_agent - pkg_goal --> pkg_brand - pkg_goal --> pkg_invariants - pkg_goal --> pkg_llm - pkg_goal --> pkg_scope - pkg_goal --> pkg_session - pkg_goal --> pkg_session_projection - pkg_goal --> pkg_typert_protocol + pkg_agent --> pkg_invariants + pkg_agent --> pkg_llm + pkg_agent --> pkg_scope + pkg_agent --> pkg_session + pkg_agent --> pkg_session_projection + pkg_agent --> pkg_system_prompt + pkg_agent --> pkg_typert_protocol pkg_fs --> pkg_brand pkg_fs --> pkg_invariants pkg_fs --> pkg_llm pkg_fs --> pkg_sandbox - pkg_web_search_deepseek --> pkg_agent - pkg_web_search_deepseek --> pkg_credentials - pkg_web_search_deepseek --> pkg_invariants - pkg_web_search_deepseek --> pkg_launch_environment - pkg_web_search_deepseek --> pkg_session - pkg_web_search_deepseek --> pkg_settings - pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill - pkg_time_context --> pkg_agent - pkg_time_context --> pkg_invariants - pkg_time_context --> pkg_session pkg_message_feedback --> pkg_brand pkg_message_feedback --> pkg_invariants pkg_message_feedback --> pkg_llm @@ -521,45 +494,10 @@ flowchart TD pkg_message_feedback --> pkg_session_persistence pkg_message_feedback --> pkg_storage_domain pkg_message_feedback --> pkg_typert_protocol - pkg_commands --> pkg_agent - pkg_commands --> pkg_attachment - pkg_commands --> pkg_brand - pkg_commands --> pkg_invariants - pkg_commands --> pkg_llm - pkg_commands --> pkg_scope - pkg_commands --> pkg_session - pkg_commands --> pkg_typert_protocol - pkg_user_approval --> pkg_agent - pkg_user_approval --> pkg_brand - pkg_user_approval --> pkg_invariants - pkg_user_approval --> pkg_llm - pkg_user_approval --> pkg_scope - pkg_user_approval --> pkg_session - pkg_user_approval --> pkg_system_prompt - pkg_user_questions --> pkg_agent - pkg_user_questions --> pkg_invariants - pkg_user_questions --> pkg_llm - pkg_jobs --> pkg_agent - pkg_jobs --> pkg_brand - pkg_jobs --> pkg_invariants - pkg_jobs --> pkg_session - pkg_agent_presets --> pkg_agent - pkg_agent_presets --> pkg_atomic_write - pkg_agent_presets --> pkg_home_paths - pkg_agent_presets --> pkg_invariants - pkg_agent_presets --> pkg_scope - pkg_agent_presets --> pkg_session - pkg_agent_presets --> pkg_settings - pkg_agent_presets --> pkg_system_prompt pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox pkg_sandbox_local --> pkg_session - pkg_sandbox_policy --> pkg_agent - pkg_sandbox_policy --> pkg_invariants - pkg_sandbox_policy --> pkg_sandbox - pkg_sandbox_policy --> pkg_session - pkg_sandbox_policy --> pkg_system_prompt pkg_session_persistence_jsonl --> pkg_invariants pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence @@ -575,18 +513,128 @@ flowchart TD pkg_session_stats --> pkg_llm pkg_session_stats --> pkg_session pkg_session_stats --> pkg_session_projection + pkg_shell --> pkg_invariants + pkg_shell --> pkg_sandbox + pkg_shell --> pkg_settings + pkg_shell --> pkg_subprocess + pkg_workspace --> pkg_brand + pkg_workspace --> pkg_invariants + pkg_workspace --> pkg_session + pkg_workspace --> pkg_session_persistence + pkg_workspace --> pkg_storage + pkg_workspace --> pkg_storage_domain + pkg_llm_retry --> pkg_agent + pkg_llm_retry --> pkg_brand + pkg_llm_retry --> pkg_invariants + pkg_llm_retry --> pkg_llm + pkg_llm_retry --> pkg_session + pkg_llm_retry --> pkg_session_projection + pkg_llm_retry --> pkg_timeout + pkg_agent_default_model --> pkg_agent + pkg_agent_default_model --> pkg_invariants + pkg_agent_default_model --> pkg_llm + pkg_agent_default_model --> pkg_settings + pkg_goal --> pkg_agent + pkg_goal --> pkg_brand + pkg_goal --> pkg_invariants + pkg_goal --> pkg_llm + pkg_goal --> pkg_scope + pkg_goal --> pkg_session + pkg_goal --> pkg_session_projection + pkg_goal --> pkg_typert_protocol + pkg_fs_local --> pkg_fs + pkg_fs_local --> pkg_invariants + pkg_fs_observation_policy --> pkg_fs + pkg_fs_observation_policy --> pkg_invariants + pkg_skill_filesystem --> pkg_fs + pkg_skill_filesystem --> pkg_home_paths + pkg_skill_filesystem --> pkg_invariants + pkg_skill_filesystem --> pkg_skill + pkg_web_search_deepseek --> pkg_agent + pkg_web_search_deepseek --> pkg_credentials + pkg_web_search_deepseek --> pkg_invariants + pkg_web_search_deepseek --> pkg_launch_environment + pkg_web_search_deepseek --> pkg_session + pkg_web_search_deepseek --> pkg_settings + pkg_web_search_deepseek --> pkg_web + pkg_hook_protocol --> pkg_invariants + pkg_hook_protocol --> pkg_session + pkg_hook_protocol --> pkg_shell + pkg_time_context --> pkg_agent + pkg_time_context --> pkg_invariants + pkg_time_context --> pkg_session + pkg_time_context --> pkg_session_projection + pkg_tmux_context --> pkg_agent + pkg_tmux_context --> pkg_invariants + pkg_tmux_context --> pkg_session + pkg_tmux_context --> pkg_session_projection + pkg_tmux_context --> pkg_shell + pkg_fs_e2b --> pkg_e2b + pkg_fs_e2b --> pkg_fs + pkg_fs_e2b --> pkg_invariants + pkg_commands --> pkg_agent + pkg_commands --> pkg_attachment + pkg_commands --> pkg_brand + pkg_commands --> pkg_invariants + pkg_commands --> pkg_llm + pkg_commands --> pkg_scope + pkg_commands --> pkg_session + pkg_commands --> pkg_typert_protocol + pkg_user_approval --> pkg_agent + pkg_user_approval --> pkg_brand + pkg_user_approval --> pkg_invariants + pkg_user_approval --> pkg_llm + pkg_user_approval --> pkg_scope + pkg_user_approval --> pkg_session + pkg_user_approval --> pkg_session_projection + pkg_user_approval --> pkg_system_prompt + pkg_user_questions --> pkg_agent + pkg_user_questions --> pkg_invariants + pkg_user_questions --> pkg_llm + pkg_jobs --> pkg_agent + pkg_jobs --> pkg_brand + pkg_jobs --> pkg_invariants + pkg_jobs --> pkg_session + pkg_lsp_stdio --> pkg_brand + pkg_lsp_stdio --> pkg_fs + pkg_lsp_stdio --> pkg_invariants + pkg_lsp_stdio --> pkg_llm + pkg_lsp_stdio --> pkg_lsp + pkg_lsp_stdio --> pkg_subprocess + pkg_lsp_stdio --> pkg_timeout + pkg_agent_presets --> pkg_agent + pkg_agent_presets --> pkg_atomic_write + pkg_agent_presets --> pkg_home_paths + pkg_agent_presets --> pkg_invariants + pkg_agent_presets --> pkg_scope + pkg_agent_presets --> pkg_session + pkg_agent_presets --> pkg_settings + pkg_agent_presets --> pkg_system_prompt + pkg_sandbox_policy --> pkg_agent + pkg_sandbox_policy --> pkg_invariants + pkg_sandbox_policy --> pkg_sandbox + pkg_sandbox_policy --> pkg_session + pkg_sandbox_policy --> pkg_session_projection + pkg_sandbox_policy --> pkg_system_prompt pkg_session_telemetry --> pkg_agent pkg_session_telemetry --> pkg_invariants pkg_session_telemetry --> pkg_session + pkg_session_title --> pkg_agent pkg_session_title --> pkg_brand pkg_session_title --> pkg_invariants pkg_session_title --> pkg_llm pkg_session_title --> pkg_session pkg_session_title --> pkg_session_projection - pkg_shell --> pkg_invariants - pkg_shell --> pkg_sandbox - pkg_shell --> pkg_settings - pkg_shell --> pkg_subprocess + pkg_bash_local --> pkg_invariants + pkg_bash_local --> pkg_settings + pkg_bash_local --> pkg_shell + pkg_bash_local --> pkg_subprocess + pkg_bash_local --> pkg_timeout + pkg_pwsh_local --> pkg_invariants + pkg_pwsh_local --> pkg_settings + pkg_pwsh_local --> pkg_shell + pkg_pwsh_local --> pkg_subprocess + pkg_pwsh_local --> pkg_timeout pkg_terminal --> pkg_agent pkg_terminal --> pkg_brand pkg_terminal --> pkg_invariants @@ -599,12 +647,6 @@ flowchart TD pkg_workflow --> pkg_invariants pkg_workflow --> pkg_llm pkg_workflow --> pkg_session - pkg_workspace --> pkg_brand - pkg_workspace --> pkg_invariants - pkg_workspace --> pkg_session - pkg_workspace --> pkg_session_persistence - pkg_workspace --> pkg_storage - pkg_workspace --> pkg_storage_domain pkg_tools --> pkg_agent pkg_tools --> pkg_code_runtime pkg_tools --> pkg_invariants @@ -622,17 +664,11 @@ flowchart TD pkg_goal_round_driver --> pkg_invariants pkg_goal_round_driver --> pkg_llm pkg_goal_round_driver --> pkg_session - pkg_fs_local --> pkg_fs - pkg_fs_local --> pkg_invariants - pkg_fs_observation_policy --> pkg_fs - pkg_fs_observation_policy --> pkg_invariants - pkg_skill_filesystem --> pkg_fs - pkg_skill_filesystem --> pkg_home_paths - pkg_skill_filesystem --> pkg_invariants - pkg_skill_filesystem --> pkg_skill - pkg_hook_protocol --> pkg_invariants - pkg_hook_protocol --> pkg_session - pkg_hook_protocol --> pkg_shell + pkg_fs_sandbox --> pkg_fs + pkg_fs_sandbox --> pkg_fs_local + pkg_fs_sandbox --> pkg_invariants + pkg_fs_sandbox --> pkg_sandbox + pkg_fs_sandbox --> pkg_sandbox_policy pkg_session_query --> pkg_brand pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm @@ -655,13 +691,6 @@ flowchart TD pkg_compaction --> pkg_invariants pkg_compaction --> pkg_llm pkg_compaction --> pkg_session - pkg_tmux_context --> pkg_agent - pkg_tmux_context --> pkg_invariants - pkg_tmux_context --> pkg_session - pkg_tmux_context --> pkg_shell - pkg_fs_e2b --> pkg_e2b - pkg_fs_e2b --> pkg_fs - pkg_fs_e2b --> pkg_invariants pkg_command_feedback --> pkg_anonymous_user_id pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants @@ -681,33 +710,27 @@ flowchart TD pkg_jobs_local --> pkg_jobs pkg_jobs_local --> pkg_scope pkg_jobs_local --> pkg_timeout - pkg_lsp_stdio --> pkg_brand - pkg_lsp_stdio --> pkg_fs - pkg_lsp_stdio --> pkg_invariants - pkg_lsp_stdio --> pkg_llm - pkg_lsp_stdio --> pkg_lsp - pkg_lsp_stdio --> pkg_subprocess - pkg_lsp_stdio --> pkg_timeout pkg_session_title_llm --> pkg_invariants pkg_session_title_llm --> pkg_llm pkg_session_title_llm --> pkg_session pkg_session_title_llm --> pkg_session_title pkg_session_title_llm --> pkg_timeout - pkg_bash_local --> pkg_invariants - pkg_bash_local --> pkg_settings - pkg_bash_local --> pkg_shell - pkg_bash_local --> pkg_subprocess - pkg_bash_local --> pkg_timeout - pkg_pwsh_local --> pkg_invariants - pkg_pwsh_local --> pkg_settings - pkg_pwsh_local --> pkg_shell - pkg_pwsh_local --> pkg_subprocess - pkg_pwsh_local --> pkg_timeout + pkg_bash_sandbox --> pkg_bash_local + pkg_bash_sandbox --> pkg_invariants + pkg_bash_sandbox --> pkg_sandbox + pkg_bash_sandbox --> pkg_sandbox_policy + pkg_bash_sandbox --> pkg_shell + pkg_pwsh_sandbox --> pkg_invariants + pkg_pwsh_sandbox --> pkg_pwsh_local + pkg_pwsh_sandbox --> pkg_sandbox + pkg_pwsh_sandbox --> pkg_sandbox_policy + pkg_pwsh_sandbox --> pkg_shell pkg_terminal_bash --> pkg_agent pkg_terminal_bash --> pkg_invariants pkg_terminal_bash --> pkg_sandbox pkg_terminal_bash --> pkg_sandbox_policy pkg_terminal_bash --> pkg_session + pkg_terminal_bash --> pkg_session_projection pkg_terminal_bash --> pkg_subprocess pkg_terminal_bash --> pkg_terminal pkg_token_meter --> pkg_compaction @@ -721,6 +744,7 @@ flowchart TD pkg_agent_loop --> pkg_scope pkg_agent_loop --> pkg_session pkg_agent_loop --> pkg_session_persistence + pkg_agent_loop --> pkg_session_projection pkg_agent_loop --> pkg_settings pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools @@ -731,13 +755,9 @@ flowchart TD pkg_tool_goal --> pkg_invariants pkg_tool_goal --> pkg_llm pkg_tool_goal --> pkg_session + pkg_tool_goal --> pkg_session_projection pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools - pkg_fs_sandbox --> pkg_fs - pkg_fs_sandbox --> pkg_fs_local - pkg_fs_sandbox --> pkg_invariants - pkg_fs_sandbox --> pkg_sandbox - pkg_fs_sandbox --> pkg_sandbox_policy pkg_tool_fs --> pkg_attachment pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants @@ -765,6 +785,7 @@ flowchart TD pkg_tool_skill --> pkg_agent pkg_tool_skill --> pkg_invariants pkg_tool_skill --> pkg_llm + pkg_tool_skill --> pkg_session_projection pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools pkg_subagent --> pkg_agent @@ -813,14 +834,17 @@ flowchart TD pkg_hooks_codex --> pkg_llm pkg_hooks_codex --> pkg_session pkg_hooks_codex --> pkg_session_persistence + pkg_hooks_codex --> pkg_session_projection pkg_hooks_codex --> pkg_tools pkg_session_query_sqlite --> pkg_invariants pkg_session_query_sqlite --> pkg_session pkg_session_query_sqlite --> pkg_session_persistence pkg_session_query_sqlite --> pkg_session_query + pkg_tool_session_query --> pkg_agent pkg_tool_session_query --> pkg_invariants pkg_tool_session_query --> pkg_llm pkg_tool_session_query --> pkg_session + pkg_tool_session_query --> pkg_session_projection pkg_tool_session_query --> pkg_session_query pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout @@ -834,6 +858,7 @@ flowchart TD pkg_agent_instructions --> pkg_invariants pkg_agent_instructions --> pkg_llm pkg_agent_instructions --> pkg_session + pkg_agent_instructions --> pkg_session_projection pkg_agent_instructions --> pkg_tools pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compaction @@ -909,16 +934,6 @@ flowchart TD pkg_session_title_first_prompt_llm --> pkg_session pkg_session_title_first_prompt_llm --> pkg_session_title pkg_session_title_first_prompt_llm --> pkg_session_title_llm - pkg_bash_sandbox --> pkg_bash_local - pkg_bash_sandbox --> pkg_invariants - pkg_bash_sandbox --> pkg_sandbox - pkg_bash_sandbox --> pkg_sandbox_policy - pkg_bash_sandbox --> pkg_shell - pkg_pwsh_sandbox --> pkg_invariants - pkg_pwsh_sandbox --> pkg_pwsh_local - pkg_pwsh_sandbox --> pkg_sandbox - pkg_pwsh_sandbox --> pkg_sandbox_policy - pkg_pwsh_sandbox --> pkg_shell pkg_shell_env --> pkg_home_paths pkg_shell_env --> pkg_invariants pkg_shell_env --> pkg_session_persistence @@ -1003,6 +1018,7 @@ flowchart TD pkg_hooks_claude_code --> pkg_llm pkg_hooks_claude_code --> pkg_session pkg_hooks_claude_code --> pkg_session_persistence + pkg_hooks_claude_code --> pkg_session_projection pkg_hooks_claude_code --> pkg_subagent pkg_hooks_claude_code --> pkg_tools pkg_web_app --> pkg_invariants @@ -1028,6 +1044,7 @@ flowchart TD pkg_tool_cordis --> pkg_session pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools + pkg_host_apiproxy --> pkg_agent_loop pkg_host_apiproxy --> pkg_agent_presets pkg_host_apiproxy --> pkg_cordis_host_runner pkg_host_apiproxy --> pkg_invariants @@ -1472,7 +1489,6 @@ flowchart TD | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | @@ -1485,72 +1501,75 @@ flowchart TD | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`acp-snapshot`](../packages/test-support/acp-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | -| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`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) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | -| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | -| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`session-title`](../packages/session/session-title) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | -| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | -| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | -| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | +| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | +| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | +| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | +| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`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) | +| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | +| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | +| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`session-title`](../packages/session/session-title) | `session` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`bash-local`](../packages/shell/bash-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | +| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | -| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) | | [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | -| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | -| [`bash-local`](../packages/shell/bash-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) | +| [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | +| [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | +| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | -| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | -| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | +| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session-projection`](../packages/session/session-projection), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`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), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | -| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | +| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | -| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | @@ -1564,8 +1583,6 @@ flowchart TD | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | -| [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`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) | @@ -1579,12 +1596,12 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`team`](../packages/experimental/team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`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), [`invariants`](../packages/runtime-diagnostics/invariants), [`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) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index a45465099c..bee586ef93 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: c79c1bbf8f8fe9c3ed677f1cee160472b22a3584 -persistence-catalog.zh.md: 23e2c15810d97998b49cfee1d1e96204c30661f9 +persistence-catalog.md: db96226b648afecd65e74f50e14a82d036f7de74 +persistence-catalog.zh.md: 0a247b4c4c5bf7eedbf8b7b8120bac3828090bc6 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index c79c1bbf8f..db96226b64 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -115,7 +115,7 @@ Sources: [`packages/core/session/src/types.ts:336`](../packages/core/session/src } ``` -Source: [`packages/core/agent/src/types.ts:19`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:33`](../packages/core/agent/src/types.ts) ### `agent-preset/*` @@ -160,7 +160,7 @@ Source: [`packages/preset/agent-presets/src/session.ts:26`](../packages/preset/a Types: [CallId](subsystems/core.md) -Source: [`packages/interaction/user-approval/src/index.ts:44`](../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:73`](../packages/interaction/user-approval/src/index.ts) @@ -178,7 +178,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:44`](../packages/inter } ``` -Source: [`packages/interaction/user-approval/src/index.ts:55`](../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:84`](../packages/interaction/user-approval/src/index.ts) @@ -189,7 +189,8 @@ Source: [`packages/interaction/user-approval/src/index.ts:55`](../packages/inter * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy * from the runtime-context snapshot and live switch notices). The LAST - * such event is the session's override ({@link effectiveApprovalPolicy}). + * such event is the session's override (folded by the approvalPolicy + * projection unit). * `source: 'delegation'` marks an override seeded into a child; an absent * source is a runtime switch. */ @@ -200,7 +201,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:55`](../packages/inter } ``` -Source: [`packages/interaction/user-approval/src/index.ts:67`](../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:97`](../packages/interaction/user-approval/src/index.ts) ### `assistant/*` @@ -504,13 +505,13 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src /** * Records the selected preset as durable, log-only user intent. The knob * events follow in the same turn and control execution; this event stays - * out of the model transcript and lets {@link effectivePermissionPreset} + * out of the model transcript and lets the permission projection unit * preserve a selection when bundles match. */ 'permission/preset': { preset: string } ``` -Source: [`packages/interaction/permission-presets/src/index.ts:50`](../packages/interaction/permission-presets/src/index.ts) +Source: [`packages/interaction/permission-presets/src/index.ts:46`](../packages/interaction/permission-presets/src/index.ts) ### `plan/*` @@ -522,12 +523,12 @@ Source: [`packages/interaction/permission-presets/src/index.ts:50`](../packages/ /** * Whether plan mode is in force from this point on: log-only, non-surface, * whole-value replace. The last `plan/mode` wins; a log with none folds to - * inactive through {@link foldPlanMode}. + * inactive through the projection unit's fold. */ 'plan/mode': { active: boolean } ``` -Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:48`](../packages/plan/plan-mode/src/index.ts) ### `request/*` @@ -570,7 +571,7 @@ Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/ * The session's sandbox mode was switched — log-only (like `approval/*`; * NOT a surface event, carries no `surfaceOp`): durable and replayable, * never in the model transcript. The LAST such event is the session's - * override ({@link effectiveSandboxMode}). `source: 'delegation'` marks + * override (folded by the sandboxMode projection unit). `source: 'delegation'` marks * an override seeded into a child; an absent source is a runtime switch. */ 'sandbox/mode': { @@ -648,7 +649,7 @@ Source: [`packages/core/session/src/types.ts:332`](../packages/core/session/src/ Types: [SessionTitleEventData](subsystems/session-title.md) -Source: [`packages/session/session-title/src/index.ts:100`](../packages/session/session-title/src/index.ts) +Source: [`packages/session/session-title/src/index.ts:75`](../packages/session/session-title/src/index.ts) diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 23e2c15810..0a247b4c4c 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -117,7 +117,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/agent/src/types.ts:19`](../packages/core/agent/src/types.ts) +来源:[`packages/core/agent/src/types.ts:33`](../packages/core/agent/src/types.ts) ### `agent-preset/*` @@ -162,7 +162,7 @@ export type SessionEvent = { 类型:[CallId](subsystems/core.md) -来源:[`packages/interaction/user-approval/src/index.ts:44`](../packages/interaction/user-approval/src/index.ts) +来源:[`packages/interaction/user-approval/src/index.ts:73`](../packages/interaction/user-approval/src/index.ts) @@ -180,7 +180,7 @@ export type SessionEvent = { } ``` -来源:[`packages/interaction/user-approval/src/index.ts:55`](../packages/interaction/user-approval/src/index.ts) +来源:[`packages/interaction/user-approval/src/index.ts:84`](../packages/interaction/user-approval/src/index.ts) @@ -191,7 +191,8 @@ export type SessionEvent = { * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy * from the runtime-context snapshot and live switch notices). The LAST - * such event is the session's override ({@link effectiveApprovalPolicy}). + * such event is the session's override (folded by the approvalPolicy + * projection unit). * `source: 'delegation'` marks an override seeded into a child; an absent * source is a runtime switch. */ @@ -202,7 +203,7 @@ export type SessionEvent = { } ``` -来源:[`packages/interaction/user-approval/src/index.ts:67`](../packages/interaction/user-approval/src/index.ts) +来源:[`packages/interaction/user-approval/src/index.ts:97`](../packages/interaction/user-approval/src/index.ts) ### `assistant/*` @@ -506,13 +507,13 @@ export type SessionEvent = { /** * Records the selected preset as durable, log-only user intent. The knob * events follow in the same turn and control execution; this event stays - * out of the model transcript and lets {@link effectivePermissionPreset} + * out of the model transcript and lets the permission projection unit * preserve a selection when bundles match. */ 'permission/preset': { preset: string } ``` -来源:[`packages/interaction/permission-presets/src/index.ts:50`](../packages/interaction/permission-presets/src/index.ts) +来源:[`packages/interaction/permission-presets/src/index.ts:46`](../packages/interaction/permission-presets/src/index.ts) ### `plan/*` @@ -524,12 +525,12 @@ export type SessionEvent = { /** * Whether plan mode is in force from this point on: log-only, non-surface, * whole-value replace. The last `plan/mode` wins; a log with none folds to - * inactive through {@link foldPlanMode}. + * inactive through the projection unit's fold. */ 'plan/mode': { active: boolean } ``` -来源:[`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) +来源:[`packages/plan/plan-mode/src/index.ts:48`](../packages/plan/plan-mode/src/index.ts) ### `request/*` @@ -572,7 +573,7 @@ export type SessionEvent = { * The session's sandbox mode was switched — log-only (like `approval/*`; * NOT a surface event, carries no `surfaceOp`): durable and replayable, * never in the model transcript. The LAST such event is the session's - * override ({@link effectiveSandboxMode}). `source: 'delegation'` marks + * override (folded by the sandboxMode projection unit). `source: 'delegation'` marks * an override seeded into a child; an absent source is a runtime switch. */ 'sandbox/mode': { @@ -650,7 +651,7 @@ export type SessionEvent = { 类型:[SessionTitleEventData](subsystems/session-title.md) -来源:[`packages/session/session-title/src/index.ts:100`](../packages/session/session-title/src/index.ts) +来源:[`packages/session/session-title/src/index.ts:75`](../packages/session/session-title/src/index.ts) diff --git a/docs/subsystems/approval.i18n.yaml b/docs/subsystems/approval.i18n.yaml index 864e850f91..08495f66b2 100644 --- a/docs/subsystems/approval.i18n.yaml +++ b/docs/subsystems/approval.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/approval.md -approval.md: 8e0ae22f5ed4c68f460ddfd929cf481d24015a27 -approval.zh.md: f4a6371f9ac962db4d0cbce41399a47e4e444dbd +approval.md: ee8ab1cfca1fba6868b5f4931bab3dd9118a7a9b +approval.zh.md: f84d6a1bcd44e565a5cce85fbeb2a1194a2acade diff --git a/docs/subsystems/approval.md b/docs/subsystems/approval.md index 8e0ae22f5e..ee8ab1cfca 100644 --- a/docs/subsystems/approval.md +++ b/docs/subsystems/approval.md @@ -132,7 +132,7 @@ setPolicy(agent: Agent, policy: ApprovalPolicy): void async request(req: ApprovalRequest): Promise /** - * Read the session override without applying the configured default. + * Read the projected session override without applying the configured default. * @param session - session whose log supplies the override. * @returns the last logged policy, or `undefined` without one. */ @@ -141,7 +141,7 @@ overrideOf(session: Session): ApprovalPolicy | undefined Types: [Agent](core.md) · [Session](session.md) -Source: [`packages/interaction/user-approval/src/index.ts:192`](../../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:190`](../../packages/interaction/user-approval/src/index.ts) @@ -166,5 +166,5 @@ Ask composed answerers for one decision. Return an outcome to claim the request Types: [Scoped](scope.md) -Source: [`packages/interaction/user-approval/src/index.ts:30`](../../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:32`](../../packages/interaction/user-approval/src/index.ts) diff --git a/docs/subsystems/approval.zh.md b/docs/subsystems/approval.zh.md index f4a6371f9a..f84d6a1bcd 100644 --- a/docs/subsystems/approval.zh.md +++ b/docs/subsystems/approval.zh.md @@ -132,7 +132,7 @@ setPolicy(agent: Agent, policy: ApprovalPolicy): void async request(req: ApprovalRequest): Promise /** - * Read the session override without applying the configured default. + * Read the projected session override without applying the configured default. * @param session - session whose log supplies the override. * @returns the last logged policy, or `undefined` without one. */ @@ -141,7 +141,7 @@ overrideOf(session: Session): ApprovalPolicy | undefined Types: [Agent](core.md) · [Session](session.md) -Source: [`packages/interaction/user-approval/src/index.ts:192`](../../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:190`](../../packages/interaction/user-approval/src/index.ts) @@ -166,5 +166,5 @@ Ask composed answerers for one decision. Return an outcome to claim the request Types: [Scoped](scope.md) -Source: [`packages/interaction/user-approval/src/index.ts:30`](../../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:32`](../../packages/interaction/user-approval/src/index.ts) diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 1265b726fe..7c38ce0010 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: d14ad52e57572d5b0b110a0b16ff734e3499bdf5 -core.zh.md: 9ace28731f2532f571b66f2e7f6a3ea4a2c4e345 +core.md: 1f53a6f6831f4d969464c9ff83fd64a96b5cadd9 +core.zh.md: 083b88c136efec54bb45db3d736d7b131df9b048 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index d14ad52e57..1f53a6f683 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -377,7 +377,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise @@ -720,7 +720,7 @@ list(): Agent[] roots(): Agent[] ``` -Source: [`packages/core/agent/src/index.ts:256`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:257`](../../packages/core/agent/src/index.ts) @@ -1043,7 +1043,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers 'agent-loop/config-start-failed'(payload: { sessionId: SessionId; error: unknown }): void ``` -Source: [`packages/core/agent-loop/src/index.ts:183`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:242`](../../packages/core/agent-loop/src/index.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 9ace28731f..083b88c136 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -385,7 +385,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise @@ -728,7 +728,7 @@ list(): Agent[] roots(): Agent[] ``` -Source: [`packages/core/agent/src/index.ts:256`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:257`](../../packages/core/agent/src/index.ts) @@ -1051,7 +1051,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers 'agent-loop/config-start-failed'(payload: { sessionId: SessionId; error: unknown }): void ``` -Source: [`packages/core/agent-loop/src/index.ts:183`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:242`](../../packages/core/agent-loop/src/index.ts) diff --git a/docs/subsystems/goal.i18n.yaml b/docs/subsystems/goal.i18n.yaml index df731a5dba..3e48766d14 100644 --- a/docs/subsystems/goal.i18n.yaml +++ b/docs/subsystems/goal.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/goal.md -goal.md: 676b2f49d00681ac7f05b894cda0157ebba4cfcd -goal.zh.md: 1d46bc2c9ba1f3b35026ff132d323e46ccd3bc19 +goal.md: e5c8d77e7a897b097e7801f65024612d233319a9 +goal.zh.md: a4c8f26173a1ed4b991beaa02de7894258f50b6d diff --git a/docs/subsystems/goal.md b/docs/subsystems/goal.md index 676b2f49d0..e5c8d77e7a 100644 --- a/docs/subsystems/goal.md +++ b/docs/subsystems/goal.md @@ -247,7 +247,7 @@ block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView Types: [Agent](core.md) -Source: [`packages/goal/goal/src/index.ts:183`](../../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:182`](../../packages/goal/goal/src/index.ts) diff --git a/docs/subsystems/goal.zh.md b/docs/subsystems/goal.zh.md index 1d46bc2c9b..a4c8f26173 100644 --- a/docs/subsystems/goal.zh.md +++ b/docs/subsystems/goal.zh.md @@ -247,7 +247,7 @@ block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView Types: [Agent](core.md) -Source: [`packages/goal/goal/src/index.ts:183`](../../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:182`](../../packages/goal/goal/src/index.ts) diff --git a/docs/subsystems/permission-presets.i18n.yaml b/docs/subsystems/permission-presets.i18n.yaml index 9f048fd3be..07d7e5ea9f 100644 --- a/docs/subsystems/permission-presets.i18n.yaml +++ b/docs/subsystems/permission-presets.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/permission-presets.md -permission-presets.md: 9fc0fe8d0c347cc8a60b20df04542963aac03bb1 -permission-presets.zh.md: 3ae37c6c0cf3c4ef77be41968f446fb4f2199bd9 +permission-presets.md: 55f74f86478b96807678a9e0040a45fdd209503f +permission-presets.zh.md: 3e8877d28556eedca9218bd87f85e5bd0768b741 diff --git a/docs/subsystems/permission-presets.md b/docs/subsystems/permission-presets.md index 9fc0fe8d0c..55f74f8647 100644 --- a/docs/subsystems/permission-presets.md +++ b/docs/subsystems/permission-presets.md @@ -45,7 +45,7 @@ The service requires a confining `ctx.shell` executor and `ctx.approval`, and mi ## Current preset and the derived `custom` -`current(events)` derives the effective preset from the knobs, not from its own event alone: it folds the session's effective sandbox mode (falling back to the executor's configured mode) and effective approval policy (falling back to the approval service config, then `ask`), prefers a still-matching recorded selection, then the first matching table entry in declaration order, and otherwise returns `CUSTOM_PRESET` (`'custom'`). `custom` is derived-only: clients may display it as the current value, but it is never a switch target or an event payload. +`current(session)` derives the effective preset from the required `permissions` projection state, not from its own event alone. The unit folds the session's effective sandbox mode (falling back to the executor's configured mode), effective approval policy (falling back to the approval service config, then `ask`), and recorded selection. The service prefers a still-matching selection, then the first matching table entry in declaration order, and otherwise returns `CUSTOM_PRESET` (`'custom'`). `custom` is derived-only: clients may display it as the current value, but it is never a switch target or an event payload. `names` lists the switchable presets in table declaration order; `optionOf(name)` builds the option a client renders for a table key (label falls back to the key) or for `custom`, and throws for any other name. @@ -65,7 +65,7 @@ interface PresetOption { `set(session, name)` resolves the preset (unknown names throw), appends a log-only `permission/preset` event unless `name` is already the effective preset, then writes each knob through its own setter — `setSandboxMode` from [dsh-sandbox-policy](../../packages/sandbox/sandbox-policy) and `setApprovalPolicy` from [dsh-user-approval](../../packages/interaction/user-approval) — only when that knob's effective value changes. The selection event precedes the knob events in the same turn, and re-selecting the effective preset appends nothing at all. -`permission/preset` is durable, log-only user intent: it stays out of the model transcript (the knob events own the model-visible consequences through their consumers), and it exists so `current()` can preserve WHICH preset the user chose when two presets share a bundle; `effectivePermissionPreset(events)` folds the last one, and replay needs no catch-up state. The complete event declaration is in the [persistence log event catalog](../persistence-catalog.md); the method signatures are in the generated [service catalog](#ctxpermissionpresets--permissionpresetservice). +`permission/preset` is durable, log-only user intent: it stays out of the model transcript (the knob events own the model-visible consequences through their consumers), and it exists so `current()` can preserve WHICH preset the user chose when two presets share a bundle. The `permissions` projection folds that selection with both knob events; replay needs no catch-up state. The complete event declaration is in the [persistence log event catalog](../persistence-catalog.md); the method signatures are in the generated [service catalog](#ctxpermissionpresets--permissionpresetservice). @@ -86,10 +86,10 @@ Owns the deployment's permission presets and their write path. Requires a confin * Resolve the preset matching the effective knob values. A still-matching * last selection wins shared-bundle ties; otherwise the first table match * wins, or {@link CUSTOM_PRESET} when no entry matches. - * @param events - the session's events in log order. + * @param session - the session whose knob state is read. * @returns the effective preset name, or `custom` when nothing matches. */ -current(events: readonly SessionEvent[]): string +current(session: Session): string /** * Build the whole select value for one folded knob state: every table @@ -125,7 +125,7 @@ optionOf(name: string): PresetOption set(session: Session, name: string): void ``` -Types: [Session](session.md) · [SessionEvent](session.md) +Types: [Session](session.md) -Source: [`packages/interaction/permission-presets/src/index.ts:175`](../../packages/interaction/permission-presets/src/index.ts) +Source: [`packages/interaction/permission-presets/src/index.ts:135`](../../packages/interaction/permission-presets/src/index.ts) diff --git a/docs/subsystems/permission-presets.zh.md b/docs/subsystems/permission-presets.zh.md index 3ae37c6c0c..3e8877d285 100644 --- a/docs/subsystems/permission-presets.zh.md +++ b/docs/subsystems/permission-presets.zh.md @@ -45,7 +45,7 @@ interface Config { ## 当前预设与派生的 `custom` -`current(events)` 从 knob 派生实际生效的预设,而不是只看自身事件:它折叠会话的生效沙箱模式(回退到执行器配置的模式)与生效审批策略(先回退到审批服务配置,再回退到 `ask`),优先取仍然匹配的已记录选择,其次取声明顺序中第一个匹配的表项,否则返回 `CUSTOM_PRESET`(`'custom'`)。`custom` 只是派生值:客户端可以把它显示为当前值,但它绝不是切换目标,也绝不出现在事件 payload 中。 +`current(session)` 从必需的 `permissions` 投影状态派生实际生效的预设,而不是只看自身事件。该单元折叠会话的生效沙箱模式(回退到执行器配置的模式)、生效审批策略(先回退到审批服务配置,再回退到 `ask`)和已记录选择。服务优先取仍然匹配的选择,其次取声明顺序中第一个匹配的表项,否则返回 `CUSTOM_PRESET`(`'custom'`)。`custom` 只是派生值:客户端可以把它显示为当前值,但它绝不是切换目标,也绝不出现在事件 payload 中。 `names` 按预设表声明顺序列出可切换的预设;`optionOf(name)` 为某个表键(label 回退为该键)或 `custom` 构建客户端渲染的选项,传入其他任何名称都会抛出异常。 @@ -65,7 +65,7 @@ interface PresetOption { `set(session, name)` 解析预设(未知名称抛出异常),在 `name` 尚不是生效预设时追加一条仅记日志的 `permission/preset` 事件,然后通过各旋钮自己的 setter([dsh-sandbox-policy](../../packages/sandbox/sandbox-policy) 的 `setSandboxMode` 与 [dsh-user-approval](../../packages/interaction/user-approval) 的 `setApprovalPolicy`)写入,且仅当该 knob的生效值发生变化时才写。同一轮次内,选择事件先于旋钮事件出现;重新选择当前生效的预设则什么都不追加。 -`permission/preset` 是持久、仅记日志的用户意图:它不进入模型 transcript(文本记录),模型可见的后果由 knob 事件经各自消费方承担;它存在是为了在两个预设共享同一个旋钮组合时,让 `current()` 仍能保住用户选择的究竟是哪一个预设;`effectivePermissionPreset(events)` 折叠最后一条,回放不需要任何追赶状态。完整事件声明见[持久化日志事件目录](../persistence-catalog.md);方法签名见生成的[服务目录](#ctxpermissionpresets--permissionpresetservice)。 +`permission/preset` 是持久、仅记日志的用户意图:它不进入模型 transcript(文本记录),模型可见的后果由 knob 事件经各自消费方承担;它存在是为了在两个预设共享同一个旋钮组合时,让 `current()` 仍能保住用户选择的究竟是哪一个预设。`permissions` 投影把该选择与两个 knob 事件一同折叠,回放不需要任何追赶状态。完整事件声明见[持久化日志事件目录](../persistence-catalog.md);方法签名见生成的[服务目录](#ctxpermissionpresets--permissionpresetservice)。 @@ -86,10 +86,10 @@ Owns the deployment's permission presets and their write path. Requires a confin * Resolve the preset matching the effective knob values. A still-matching * last selection wins shared-bundle ties; otherwise the first table match * wins, or {@link CUSTOM_PRESET} when no entry matches. - * @param events - the session's events in log order. + * @param session - the session whose knob state is read. * @returns the effective preset name, or `custom` when nothing matches. */ -current(events: readonly SessionEvent[]): string +current(session: Session): string /** * Build the whole select value for one folded knob state: every table @@ -125,7 +125,7 @@ optionOf(name: string): PresetOption set(session: Session, name: string): void ``` -Types: [Session](session.md) · [SessionEvent](session.md) +Types: [Session](session.md) -Source: [`packages/interaction/permission-presets/src/index.ts:175`](../../packages/interaction/permission-presets/src/index.ts) +Source: [`packages/interaction/permission-presets/src/index.ts:135`](../../packages/interaction/permission-presets/src/index.ts) diff --git a/docs/subsystems/plan.i18n.yaml b/docs/subsystems/plan.i18n.yaml index 14823f0e11..34f012f697 100644 --- a/docs/subsystems/plan.i18n.yaml +++ b/docs/subsystems/plan.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/plan.md -plan.md: 11abe27f2bfa0f2d32bd9d37600792ba915d63fb -plan.zh.md: 47fa160beb4651850a44b325edb6700b116855a4 +plan.md: e8e84bc92792198ca56f0abb7e208ff33f5c0ab6 +plan.zh.md: 1a848085342a2bceeda6515fde529bf2d7f5d5e1 diff --git a/docs/subsystems/plan.md b/docs/subsystems/plan.md index 11abe27f2b..e8e84bc927 100644 --- a/docs/subsystems/plan.md +++ b/docs/subsystems/plan.md @@ -8,7 +8,7 @@ Source: [`packages/plan/plan-mode/src/index.ts`](../../packages/plan/plan-mode/s ## Logged state and recovery -`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace [session event](session.md): durable and replayable, never in the model transcript. `foldPlanMode(events, end?)` returns the last logged value in the prefix, or `false` when there is none — the state in force is always a pure fold of the session log, so resume, fork, and compaction recover it with no live mirror, and UIs observe committed flips through `session/event`. The complete event declaration is in the [persistence log event catalog](../persistence-catalog.md). +`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace [session event](session.md): durable and replayable, never in the model transcript. The required `plan` session-projection unit folds committed mode, command settlement, and the mode recorded at the latest request header. `ctx.planMode` reads that host state through `stateOf()`, while clients receive only `{ active, pending }`; resume, fork, and compaction recover both from the log. The complete event declaration is in the [persistence log event catalog](../persistence-catalog.md). ## Pending selections and the pre-step append @@ -50,7 +50,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.planMode` — `PlanModeController` -`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. UIs observe committed flips through `session/event`; there is no live mirror. +`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. Client carriers expose the projection's cropped `{ active, pending }` view. ```ts cordis-catalog /** @@ -83,5 +83,5 @@ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop Types: [Agent](core.md) -Source: [`packages/plan/plan-mode/src/index.ts:199`](../../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:169`](../../packages/plan/plan-mode/src/index.ts) diff --git a/docs/subsystems/plan.zh.md b/docs/subsystems/plan.zh.md index 47fa160beb..1a84808534 100644 --- a/docs/subsystems/plan.zh.md +++ b/docs/subsystems/plan.zh.md @@ -8,7 +8,7 @@ ## 已记录状态与恢复 -`plan/mode`(`{ active: boolean }`)是仅记日志、整值替换的[会话事件](session.md):持久且可回放,绝不进入模型 transcript(文本记录)。`foldPlanMode(events, end?)` 返回前缀中最后一条已记录值,没有时返回 `false`:生效状态始终是会话日志的纯折叠,因此恢复、fork 与压缩(compaction)无需实时镜像即可将其复原,UI 通过 `session/event` 观察已提交的切换。完整事件声明见[持久化日志事件目录](../persistence-catalog.md)。 +`plan/mode`(`{ active: boolean }`)是仅记日志、整值替换的[会话事件](session.md):持久且可回放,绝不进入模型 transcript(文本记录)。必需的 `plan` 会话投影单元折叠已提交模式、命令结算结果和最近一次请求头记录的模式。`ctx.planMode` 通过 `stateOf()` 读取该 host 状态,而客户端只接收 `{ active, pending }`;恢复、fork 与压缩(compaction)都能从日志恢复两者。完整事件声明见[持久化日志事件目录](../persistence-catalog.md)。 ## 待生效选择与 pre-step 追加 @@ -50,7 +50,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.planMode` — `PlanModeController` -`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. UIs observe committed flips through `session/event`; there is no live mirror. +`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. Client carriers expose the projection's cropped `{ active, pending }` view. ```ts cordis-catalog /** @@ -83,5 +83,5 @@ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop Types: [Agent](core.md) -Source: [`packages/plan/plan-mode/src/index.ts:199`](../../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:169`](../../packages/plan/plan-mode/src/index.ts) diff --git a/docs/subsystems/sandbox.i18n.yaml b/docs/subsystems/sandbox.i18n.yaml index 400bf54df3..b46cd3a8be 100644 --- a/docs/subsystems/sandbox.i18n.yaml +++ b/docs/subsystems/sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/sandbox.md -sandbox.md: 19f1807cf4fc7fa5e7ae0843daeee818a7e2bdde -sandbox.zh.md: 78ee0f162d1179abea723465bfd462ef9b9e255e +sandbox.md: bb35f3d655844e65fd30f1016fa31aec55ed8bc1 +sandbox.zh.md: bf8567944ec5789371002d376eb5181d4770381b diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index 19f1807cf4..bb35f3d655 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -190,7 +190,7 @@ Source: [`packages/sandbox/sandbox/src/index.ts:158`](../../packages/sandbox/san ### `ctx.sandboxPolicy` — `SandboxPolicyService` -The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, and current request-time policy section. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability. +The sandbox policy seam: the deployment default mode and workspace-write root, with per-session overrides folded from the log. ```ts cordis-catalog /** @@ -214,5 +214,5 @@ overrideOf(session: Session): SandboxMode | undefined Types: [Session](session.md) -Source: [`packages/sandbox/sandbox-policy/src/index.ts:91`](../../packages/sandbox/sandbox-policy/src/index.ts) +Source: [`packages/sandbox/sandbox-policy/src/index.ts:108`](../../packages/sandbox/sandbox-policy/src/index.ts) diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 78ee0f162d..bf8567944e 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -190,7 +190,7 @@ Source: [`packages/sandbox/sandbox/src/index.ts:158`](../../packages/sandbox/san ### `ctx.sandboxPolicy` — `SandboxPolicyService` -The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, and current request-time policy section. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability. +The sandbox policy seam: the deployment default mode and workspace-write root, with per-session overrides folded from the log. ```ts cordis-catalog /** @@ -214,5 +214,5 @@ overrideOf(session: Session): SandboxMode | undefined Types: [Session](session.md) -Source: [`packages/sandbox/sandbox-policy/src/index.ts:91`](../../packages/sandbox/sandbox-policy/src/index.ts) +Source: [`packages/sandbox/sandbox-policy/src/index.ts:108`](../../packages/sandbox/sandbox-policy/src/index.ts) diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index cb6375dd42..de2d3209b4 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md -session-projection.md: c0e75ddad59cd764025269fef1f79f07472b74fe -session-projection.zh.md: 24c96b6055a1f89ee256253c3005e9ca100a4f90 +session-projection.md: 3a354c081e38d23eb5ba337f3d4973788f1fe124 +session-projection.zh.md: 378b22faff90d15b3e34dc31112597a64a3a7b44 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index c0e75ddad5..3a354c081e 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -2,7 +2,7 @@ English | [中文](session-projection.zh.md) -The session-projection seam — a [capability seam](../capability-seams.md) through which domain host plugins serve whole current values of log-derived per-session state to client carriers: the Service Definition and registry ([dsh-session-projection](../../packages/session/session-projection), `ctx.sessionProjections`), domain contributors (each registering one pure unit), and carriers ([dsh-host-apiproxy](../../packages/host/apiproxy)'s history tail page and `session/projection` push frame). It is one optional capability, not part of the agent-loop spine. The framework drives, the domain computes: the registry subscribes to `session/event` once and folds every committed event through every unit; domains hold no subscriptions and clients never fold domain events — they receive finished values. Design authority: the [session-projection RFC](../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md); drive/cache/feed contracts: the [package README](../../packages/session/session-projection/README.md). +The session-projection [capability seam](../capability-seams.md) serves log-derived per-session state to host consumers and client carriers. Its registry ([dsh-session-projection](../../packages/session/session-projection), `ctx.sessionProjections`) drives pure domain units; [dsh-host-apiproxy](../../packages/host/apiproxy) carries selected values on history/list baselines and `session/projection` frames. Plugins that contribute or read units require the registry. Design authority: the [session-projection RFC](../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md); drive/cache/feed contracts: the [package README](../../packages/session/session-projection/README.md). Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) @@ -100,7 +100,7 @@ type ProjectionChangeListener = ( ## The registry: `ctx.sessionProjections` -`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect whose disposer rides the calling fiber: an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots, and clients read that as capability absence; duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. +`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect whose disposer rides the calling fiber: an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots, and clients read that as capability absence. Unit contributors and host readers require the registry, so an incomplete composition fails during activation ([decision](../../.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md)). Registrants of the same compatible definition share the unit until the last registration is disposed. @@ -165,7 +165,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Unit contributors and host readers require this service, so incomplete compositions fail during activation. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 24c96b6055..378b22faff 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -2,7 +2,7 @@ [English](session-projection.md) | 中文 -会话投影 seam 是一项[能力 seam](../capability-seams.md):领域 host 插件经由它向客户端载体供给按会话的日志派生状态的当前全量值;三方分别是 Service Definition 与注册表([dsh-session-projection](../../packages/session/session-projection),`ctx.sessionProjections`)、领域贡献方(每个领域注册一个纯单元)与载体([dsh-host-apiproxy](../../packages/host/apiproxy) 的历史尾页与 `session/projection` 推送帧)。它是一项可选能力,不属于 agent loop(智能体循环)主干。框架负责驱动,领域负责计算:注册表只订阅一次 `session/event`,并把每个已提交事件折叠进每个单元;领域不持有任何订阅,客户端也从不折叠领域事件——它们收到的是成品值。设计权威:[session-projection RFC](../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md);驱动、缓存与变更流约定:[包 README](../../packages/session/session-projection/README.md)。 +会话投影[能力 seam](../capability-seams.md)向 host 消费方和客户端载体提供按会话的日志派生状态。其注册表([dsh-session-projection](../../packages/session/session-projection),`ctx.sessionProjections`)驱动纯领域单元;[dsh-host-apiproxy](../../packages/host/apiproxy)在历史/列表基线和 `session/projection` 帧中承载选定值。贡献或读取单元的插件要求该注册表。设计权威:[session-projection RFC](../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md);驱动、缓存与变更流约定:[包 README](../../packages/session/session-projection/README.md)。 源码:[`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) @@ -100,7 +100,7 @@ type ProjectionChangeListener = ( ## 注册表:`ctx.sessionProjections` -`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动权:一份 `session/event` 订阅、对每个已注册单元即时调用 `apply`,以及每会话每单元的水位线(watermark)cell。cell 惰性构建:在事件流过之后才注册的单元,或比注册表更早的会话,都在首次触达(事件或读取)时从 `init` 出发在内存日志上折叠。注册是一个 effect,其 disposer 随调用方 fiber 走:领域插件卸载后,其 key(连同缓存的 cell)从后续驱动与快照中消失,客户端将其读作能力缺失;key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。 +`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动权:一份 `session/event` 订阅、对每个已注册单元即时调用 `apply`,以及每会话每单元的水位线(watermark)cell。cell 惰性构建:在事件流过之后才注册的单元,或比注册表更早的会话,都在首次触达(事件或读取)时从 `init` 出发在内存日志上折叠。注册是一个 effect,其 disposer 随调用方 fiber 走:领域插件卸载后,其 key(连同缓存的 cell)从后续驱动与快照中消失,客户端将其读作能力缺失。单元贡献方与 host 读取方要求该注册表,因此不完整的组合会在激活时失败([决策](../../.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md))。相同且兼容定义的多个注册者共享该单元,直到最后一项注册被 dispose。 @@ -165,7 +165,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Unit contributors and host readers require this service, so incomplete compositions fail during activation. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** diff --git a/docs/subsystems/session-title.i18n.yaml b/docs/subsystems/session-title.i18n.yaml index 2dbeaad759..721bbb0459 100644 --- a/docs/subsystems/session-title.i18n.yaml +++ b/docs/subsystems/session-title.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-title.md -session-title.md: 6952cd7289861e1f83e76ef7ae9a115a404ee671 -session-title.zh.md: 37442e258325e23fe6d3b288dd8c680efd2d0ca4 +session-title.md: d872086500cf477cfa88f8eb756fe9b7be5fc571 +session-title.zh.md: 01aa62a24320e9c9329641603529c1ced9da4360 diff --git a/docs/subsystems/session-title.md b/docs/subsystems/session-title.md index 6952cd7289..d872086500 100644 --- a/docs/subsystems/session-title.md +++ b/docs/subsystems/session-title.md @@ -8,7 +8,7 @@ Sources: [`packages/session/session-title/src/index.ts`](../../packages/session/ ## Durable title state -`SessionTitleProviderId` is recorded for provider-produced revisions. `SessionTitleEventData` lists the exact human-message seqs used for the title, while `SessionTitleSnapshot` adds the durable event envelope facts selected by `foldSessionTitle()`. +`SessionTitleProviderId` is recorded for provider-produced revisions. `SessionTitleEventData` lists the exact human-message seqs used for the title, while `SessionTitleSnapshot` adds the durable event envelope facts retained in the `title` projection state. `ctx.sessionTitle.get()` reads that state through `stateOf()`; the client view remains only the title string or `null`. `foldSessionTitle()` provides the same selection for detached logs. ```ts type-equiv /** Identifies one session-title provider registration. */ @@ -200,5 +200,5 @@ register(provider: SessionTitleProvider): () => Promise Types: [Session](session.md) -Source: [`packages/session/session-title/src/index.ts:261`](../../packages/session/session-title/src/index.ts) +Source: [`packages/session/session-title/src/index.ts:282`](../../packages/session/session-title/src/index.ts) diff --git a/docs/subsystems/session-title.zh.md b/docs/subsystems/session-title.zh.md index 37442e2583..01aa62a243 100644 --- a/docs/subsystems/session-title.zh.md +++ b/docs/subsystems/session-title.zh.md @@ -8,7 +8,7 @@ ## 持久标题状态 -提供方生成修订时会记录 `SessionTitleProviderId`。`SessionTitleEventData` 列出生成标题时使用的精确人类消息 seq,`SessionTitleSnapshot` 则加入 `foldSessionTitle()` 选出的持久事件封装信息。 +提供方生成修订时会记录 `SessionTitleProviderId`。`SessionTitleEventData` 列出生成标题时使用的精确人类消息 seq,`SessionTitleSnapshot` 则加入 `title` 投影状态保留的持久事件封装信息。`ctx.sessionTitle.get()` 通过 `stateOf()` 读取该状态;客户端视图仍只包含标题字符串或 `null`。`foldSessionTitle()` 为脱离服务的日志提供相同选择。 ```ts type-equiv /** Identifies one session-title provider registration. */ @@ -200,5 +200,5 @@ register(provider: SessionTitleProvider): () => Promise Types: [Session](session.md) -Source: [`packages/session/session-title/src/index.ts:261`](../../packages/session/session-title/src/index.ts) +Source: [`packages/session/session-title/src/index.ts:282`](../../packages/session/session-title/src/index.ts) diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 42411e4786..f89d4dc223 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md -subagent.md: fee5f95d4a8810959e452653ecf9d7bcf36b7387 -subagent.zh.md: 38136f27172c071af95bf3b3cce7986af499368f +subagent.md: e998c2fbea2d2e2a89798275466a4c00a205e895 +subagent.zh.md: 0eaf927552b6e19ab4f7b58a47c2409da44498d3 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index fee5f95d4a..e998c2fbea 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -286,7 +286,7 @@ A local one-shot provider appends the descriptor inside the child's initial turn ## Durable enumeration: `listChildren()`, `listDescendants()`, and their entries -`SubagentRuntime.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from the live-preferred merge of `ctx.sessions.list()` and optional `ctx.sessionPersistence.list()` — no query service, and no Agent is loaded or resumed. Candidates are the direct children whose durable header carries `origin: 'subagent'`; the marker classifies enumeration and coarse generic-route denial but cannot establish a valid descriptor, resumability, or authorization — the projection fold owns identity, and the Activation contract owns resume. Each row's `mode`/`label` is the registered `subagent` projection unit's value, served through a three-rung ladder: the registry's watermark cache for a live child (zero log reads); the optional projection checkpoint cache for a cold one (`cachedSnapshot` — an identity passing the own-suffix seq gate is final, because an own descriptor is immutable once appended); otherwise one `persistence.inspect()` reading folded through the registry (bounded concurrency, recomputed per listing). The cache is a pure optional accelerator: absent, serving the `null` sentinel or missing the key, failing the seq gate, or faulting, it falls silently through to the authoritative refold. The fold is `subagent/descriptor` last-wins with no failure channel: the child's own descriptor overrides a fork-seeded ancestor's, and a malformed or unknown-version payload folds to a serializable `null` sentinel, treated as no value. The result is one `SubagentListEntry[]` in `createdAt`-then-id order: a served identity yields a `child` entry with `mode: 'one-shot' | 'continuable'` and `activity: 'running' | 'inactive'`; continuable entries always carry `label`, while one-shot entries carry it only when the start caller supplied presentation metadata. A settled candidate whose fold served no identity yields a `corrupt` diagnostic — missing, malformed, and unknown-version descriptors deliberately undistinguished (`unsupported` remains in the type but is never produced); a running candidate without an identity is omitted (the creation window before its descriptor lands); a failed cold inspection yields one `unavailable` diagnostic retried on the next listing, so one damaged sibling cannot hide healthy children. `hasChildren` marks a direct descendant with durable subagent origin, read from the same merged material. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. Absent persistence, enumeration is live-only rather than an error — a cold child cannot be resumed then either. `listChildren()` throws `SubagentError` with code `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` when the `ctx.sessionProjections` registry is absent and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` when the session store is, both checked before any read so a deployment with zero children still fails deterministically; the list tool requires `ctx.subagents` and `ctx.agents` at plugin load. A service consumer such as a UI can display both modes and choose an unlabeled one-shot fallback, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) keeps only continuable entries and refines status through the live Agent registry into its own `running`/`idle`/`ready` vocabulary, whose `ready` names a storage-only child as resumable rather than terminal. Listing does not consult the continuation manager's Activation map, Agent registry, or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed running continuable child may still reject delivery as an ownership conflict. The read-path rationale lives in [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md). +`SubagentRuntime.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from the live-preferred merge of `ctx.sessions.list()` and optional `ctx.sessionPersistence.list()` — no query service, and no Agent is loaded or resumed. Candidates are the direct children whose durable header carries `origin: 'subagent'`; the marker classifies enumeration and coarse generic-route denial but cannot establish a valid descriptor, resumability, or authorization — the projection fold owns identity, and the Activation contract owns resume. Each row's `mode`/`label` is the registered `subagent` projection unit's value, served through a three-rung ladder: `stateOf()` for a live child (zero log reads); the optional projection checkpoint cache for a cold one (`cachedSnapshot` — an identity passing the own-suffix seq gate is final, because an own descriptor is immutable once appended); otherwise one `persistence.inspect()` reading folded through the registry (bounded concurrency, recomputed per listing). The cache is a pure optional accelerator: absent, serving the `null` sentinel or missing the key, failing the seq gate, or faulting, it falls silently through to the authoritative refold. The fold is `subagent/descriptor` last-wins with no failure channel: the child's own descriptor overrides a fork-seeded ancestor's, and a malformed or unknown-version payload folds to a serializable `null` sentinel, treated as no value. The result is one `SubagentListEntry[]` in `createdAt`-then-id order: a served identity yields a `child` entry with `mode: 'one-shot' | 'continuable'` and `activity: 'running' | 'inactive'`; continuable entries always carry `label`, while one-shot entries carry it only when the start caller supplied presentation metadata. A settled candidate whose fold served no identity yields a `corrupt` diagnostic — missing, malformed, and unknown-version descriptors deliberately undistinguished (`unsupported` remains in the type but is never produced); a running candidate without an identity is omitted (the creation window before its descriptor lands); a failed cold inspection yields one `unavailable` diagnostic retried on the next listing, so one damaged sibling cannot hide healthy children. `hasChildren` marks a direct descendant with durable subagent origin, read from the same merged material. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. Absent persistence, enumeration is live-only rather than an error — a cold child cannot be resumed then either. `listChildren()` requires the runtime's projection registry and throws `SubagentError` with code `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` when the session store is absent, checked before any read so a deployment with zero children still fails deterministically; the list tool requires `ctx.subagents` and `ctx.agents` at plugin load. A service consumer such as a UI can display both modes and choose an unlabeled one-shot fallback, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) keeps only continuable entries and refines status through the live Agent registry into its own `running`/`idle`/`ready` vocabulary, whose `ready` names a storage-only child as resumable rather than terminal. Listing does not consult the continuation manager's Activation map, Agent registry, or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed running continuable child may still reject delivery as an ownership conflict. The read-path rationale lives in [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md). `SubagentRuntime.listDescendants(rootSessionId)` applies the same live-preferred corpus and projection-backed interpretation to the root's complete descendant tree in stable pre-order. Ordinary sessions and one-shot children remain traversal nodes, so continuable descendants below them are discovered; only `origin: 'subagent'` candidates produce rows. Each returned child or diagnostic adds its position from the enumerated durable header, while a cold inspection revalidates that complete lifecycle before serving identity: @@ -591,7 +591,7 @@ async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): P * row when the optional cache serves an own-suffix identity (its `seq` * gate proves the value postdates the fork seed, where a child's own * descriptor is immutable once appended), else one persistence inspection - * folded through the registry. The + * folded through the same unit. The * projection fold is the single classification authority; per-child * diagnostics relay a fold that served no identity or a failed inspection, * never a list-time descriptor parse. Absent persistence, enumeration is @@ -606,8 +606,8 @@ async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): P * @param signal - caller-owned cancellation forwarded to persistence reads * and observed around every read await. * @returns children and per-child diagnostics ordered by `createdAt`, then id. - * @throws {@link SubagentError} when the projection registry or the session - * store is not mounted, or the caller cancels the listing. + * @throws {@link SubagentError} when the session store is not mounted, or + * the caller cancels the listing. */ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 38136f2717..0eaf927552 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -286,7 +286,7 @@ interface ContinuableCreateSpec { ## 持久化枚举:`listChildren()`、`listDescendants()` 与其条目 -`SubagentRuntime.listChildren(parentSessionId)` 从 `ctx.sessions.list()` 与可选 `ctx.sessionPersistence.list()` 的实时优先合并中枚举 parent 直接且由会话支撑的 subagent——不经查询服务,也不会加载或恢复任何 Agent。候选是持久 header 携带 `origin: 'subagent'` 的直接 child;该标记只负责枚举分类与粗粒度的通用路由拒绝,不能证明描述符有效、child 可恢复或操作已获授权——身份由投影折叠负责,恢复由 Activation 约定负责。每行的 `mode`/`label` 是已注册 `subagent` projection unit 的值,经三级阶梯供值:存活 child 由注册表水位缓存供值(零日志读取);冷 child 先读可选的投影 checkpoint 缓存(`cachedSnapshot`——过 own-suffix seq 门的身份即定值,own descriptor 一经追加不可变);否则在一次 `persistence.inspect()` 读取上经注册表折叠(有界并发,每次列表重新计算)。该缓存是纯可选加速层:服务缺席、行里是 `null` 哨兵或 key 缺席、seq 门不过、读取出错,都静默落到权威重折。折叠规则是 `subagent/descriptor` last-wins 且没有失败通道:子 agent 自己的描述符覆盖 fork seed 中祖先的描述符,格式错误或版本不认识的载荷折叠为可序列化的 `null` 哨兵,视同无值。结果是按 `createdAt`、再按 id 排序的 `SubagentListEntry[]`:取到身份即生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。已定局而折叠无身份的候选生成 `corrupt` diagnostic——缺失、格式错误与版本不认识的描述符有意不再细分(`unsupported` 仍保留在类型中但从不产出);运行中而无身份的候选被省略(描述符落盘前的创建窗口);冷检查失败生成一条 `unavailable` diagnostic 并在下次列表自然重试,因此一个损坏的 sibling 不会隐藏健康 child。`hasChildren` 标记存在持久 subagent origin 的直接后代,读取自同一份合并材料。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。缺少持久化时,枚举退化为仅存活枚举而不是报错——此时冷 child 本就无法恢复。缺少 `ctx.sessionProjections` 注册表时,`listChildren()` 抛出携带错误码 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 的 `SubagentError`,缺少会话存储时则抛出 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`,两者都在任何读取之前检查,因此零 child 的部署同样确定失败;列表工具在插件加载时要求 `ctx.subagents` 与 `ctx.agents`。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并通过在线 Agent 注册表将状态细化为自己的 `running`/`idle`/`ready` 词汇,其中 `ready` 把仅存于存储的 child 命名为可恢复而非终态。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。读路径的设计理由见[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)。 +`SubagentRuntime.listChildren(parentSessionId)` 从 `ctx.sessions.list()` 与可选 `ctx.sessionPersistence.list()` 的实时优先合并中枚举 parent 直接且由会话支撑的 subagent——不经查询服务,也不会加载或恢复任何 Agent。候选是持久 header 携带 `origin: 'subagent'` 的直接 child;该标记只负责枚举分类与粗粒度的通用路由拒绝,不能证明描述符有效、child 可恢复或操作已获授权——身份由投影折叠负责,恢复由 Activation 约定负责。每行的 `mode`/`label` 是已注册 `subagent` projection unit 的值,经三级阶梯供值:存活 child 通过 `stateOf()` 供值(零日志读取);冷 child 先读可选的投影 checkpoint 缓存(`cachedSnapshot`——过 own-suffix seq 门的身份即定值,own descriptor 一经追加不可变);否则在一次 `persistence.inspect()` 读取上经注册表折叠(有界并发,每次列表重新计算)。该缓存是纯可选加速层:服务缺席、行里是 `null` 哨兵或 key 缺席、seq 门不过、读取出错,都静默落到权威重折。折叠规则是 `subagent/descriptor` last-wins 且没有失败通道:子 agent 自己的描述符覆盖 fork seed 中祖先的描述符,格式错误或版本不认识的载荷折叠为可序列化的 `null` 哨兵,视同无值。结果是按 `createdAt`、再按 id 排序的 `SubagentListEntry[]`:取到身份即生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。已定局而折叠无身份的候选生成 `corrupt` diagnostic——缺失、格式错误与版本不认识的描述符有意不再细分(`unsupported` 仍保留在类型中但从不产出);运行中而无身份的候选被省略(描述符落盘前的创建窗口);冷检查失败生成一条 `unavailable` diagnostic 并在下次列表自然重试,因此一个损坏的 sibling 不会隐藏健康 child。`hasChildren` 标记存在持久 subagent origin 的直接后代,读取自同一份合并材料。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。缺少持久化时,枚举退化为仅存活枚举而不是报错——此时冷 child 本就无法恢复。`listChildren()` 要求 runtime 的投影注册表;缺少会话存储时抛出携带错误码 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 的 `SubagentError`,并在任何读取之前检查,因此零 child 的部署同样确定失败;列表工具在插件加载时要求 `ctx.subagents` 与 `ctx.agents`。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并通过在线 Agent 注册表将状态细化为自己的 `running`/`idle`/`ready` 词汇,其中 `ready` 把仅存于存储的 child 命名为可恢复而非终态。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。读路径的设计理由见[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)。 `SubagentRuntime.listDescendants(rootSessionId)` 将同一份实时优先语料与基于投影的解释应用到根的完整后代树,并按稳定 pre-order 输出。普通会话和一次性 child 仍作为遍历节点,因此其下的可继续后代仍可发现;只有 `origin: 'subagent'` 的候选会生成条目。每个返回的 child 或 diagnostic 都从枚举所得的持久 header 附加树位置;冷检查在提供身份前还会重新校验完整生命周期: @@ -593,7 +593,7 @@ async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): P * row when the optional cache serves an own-suffix identity (its `seq` * gate proves the value postdates the fork seed, where a child's own * descriptor is immutable once appended), else one persistence inspection - * folded through the registry. The + * folded through the same unit. The * projection fold is the single classification authority; per-child * diagnostics relay a fold that served no identity or a failed inspection, * never a list-time descriptor parse. Absent persistence, enumeration is @@ -608,8 +608,8 @@ async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): P * @param signal - caller-owned cancellation forwarded to persistence reads * and observed around every read await. * @returns children and per-child diagnostics ordered by `createdAt`, then id. - * @throws {@link SubagentError} when the projection registry or the session - * store is not mounted, or the caller cancels the listing. + * @throws {@link SubagentError} when the session store is not mounted, or + * the caller cancels the listing. */ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise diff --git a/docs/subsystems/token-meter.i18n.yaml b/docs/subsystems/token-meter.i18n.yaml index 57ffd70d04..e14c696d4d 100644 --- a/docs/subsystems/token-meter.i18n.yaml +++ b/docs/subsystems/token-meter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/token-meter.md -token-meter.md: a1a096b800d895cabe9e05b751e23f06cea67d67 -token-meter.zh.md: 16e13e3d168c3cd67bdf12903045dd9b4056402f +token-meter.md: 180da591e6e123e07c5249cd88d9e59fd3e448b3 +token-meter.zh.md: b8b82fcefca836796cd3d987af863afe80b0700b diff --git a/docs/subsystems/token-meter.md b/docs/subsystems/token-meter.md index a1a096b800..180da591e6 100644 --- a/docs/subsystems/token-meter.md +++ b/docs/subsystems/token-meter.md @@ -86,5 +86,5 @@ estimateMessage(message: Message): number Types: [EpochHeader](session.md) · [Message](llm-streaming.md) · [Session](session.md) -Source: [`packages/llm/token-meter/src/index.ts:74`](../../packages/llm/token-meter/src/index.ts) +Source: [`packages/llm/token-meter/src/index.ts:78`](../../packages/llm/token-meter/src/index.ts) diff --git a/docs/subsystems/token-meter.zh.md b/docs/subsystems/token-meter.zh.md index 16e13e3d16..b8b82fcefc 100644 --- a/docs/subsystems/token-meter.zh.md +++ b/docs/subsystems/token-meter.zh.md @@ -86,5 +86,5 @@ estimateMessage(message: Message): number Types: [EpochHeader](session.md) · [Message](llm-streaming.md) · [Session](session.md) -Source: [`packages/llm/token-meter/src/index.ts:74`](../../packages/llm/token-meter/src/index.ts) +Source: [`packages/llm/token-meter/src/index.ts:78`](../../packages/llm/token-meter/src/index.ts) diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index cca7641575..a86d2cf2c0 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -12,6 +12,7 @@ import type { ToolExecutionResult } 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 { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' @@ -53,6 +54,7 @@ async function codeModeHarness(cwd: string): Promise { const harness = new Context() await harness.plugin(LlmRuntime) await harness.plugin(SessionStore) + await harness.plugin(SessionProjectionRegistry) await harness.plugin(SystemPrompt, { persona: PERSONA }) await harness.plugin(ToolRuntime, { mode: 'code' }) await harness.plugin(AgentRegistry) @@ -70,6 +72,7 @@ async function workspaceCodeModeHarness(): Promise { const harness = new Context() await harness.plugin(LlmRuntime) await harness.plugin(SessionStore) + await harness.plugin(SessionProjectionRegistry) await harness.plugin(SystemPrompt, { persona: PERSONA }) await harness.plugin(ToolRuntime, { mode: 'code' }) await harness.plugin(AgentRegistry) diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index d20637eede..495b7ebbd2 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -2,6 +2,7 @@ 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' @@ -55,6 +56,7 @@ export interface CodingHarnessOptions { export async function codingHarness(workdir: string, options: CodingHarnessOptions = {}): Promise { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona: options.persona ?? '' }, }) diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index 2f7ea46ddf..09b8987503 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -46,6 +46,10 @@ - id: session-checkpoints name: '@deepseek-ai/dsh-session-checkpoint-policy' +# Required by plugins that register or read session projection state below. +- id: session-projection + name: '@deepseek-ai/dsh-session-projection' + - id: subagent name: '@deepseek-ai/dsh-subagent' diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index b099fd90f3..8df0aa7896 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/acp/acp/tests/approval.spec.ts b/packages/acp/acp/tests/approval.spec.ts index ea1ec994a4..f4a0c9b640 100644 --- a/packages/acp/acp/tests/approval.spec.ts +++ b/packages/acp/acp/tests/approval.spec.ts @@ -61,7 +61,11 @@ describe('ACP machine permission policy', () => { harness = await makeBridgeHarness() const request = await ownedRequest() const foreign = { - session: { id: request.agent.session.id, events: [{ type: 'turn/start' }], append: () => ({}) }, + session: { + id: request.agent.session.id, + events: [{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }], + append: () => ({}), + }, } as unknown as Agent await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'bash', callId: CallId('call') })) .resolves.toBe('unavailable') diff --git a/packages/acp/acp/tests/harness.ts b/packages/acp/acp/tests/harness.ts index ce6e93794f..9f579e8b64 100644 --- a/packages/acp/acp/tests/harness.ts +++ b/packages/acp/acp/tests/harness.ts @@ -17,6 +17,7 @@ import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, St import { type GenerateOptions, LlmAdapter, 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 * as AcpPlugin from '../src/index.ts' import type { AcpConfig } from '../src/index.ts' @@ -184,6 +185,10 @@ export async function makeBridgeHarness(options: { const adapter = new MockAdapter(options.script ?? [], options.imageCapable === true) const ctx = new Context() await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona: 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) if (options.attachments !== false) await ctx.plugin(MemoryAttachmentStore) const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index 232cf9b2f6..018e11211c 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -26,6 +26,7 @@ const requiredArtifacts = [ 'packages/api/gateway/lib/index.js', 'packages/typert/registry/lib/client.js', 'packages/typert/registry/lib/index.js', + 'packages/session/session-projection/lib/index.js', ].every(path => existsSync(artifact(path))) describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { @@ -42,6 +43,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { registryHost: 'packages/typert/registry/lib/index.js', remotesClient: 'packages/api/remotes/lib/client.js', session: 'packages/core/session/lib/index.js', + sessionProjections: 'packages/session/session-projection/lib/index.js', }).map(([key, path]) => [key, artifactUrl(path)])) const script = ` import { createServer } from 'node:http' @@ -53,6 +55,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { const connectionHost = await import(urls.connectionHost) const { default: TypertRemoteService } = await import(urls.apiGatewayHost) const { default: GoalService } = await import(urls.goal) + const { default: SessionProjectionRegistry } = await import(urls.sessionProjections) const { TYPERT } = await import(urls.goalTypert) const { default: TypertRegistry } = await import(urls.registryHost) const { Session, SessionId } = await import(urls.session) @@ -71,6 +74,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { await host.plugin(TypertRegistry) await host.plugin(AgentRegistry) await host.plugin(TypertRemoteService) + await host.plugin(SessionProjectionRegistry) await host.plugin(GoalService) host.typert.register(TYPERT) diff --git a/packages/compaction/compaction-basic/package.json b/packages/compaction/compaction-basic/package.json index 9c9416905f..0bc28652bc 100644 --- a/packages/compaction/compaction-basic/package.json +++ b/packages/compaction/compaction-basic/package.json @@ -65,6 +65,7 @@ "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts index 2cf07d3f70..80983c0ec7 100644 --- a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts @@ -23,6 +23,7 @@ import type { TokenUsage, } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeter from '@deepseek-ai/dsh-token-meter' import { agentEvents, type Agent, type RequestErrorAction } from '@deepseek-ai/dsh-agent' import ToolResultPruner from '@deepseek-ai/dsh-compaction-tool-result-pruner' @@ -72,6 +73,9 @@ class RoutedContextAdapter extends LlmAdapter { function createContext(contextWindow = 1_000): Context { const ctx = new Context() void new LlmRuntime(ctx) + // The registry is a required injection of TokenMeter (its three + // projection units register in the constructor); mount it synchronously. + new SessionProjectionRegistry(ctx) void new TokenMeter(ctx) ctx.llm.registerAdapter([MODEL, 'actual', 'unlisted-provider'], new ContextAdapter(contextWindow)) return ctx @@ -518,6 +522,7 @@ describe('pressure measurement and retention', () => { it('re-resolves capacity after a same-model-id provider switch in one session', async () => { const ctx = new Context() void new LlmRuntime(ctx) + new SessionProjectionRegistry(ctx) void new TokenMeter(ctx) ctx.llm.registerAdapter(['large', 'small'], new RoutedContextAdapter({ large: 10_000, @@ -545,6 +550,7 @@ describe('pressure measurement and retention', () => { it('requires capacity only for proactive pressure, not provider-confirmed overflow', async () => { const ctx = new Context() void new LlmRuntime(ctx) + new SessionProjectionRegistry(ctx) void new TokenMeter(ctx) ctx.llm.registerAdapter(['unknown-context'], new ContextAdapter(1_000)) vi.spyOn(ctx.llm, 'resolveModelInfo').mockImplementation((provider, model) => Promise.resolve({ @@ -1162,6 +1168,7 @@ async function summarizerHarness( ): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: ExposedCompactionEngine }> { const ctx = new Context() await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionProjectionRegistry) void new TokenMeter(ctx) const adapter = new ScriptedAdapter(blocks, finish) ctx.llm.registerAdapter([model], adapter) @@ -1338,6 +1345,7 @@ describe('default one-shot summarizer', () => { it('fails clearly when no complete summarization target can be resolved', async () => { const ctx = new Context() await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionProjectionRegistry) void new TokenMeter(ctx) const compact = new ExposedCompactionEngine(ctx, { auto: false }) await expect(compact.runSummarize(promptInput('history'), agent(Session.create(SessionId('model-less'))))) @@ -1852,6 +1860,7 @@ describe('automatic listener and loader composition', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const meterFiber = await ctx.plugin(TokenMeter) const compactFiber = await ctx.plugin(BasicCompactionEngine, { auto: false }) @@ -1865,6 +1874,7 @@ describe('automatic listener and loader composition', () => { it('removes its automatic listener with the plugin fiber', async () => { const ctx = new Context() await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(TokenMeter) const fiber = await ctx.plugin(TestCompactionEngine, { thresholdRatio: 0.5, diff --git a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts index 867d11c2a6..846b7f8576 100644 --- a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts @@ -13,6 +13,7 @@ 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' @@ -150,6 +151,9 @@ 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)) @@ -312,6 +316,7 @@ 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) @@ -390,6 +395,7 @@ 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) diff --git a/packages/compaction/compaction-basic/tests/loader-composition.spec.ts b/packages/compaction/compaction-basic/tests/loader-composition.spec.ts index c4efe48f97..1fc9d86bf9 100644 --- a/packages/compaction/compaction-basic/tests/loader-composition.spec.ts +++ b/packages/compaction/compaction-basic/tests/loader-composition.spec.ts @@ -8,6 +8,7 @@ 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 from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeter from '@deepseek-ai/dsh-token-meter' import BasicCompactionEngine from '@deepseek-ai/dsh-compaction-basic' import ToolResultPruner from '@deepseek-ai/dsh-compaction-tool-result-pruner' @@ -34,6 +35,7 @@ async function loadYaml(lines: readonly string[]): Promise { const modules = new Map([ ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-token-meter', TokenMeter], ['@deepseek-ai/dsh-compaction-tool-result-pruner', ToolResultPruner], ['@deepseek-ai/dsh-compaction-basic', BasicCompactionEngine], @@ -58,6 +60,7 @@ describe('real Loader composition', () => { const loaded = await loadYaml([ "- name: '@deepseek-ai/dsh-llm'", "- name: '@deepseek-ai/dsh-session'", + "- name: '@deepseek-ai/dsh-session-projection'", "- name: '@deepseek-ai/dsh-token-meter'", "- name: '@deepseek-ai/dsh-compaction-tool-result-pruner'", ' config:', @@ -86,6 +89,7 @@ describe('real Loader composition', () => { it('rejects stale token-meter config after Schemastery normalization', async () => { context = new Context() + await context.plugin(SessionProjectionRegistry) await expect(context.plugin(TokenMeter, { contextWindow: 4096, } as never)).rejects.toThrow(/TokenMeterConfig: unknown key "contextWindow"/) @@ -95,6 +99,7 @@ describe('real Loader composition', () => { context = new Context() await context.plugin(LlmRuntime) await context.plugin(SessionStore) + await context.plugin(SessionProjectionRegistry) await context.plugin(TokenMeter) await expect(context.plugin(BasicCompactionEngine, { models: { legacy: { thresholdRatio: 0.5 } }, @@ -105,6 +110,7 @@ describe('real Loader composition', () => { context = new Context() await context.plugin(LlmRuntime) await context.plugin(SessionStore) + await context.plugin(SessionProjectionRegistry) await context.plugin(TokenMeter) await expect(context.plugin(BasicCompactionEngine, { retainRatio: 0.2, @@ -120,6 +126,7 @@ describe('real Loader composition', () => { context = new Context() await context.plugin(LlmRuntime) await context.plugin(SessionStore) + await context.plugin(SessionProjectionRegistry) await context.plugin(TokenMeter) await expect(context.plugin(BasicCompactionEngine, { summarizationProvider: 'default-provider', diff --git a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts index 9e400f7741..a0497f8041 100644 --- a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts +++ b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts @@ -25,6 +25,7 @@ import type { TokenUsage, } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LlmRuntime from '@deepseek-ai/dsh-llm' import TokenMeter from '@deepseek-ai/dsh-token-meter' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -105,6 +106,7 @@ async function loopHarness(): Promise { await ctx.plugin(AgentLoopInvariant) await ctx.plugin(CompactionInvariant) await ctx.plugin(CompactionBasicInvariant) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeter) const adapter = new TextAdapter() @@ -221,6 +223,7 @@ function detachedService(): { ctx: Context; compact: GatedCompactionEngine; flus const ctx = new Context() void new LlmRuntime(ctx) void new SessionStore(ctx) + new SessionProjectionRegistry(ctx) void new TokenMeter(ctx) ctx.llm.registerAdapter([MODEL], new TextAdapter()) let flushes = 0 diff --git a/packages/compaction/compaction-tool-result-pruner/package.json b/packages/compaction/compaction-tool-result-pruner/package.json index 8a3d5bea87..1b2621a39d 100644 --- a/packages/compaction/compaction-tool-result-pruner/package.json +++ b/packages/compaction/compaction-tool-result-pruner/package.json @@ -50,6 +50,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/compaction/compaction-tool-result-pruner/tests/loader-composition.spec.ts b/packages/compaction/compaction-tool-result-pruner/tests/loader-composition.spec.ts index 7162524078..d70283b98a 100644 --- a/packages/compaction/compaction-tool-result-pruner/tests/loader-composition.spec.ts +++ b/packages/compaction/compaction-tool-result-pruner/tests/loader-composition.spec.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } 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 SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeter from '@deepseek-ai/dsh-token-meter' import ToolResultPruner from '@deepseek-ai/dsh-compaction-tool-result-pruner' @@ -24,6 +25,7 @@ describe('compaction-tool-result-pruner real Loader composition', () => { root = await mkdtemp(join(tmpdir(), 'dsh-compact-tool-result-prune-loader-')) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-session-projection'", "- name: '@deepseek-ai/dsh-token-meter'", "- name: '@deepseek-ai/dsh-compaction-tool-result-pruner'", ' config:', @@ -40,6 +42,7 @@ describe('compaction-tool-result-pruner real Loader composition', () => { context.loader.internal = { version: 'v2', async import(specifier: string) { + if (specifier === '@deepseek-ai/dsh-session-projection') return SessionProjectionRegistry if (specifier === '@deepseek-ai/dsh-token-meter') return TokenMeter if (specifier === '@deepseek-ai/dsh-compaction-tool-result-pruner') return ToolResultPruner throw new Error(`unexpected Loader import: ${specifier}`) @@ -61,8 +64,9 @@ describe('compaction-tool-result-pruner real Loader composition', () => { it('rejects stale config after plugin schema normalization', async () => { context = new Context() - // Satisfy the declared injection first: config normalization runs in the + // Satisfy the declared injections first: config normalization runs in the // service constructor, which a pending fiber never reaches. + await context.plugin(SessionProjectionRegistry) await context.plugin(TokenMeter) await expect(context.plugin(ToolResultPruner, { maxChars: 100, diff --git a/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts b/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts index 3216a6e774..730b36a7fb 100644 --- a/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts +++ b/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts @@ -9,6 +9,7 @@ import SessionStore, { import type { SurfaceEvent } from '@deepseek-ai/dsh-session' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeter from '@deepseek-ai/dsh-token-meter' import ToolResultPruner, { codePointLength, @@ -29,12 +30,15 @@ function service(config: ToolResultPruneConfig = SMALL): ToolResultPruner { const ctx = new Context() // Service constructors self-register, so `ctx.tokenMeter` resolves for the // shadow-price pricing without a full plugin boot. + new SessionProjectionRegistry(ctx) void new TokenMeter(ctx) return new ToolResultPruner(ctx, config) } /** Pricing oracle mirroring the service's estimator for expectations. */ -const METER = new TokenMeter(new Context()) +const METER_CTX = new Context() +new SessionProjectionRegistry(METER_CTX) +const METER = new TokenMeter(METER_CTX) function appendToolStep( session: Session, @@ -258,6 +262,7 @@ describe('ToolResultPruner session transaction', () => { it('runs under real invariants between closed steps but not outside a turn', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(InvariantRegistry) await ctx.plugin(SessionInvariant) await ctx.plugin(TokenMeter) diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index d7b3cdf81d..bf35d69e10 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -39,10 +39,12 @@ "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", @@ -58,6 +60,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/context/agent-instructions/src/index.ts b/packages/context/agent-instructions/src/index.ts index 1b00960adb..f4bd2bed31 100644 --- a/packages/context/agent-instructions/src/index.ts +++ b/packages/context/agent-instructions/src/index.ts @@ -15,11 +15,13 @@ import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { Session, UserMessage } from '@deepseek-ai/dsh-session' import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-session-projection' import { Config, resolveConfig, workspaceBaselineIdentity, type ResolvedConfig } from './config.ts' import { findProjectRoot, loadBaselineInstructionSet } from './files.ts' import { applyInstructionVersionUpdates, baselineInstructionState, + createWorkspaceInstructionsProjection, name, reconcileInstructionContext, workspaceContextMessage, @@ -77,7 +79,12 @@ function filePathFromExecution(exec: ToolExecution): string | undefined { return filePath.length > 0 ? filePath : undefined } +/** Required services (the projection registry drives the instruction fold). */ +export const inject = ['sessionProjections'] + export function apply(ctx: Context, config: Config): void { + ctx.sessionProjections.register(createWorkspaceInstructionsProjection()) + const resolved: ResolvedConfig = resolveConfig(config) const instructionVersions: InstructionVersionCache = new WeakMap() const baselinePreparations = new WeakMap + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** Latest workspace-instruction change by scope. */ + workspaceInstructions: WorkspaceInstructionsState + } +} + +/** + * Create the workspace-instruction projection. + * @returns Workspace-instruction projection definition. + */ +export function createWorkspaceInstructionsProjection(): ProjectionDefinition<'workspaceInstructions', WorkspaceInstructionsState> { + return { + key: 'workspaceInstructions', + stateSchema: workspaceInstructionsStateSchema, + init: () => ({}), + apply: (state, event) => { + if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) return state + const changes = workspaceInstructionChanges(event.data.source) + if (changes.length === 0) return state + let next = state + for (const change of changes) { + next = { ...next, [change.scope]: { change, seq: event.seq } } + } + return next + }, + stateVersion: 1, + } +} + function visibleInstructionChanges( agent: Agent, authorityMessages: readonly UserMessage[], ): Map { const visibleSeqs = new Set(agent.session.surface.nodes) const visible = new Map() - for (const [seq, event] of agent.session.events.entries()) { - if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue - const changes = workspaceInstructionChanges(event.data.source) - for (const change of changes) { - if (visibleSeqs.has(seq)) visible.set(change.scope, change) - } + const folded = agent.ctx.get('sessionProjections')?.stateOf(agent.session, 'workspaceInstructions') + if (folded === undefined) throw new Error('workspaceInstructions projection is not registered') + for (const [scope, record] of Object.entries(folded)) { + if (visibleSeqs.has(record.seq)) visible.set(scope, record.change) } for (const message of authorityMessages) { if (!isWorkspaceContextSource(message.source)) continue diff --git a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts index 392936b4c2..7f468bb6c8 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts @@ -11,6 +11,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' 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 * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import * as WorkspaceContext from '@deepseek-ai/dsh-agent-instructions' import { candidateScopeKey } from '../src/render.ts' @@ -39,6 +40,7 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> { ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona: 'Answer the user exactly and concisely.' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index fe37fef48a..d9caebea84 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -7,6 +7,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import * as workspaceContext from '@deepseek-ai/dsh-agent-instructions' import LlmRuntime, { createUserMessage, CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' @@ -36,6 +37,7 @@ import { import { applyInstructionVersionUpdates, baselineInstructionState, + createWorkspaceInstructionsProjection, reconcileInstructionContext, type InstructionVersionCache, } from '../src/state.ts' @@ -167,9 +169,16 @@ class BlockingReadFileSystem extends RecordingFileSystem { } } +async function pluginWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise>> { + if (ctx.get('sessionProjections') === undefined) { + await ctx.plugin(SessionProjectionRegistry) + } + return ctx.plugin(workspaceContext, config) +} + async function mountWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise>> { await ctx.plugin(LocalFileSystem, { cwd: '/' }) - return ctx.plugin(workspaceContext, config) + return pluginWorkspaceContext(ctx, config) } async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise>> { @@ -177,14 +186,17 @@ async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspace await ctx.plugin(ToolRuntime) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) - return ctx.plugin(workspaceContext, config) + return pluginWorkspaceContext(ctx, config) } function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { const id = SessionId('s1') const session = Session.create(id, seed, cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd }) + const ctx = new Context() + new SessionProjectionRegistry(ctx) + ctx.sessionProjections.register(createWorkspaceInstructionsProjection()) return { - ctx: new Context(), + ctx, id: SessionId('a1'), options: {}, session, @@ -193,7 +205,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { send: () => {}, followup: () => {}, steer: () => {}, - inject: () => { throw new Error('agent-instructions must append directly to the open step') }, + inject: () => { throw new Error('workspace-context must append directly to the open step') }, cancel() {}, runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), @@ -223,7 +235,7 @@ async function workspaceContextOf(agent: Agent): Promise { message.source.kind === 'agent-instructions') expect(context).toBeDefined() return context! - }) + }, { timeout: 10_000 }) } async function syncWorkspaceContext(ctx: Context, agent: Agent): Promise { @@ -622,10 +634,10 @@ describe('workspace context instruction discovery', () => { it('labels the default DSH home as ~/.dsh when HOME points at the configured default', async () => { const root = await tempRepo() const home = await tempRepo() + vi.stubEnv('DSH_HOME', '') try { await write(join(home, '.dsh/AGENTS.md'), 'global default rule') - // A set DSH_HOME would override the homedir default and relabel the home. vi.stubEnv('DSH_HOME', '') vi.resetModules() vi.doMock('node:os', () => ({ homedir: () => home })) @@ -985,26 +997,27 @@ describe('workspace context request injection', () => { it('requires an explicit maxBytes configuration', async () => { const ctx = new Context() - await expect(ctx.plugin(workspaceContext, {} as workspaceContext.Config)).rejects.toThrow(/maxBytes/) + await expect(pluginWorkspaceContext(ctx, {} as workspaceContext.Config)).rejects.toThrow(/maxBytes/) }) it('mounts without requiring a filesystem provider', async () => { const ctx = new Context() try { - await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) } finally { await ctx.fiber.dispose() } }) it('does not declare fs as a static inject dependency', () => { - expect('inject' in workspaceContext).toBe(false) + expect(workspaceContext.inject).toEqual(['sessionProjections']) + expect(workspaceContext.inject).not.toContain('fs') }) it('does not inject baseline context when no filesystem provider is present', async () => { const ctx = new Context() try { - await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) const agent = stubAgent('/virtual/repo') await composeBaselinePrefix(ctx, agent) @@ -1111,7 +1124,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const original = stubAgent(root) await composeBaselinePrefix(ctx, original) @@ -1355,7 +1368,7 @@ describe('workspace context request injection', () => { expect(inserted?.source).toMatchObject({ kind: 'agent-instructions', baseline: true }) await fiber.dispose() - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const resumed = stubAgent(root, [...original.session.events]) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) @@ -1401,7 +1414,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'new repo rule') await fiber.dispose() - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const resumed = stubAgent(root, [...original.session.events]) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const staleClaim = resumed.inbox.claim('next-step', 1) @@ -1454,7 +1467,7 @@ describe('workspace context request injection', () => { await originalCtx.fiber.dispose() if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' }) - await resumedCtx.plugin(workspaceContext, { dshHome: home, maxBytes }) + await pluginWorkspaceContext(resumedCtx, { dshHome: home, maxBytes }) const resumed = stubAgent(root, [...original.session.events]) agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) @@ -1564,7 +1577,7 @@ describe('workspace context request injection', () => { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: [prompt], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [prompt], turn: 1, step: 1, signal: AbortSignal.timeout(10_000) }, () => Promise.resolve(downstream), ) @@ -1649,7 +1662,7 @@ describe('workspace context request injection', () => { // Hot remount over the live session: the durable baseline remains // visible, so the fresh mount does not append a duplicate. await fiber.dispose() - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) await composeBaselinePrefix(ctx, agent) expect(baselineEvents(agent)).toHaveLength(1) @@ -1679,7 +1692,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: '/' }) - const fiber = await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const fiber = await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) const baseline = baselineEvents(agent)[0] @@ -1694,7 +1707,7 @@ describe('workspace context request injection', () => { }) await fiber.dispose() - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) await composeBaselinePrefix(ctx, agent) expect(baselineEvents(agent)).toHaveLength(2) @@ -1981,7 +1994,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'ctx.fs rule' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2004,7 +2017,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'provider-only rule' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2046,7 +2059,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'far too large' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) @@ -2071,7 +2084,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(instructionPath, { type: 'file', content: 'far too large' }) fs.omitSizes.add(instructionPath) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) @@ -2094,7 +2107,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as BlockingReadFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'blocked' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const controller = new AbortController() const reason = new Error('cancel prefix') const pending = agentEvents(ctx, stubAgent(root)).waterfall( @@ -2128,7 +2141,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(home, 'AGENTS.md'), { type: 'file', content: 'ctx global rule' }) fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'ctx claude rule' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2154,7 +2167,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'directory' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2177,7 +2190,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2200,7 +2213,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.throwOnStat.add(join(root, 'AGENTS.md')) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2222,7 +2235,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.throwOnStat.add(join(root, 'AGENTS.md')) fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'claude sibling rule' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2247,7 +2260,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.throwOnStat.add(join(root, '.git')) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2297,7 +2310,7 @@ describe('workspace context request injection', () => { await write(join(cwd, 'AGENTS.md'), 'child schema default rule') const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: '/' }) - await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) const agent = stubAgent(cwd) await composeBaselinePrefix(ctx, agent) @@ -2318,7 +2331,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.local.md'), 'local rule') const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: '/' }) - await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2514,7 +2527,7 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('workspace-context-abort'), { provider: 'mock', model: 'mock' }, { cwd: root }) @@ -2590,7 +2603,7 @@ describe('dynamic nested workspace context injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const controller = new AbortController() const reason = new Error('cancel dynamic reconciliation') controller.abort(reason) @@ -2853,7 +2866,7 @@ describe('dynamic nested workspace context injection', () => { fs.omitSizes.add(instructionPath) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -2890,7 +2903,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(instructionPath, { type: 'file', content: 'same package rule', version: FsVersion('revision-1') }) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await ctx.tools.execute({ @@ -2935,7 +2948,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(instructionPath, { type: 'file', content: 'shared path, separate sessions' }) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const firstAgent = stubAgent(root) const secondAgent = stubAgent(root) @@ -3471,7 +3484,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'provider package rule' }) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -3850,7 +3863,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(join(root, 'pkg/deep/file.txt'), { type: 'file', content: 'hello' }) fs.throwOnRead.add(nested) await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const result = await ctx.tools.execute({ @@ -3991,7 +4004,7 @@ describe('dynamic nested workspace context injection', () => { ? { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer policy block' }] } : downstream }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const blocked = await ctx.tools.execute({ @@ -4060,7 +4073,7 @@ describe('dynamic nested workspace context injection', () => { ? { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer composite block' }] } : downstream }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const blocked = await ctx.tools.execute({ @@ -4084,7 +4097,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested package rule' }) @@ -4159,7 +4172,7 @@ describe('dynamic nested workspace context injection', () => { agent.session.append('step/start', { turn: 1, step: 1 }) agent.session.append('step/end', { turn: 1, step: 1 }) agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, @@ -4182,7 +4195,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) - await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem const agent = stubAgent('/') const plainResult = { callId: CallId('plain'), content: [], isError: false as const, value: null } @@ -4231,7 +4244,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) - await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem const root = resolve('/') const agent = stubAgent(root) @@ -4297,7 +4310,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(instructionPath, { type: 'file', content: 'x'.repeat(1000) }) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 20 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 20 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -4419,7 +4432,7 @@ describe('workspace context inbox synchronization', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'tiny-budget rule' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 1 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 1 }) const agent = stubAgent(root) ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, @@ -4495,7 +4508,7 @@ describe('workspace context inbox synchronization', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'a/AGENTS.md'), { type: 'file', content: 'restored A' }) fs.entries.set(join(root, 'b/AGENTS.md'), { type: 'file', content: 'restored B' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = stubToolExecution({ signal: testToolSignal, @@ -4535,7 +4548,7 @@ describe('workspace context inbox synchronization', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'a/AGENTS.md'), { type: 'file', content: 'scope A' }) fs.entries.set(join(root, 'b/AGENTS.md'), { type: 'file', content: 'scope B' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = stubToolExecution({ signal: testToolSignal, diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 5fc478314f..7ac1a759a1 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -52,6 +52,8 @@ "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index 75619868ad..e33c8398f4 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -4,7 +4,9 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compaction' import { createUserMessage, CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionQueryEngine from '@deepseek-ai/dsh-session-query' +import { titleProjectionDefinition } from '@deepseek-ai/dsh-session-title' import SessionReferenceResolver, { decodeSessionReferenceUri, encodeSessionReferenceUri, @@ -35,6 +37,8 @@ class TestSessionQueryEngine extends SessionQueryEngine { async function harness(config: Config = {}): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(titleProjectionDefinition) await ctx.plugin(TestSessionQueryEngine) await ctx.plugin(SessionReferenceResolver, config) return ctx diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index bc944e6cfb..c9631ee73a 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -32,13 +32,15 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -50,6 +52,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index a317544a3c..d41f9c27bb 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -7,9 +7,11 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { z as zod } from 'zod' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-session-projection' import { deriveBrowserTimeZoneContext, renderBrowserTimeZoneContext, @@ -20,8 +22,29 @@ import { createTimestampFormatter, formatTimestamp } from './timestamp.ts' /** Cordis plugin name used by loader diagnostics. */ export const name = 'time-context' +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** Latest time-context readings. */ + timeContext: TimeContextProjection + } +} + +const timeContextStateSchema = zod.object({ + /** Time of the latest model-visible event (user/assistant message, tool result), or null. */ + lastMessageTime: zod.number().nullable(), + /** Time of this plugin's latest durable injection, or null. */ + lastInjectionTime: zod.number().nullable(), + /** Turn of the latest injection, or null (a same-turn injection answers precedingStepContextTime). */ + lastInjectionTurn: zod.number().nullable(), + /** The open turn at the latest fold (null between turns); the injection's turn is captured at append. */ + currentTurn: zod.number().nullable(), +}) + +/** Folded time-context readings. */ +export type TimeContextProjection = zod.infer + /** The agent registry that owns pre-step processing. */ -export const inject = ['agents'] +export const inject = ['agents', 'sessionProjections'] /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ export interface Config { @@ -54,47 +77,6 @@ function formatDuration(elapsedMs: number): string { return parts.join(' ') } -/** Find the latest model-visible event, excluding this plugin's pending append. */ -function precedingMessageTime(agent: Agent): number | undefined { - for (const event of [...agent.session.events].reverse()) { - switch (event.type) { - case 'user/message': - case 'assistant/message': - case 'tool/result': - return event.time - default: - // Merge-extensible session events: non-surface records are not messages. - break - } - } - return undefined -} - -/** Find the preceding time-context event within the open turn. */ -function precedingStepContextTime(agent: Agent, turn: number): number | undefined { - for (const event of [...agent.session.events].reverse()) { - if (event.type === 'turn/start' && event.data.turn === turn) return undefined - if (event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === name) { - return event.time - } - } - return undefined -} - -/** Find this plugin's latest durable injection, including a shadowed surface event. */ -function latestInjectionTime(agent: Agent): number | undefined { - for (const event of [...agent.session.events].reverse()) { - if (event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === name) { - return event.time - } - } - return undefined -} - /** Collect already-entered and proposed user messages belonging to one open turn. */ function requestMessages(agent: Agent, turn: number, proposed: readonly UserMessage[]): UserMessage[] { const start = agent.session.events.findLastIndex( @@ -167,6 +149,37 @@ export function apply(ctx: Context, config: Config): void { return created } + ctx.sessionProjections.register({ + key: 'timeContext', + stateVersion: 1, + stateSchema: timeContextStateSchema, + init: () => ({ lastMessageTime: null, lastInjectionTime: null, lastInjectionTurn: null, currentTurn: null }), + apply: (state, event) => { + if (event.type === 'turn/start') { + return state.currentTurn === event.data.turn ? state : { ...state, currentTurn: event.data.turn } + } + if (event.type === 'turn/end') { + return state.currentTurn === null ? state : { ...state, currentTurn: null } + } + if (event.type === 'user/message') { + const injected = event.data.source.kind === 'plugin' && event.data.source.plugin === name + const withMessage = state.lastMessageTime === event.time + ? state + : { ...state, lastMessageTime: event.time } + if (!injected) return withMessage + return { + ...withMessage, + lastInjectionTime: event.time, + lastInjectionTurn: state.currentTurn, + } + } + if (event.type === 'assistant/message' || event.type === 'tool/result') { + return state.lastMessageTime === event.time ? state : { ...state, lastMessageTime: event.time } + } + return state + }, + }) + ctx.on('agent/pre-step', async ( { agent, turn, step, signal }, next, @@ -174,15 +187,21 @@ export function apply(ctx: Context, config: Config): void { const decision = await next() if (decision.kind === 'reject' || signal.aborted) return decision const now = Date.now() - if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) { - const lastInjection = latestInjectionTime(agent) - if (lastInjection !== undefined + const state = ctx.sessionProjections.stateOf(agent.session, 'timeContext') + if (state !== undefined && refreshIntervalMs !== undefined && refreshIntervalMs > 0) { + const lastInjection = state.lastInjectionTime + if (lastInjection != null && now >= lastInjection && now - lastInjection < refreshIntervalMs) return decision } + /* v8 ignore next 2 -- time-context registers its own unit in apply, so the key is always present */ + if (state === undefined) return decision + /* v8 ignore next 6 -- an injection always records a time, so the lastInjectionTime nullish fallback is unreachable */ const previous = step === 1 - ? precedingMessageTime(agent) - : precedingStepContextTime(agent, turn) + ? state.lastMessageTime ?? undefined + : state.lastInjectionTurn === turn + ? state.lastInjectionTime ?? undefined + : undefined const messages = requestMessages(agent, turn, decision.messages) const browser = deriveBrowserTimeZoneContext(messages) const selectedTimeZone = browser.kind === 'resolved' ? browser.timeZone : fallbackTimeZone diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 833f7e4b9c..49d4c94335 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -7,6 +7,7 @@ import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, Inbox, 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 * as timeContext from '@deepseek-ai/dsh-time-context' import type { Config } from '@deepseek-ai/dsh-time-context' @@ -30,6 +31,7 @@ afterEach(() => { async function mount(config: Config = {}) { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(timeContext, config) return { ctx, fiber } @@ -137,6 +139,7 @@ class ScriptedAdapter extends LlmAdapter { async function loopHarness(adapter: ScriptedAdapter, config: Config = {}): Promise { 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) @@ -373,6 +376,7 @@ describe('configuration and lifecycle', () => { it('fails loud for an invalid explicit zone or an unavailable process zone', async () => { const invalid = new Context() + await invalid.plugin(SessionProjectionRegistry) await invalid.plugin(AgentRegistry) await expect(invalid.plugin(timeContext, { timeZone: 'Not/A_Real_Zone' })).rejects.toThrow( /invalid IANA timeZone/, @@ -382,6 +386,7 @@ describe('configuration and lifecycle', () => { throw new RangeError('system zone unavailable') }) const unresolved = new Context() + await unresolved.plugin(SessionProjectionRegistry) await unresolved.plugin(AgentRegistry) await expect(unresolved.plugin(timeContext, {})).rejects.toThrow(/failed to resolve the system time zone/) }) @@ -409,6 +414,27 @@ describe('configuration and lifecycle', () => { }) }) +describe('time-context projection fold edges', () => { + it('keeps the same turn when a repeated turn/start lands', async () => { + const { ctx } = await mount() + const session = Session.create(SessionId('same-turn')) + session.append('turn/start', { turn: 1 }) + session.append('turn/start', { turn: 1 }) + expect(ctx.sessionProjections.snapshot(session).values.timeContext).toMatchObject({ + currentTurn: 1, + }) + }) + + it('stays null when turn/end arrives without a turn/start', async () => { + const { ctx } = await mount() + const session = Session.create(SessionId('end-without-start')) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + expect(ctx.sessionProjections.snapshot(session).values.timeContext).toMatchObject({ + currentTurn: null, + }) + }) +}) + describe('real agent-loop request history', () => { it.each([ ['throws'], @@ -485,11 +511,12 @@ describe('real Loader export path', () => { const unwrapped = loader.unwrapExports(timeContext) as Record expect(unwrapped).toBe(timeContext) expect(unwrapped.name).toBe('time-context') - expect(unwrapped.inject).toEqual(['agents']) + expect(unwrapped.inject).toEqual(['agents', 'sessionProjections']) expect(unwrapped.Config).toBeDefined() expect(typeof unwrapped.apply).toBe('function') const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const plugin = loader.unwrapExports(timeContext) as Parameters[0] await ctx.plugin(plugin) diff --git a/packages/context/time-context/tsconfig.json b/packages/context/time-context/tsconfig.json index ad66ca2a15..bb6a7d6927 100644 --- a/packages/context/time-context/tsconfig.json +++ b/packages/context/time-context/tsconfig.json @@ -4,7 +4,9 @@ "rootDir": "src", "outDir": "lib/types" }, - "include": ["src"], + "include": [ + "src" + ], "references": [ { "path": "../../../vendor/cosmokit" @@ -35,6 +37,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../session/session-projection" } ] } diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index 2209d53122..11cd3c17b0 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -32,14 +32,16 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-shell": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -48,6 +50,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts index 10ac6a6ab6..155a55ac9f 100644 --- a/packages/context/tmux-context/src/index.ts +++ b/packages/context/tmux-context/src/index.ts @@ -20,7 +20,9 @@ import type { Context, LoggerService } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' +import { z as zod } from 'zod' +import type { PreStepDecision } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-session-projection' import type { ShellExecutor, ShellRunResult } from '@deepseek-ai/dsh-shell' import { createUserMessage } from '@deepseek-ai/dsh-llm' @@ -28,7 +30,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' export const name = 'tmux-context' /** The agent registry that owns pre-step processing. */ -export const inject = ['agents'] +export const inject = ['agents', 'sessionProjections'] /** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ export interface Config { @@ -178,21 +180,6 @@ function renderReading(location: TmuxLocation, turn: number): string { * schedule survives compaction and resumed processes without process-local * cache state. */ -function latestInjectedState(agent: Agent): { state: string; time: number } | undefined { - for (const event of [...agent.session.events].reverse()) { - if (event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === name) { - const [block] = event.data.content - if (block?.type !== 'text') return undefined - const newline = block.text.indexOf('\n') - const state = newline === -1 ? '' : block.text.slice(newline + 1) - return { state, time: event.time } - } - } - return undefined -} - /** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */ function validateRefreshInterval(refreshIntervalMs: number | undefined): void { if (refreshIntervalMs !== undefined && ( @@ -205,16 +192,47 @@ function validateRefreshInterval(refreshIntervalMs: number | undefined): void { } } +const tmuxContextStateSchema = zod.object({ + state: zod.string(), + time: zod.number(), +}).nullable() + +type TmuxContextState = zod.infer + /** * Register a prepended pre-step listener for the lifetime of `ctx`. * @param ctx - plugin context; the listener is disposed with it. * @param config - durable refresh scheduling configuration. * @throws when the refresh interval is invalid. */ +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** The stable state block of this plugin's latest durable injection, or null. */ + tmuxContext: TmuxContextState + } +} + export function apply(ctx: Context, config: Config): void { const refreshIntervalMs = config.refreshIntervalMs validateRefreshInterval(refreshIntervalMs) + ctx.sessionProjections.register({ + key: 'tmuxContext', + stateVersion: 1, + stateSchema: tmuxContextStateSchema, + init: () => null, + apply: (state, event) => { + if (event.type !== 'user/message' + || event.data.source.kind !== 'plugin' + || event.data.source.plugin !== name) return state + const [block] = event.data.content + if (block?.type !== 'text') return state + const newline = block.text.indexOf('\n') + const stableState = newline === -1 ? '' : block.text.slice(newline + 1) + return { state: stableState, time: event.time } + }, + }) + ctx.on('agent/pre-step', async ( { agent, turn, step, signal }, next, @@ -223,15 +241,15 @@ export function apply(ctx: Context, config: Config): void { if (decision.kind === 'reject' || signal.aborted || step !== 1) return decision const bash = ctx.get('shell') if (bash === undefined) return decision - const previous = latestInjectedState(agent) - if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== undefined) { + const previous = ctx.sessionProjections.stateOf(agent.session, 'tmuxContext') ?? null + if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== null) { const now = Date.now() if (now >= previous.time && now - previous.time < refreshIntervalMs) return decision } const location = await queryTmuxLocation(bash, ctx.logger, process.pid, signal) if (location === undefined) return decision const state = renderState(location) - if (previous !== undefined && previous.state === state) return decision + if (previous !== null && previous.state === state) return decision const text = renderReading(location, turn) return { kind: 'enter', diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index 94b058b2d1..af6de0f6b6 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -2,6 +2,7 @@ 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 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' @@ -82,6 +83,7 @@ async function mount( ): Promise<{ ctx: Context; bash: FakeBash | undefined }> { const ctx = new Context() await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) let bash: FakeBash | undefined if (withBash) { await ctx.plugin(FakeBash) diff --git a/packages/context/tmux-context/tsconfig.json b/packages/context/tmux-context/tsconfig.json index 2dda9e0f8a..42fa931fa2 100644 --- a/packages/context/tmux-context/tsconfig.json +++ b/packages/context/tmux-context/tsconfig.json @@ -4,7 +4,9 @@ "rootDir": "src", "outDir": "lib/types" }, - "include": ["src"], + "include": [ + "src" + ], "references": [ { "path": "../../../vendor/cosmokit" @@ -32,6 +34,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../session/session-projection" } ] } diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index b922d98f59..2978827f20 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -37,13 +37,15 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -53,9 +55,10 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 668ef65826..fb9f764183 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -31,6 +31,7 @@ import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, Us import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session' import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt' 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 { RuntimeContextProjection } from './runtime-context.ts' import { executeToolCalls } from './tool-calls.ts' @@ -89,7 +90,8 @@ export class ReactLoopAgent implements Agent { discarded: (message) => { this.dispatch.emit('agent/inbox/discarded', { message }) }, claimed: (message, turn) => { this.dispatch.emit('agent/inbox/claimed', { message, turn }) }, }) - const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0 + /* 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 }) diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 371154a7c9..d948b0cb24 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -8,6 +8,7 @@ import { Context, FiberState, Service } from '@deepseek-ai/cordis' import { randomUUID } from 'node:crypto' import z from '@deepseek-ai/schemastery' +import { z as zod } from 'zod' import { emitAgentEvent } from '@deepseek-ai/dsh-agent' import type { Agent, @@ -18,6 +19,7 @@ import type { CreateAgentOptions, ResumeAgentOptions, SessionStartSource, + TurnBoundaryProjection, } from '@deepseek-ai/dsh-agent' import { errorChain } from '@deepseek-ai/dsh-llm' import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -25,6 +27,8 @@ import { SessionId, SessionPreparation } from '@deepseek-ai/dsh-session' import type { Session, SessionHeader } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { ReactLoopAgent } from './agent.ts' import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts' @@ -36,6 +40,61 @@ const INACTIVE_STATES: ReadonlySet = new Set([ FiberState.FAILED, ]) +const turnBoundaryProjectionSchema: zod.ZodType = zod.object({ + openTurn: zod.number().int().nonnegative().nullable(), + openTurnStartSeq: zod.number().int().nonnegative().nullable(), + lastStepStartSeq: zod.number().int().nonnegative().nullable(), + lastStepBoundary: zod.object({ + kind: zod.union([zod.literal('start'), zod.literal('end')]), + seq: zod.number().int().nonnegative(), + }).nullable(), + lastTurn: zod.number().int().nonnegative(), +}) + +/** Host projection of agent turn and step boundaries. */ +export const turnBoundaryProjectionDefinition = { + key: 'turnBoundary', + stateVersion: 1, + stateSchema: turnBoundaryProjectionSchema, + init: () => ({ + openTurn: null, + openTurnStartSeq: null, + lastStepStartSeq: null, + lastStepBoundary: null, + lastTurn: 0, + }), + apply: (state, event) => { + switch (event.type) { + case 'turn/start': + return { + ...state, + openTurn: event.data.turn, + openTurnStartSeq: event.seq, + lastTurn: event.data.turn, + } + case 'turn/end': + return { + ...state, + openTurn: null, + openTurnStartSeq: null, + } + case 'step/start': + return { + ...state, + lastStepStartSeq: event.seq, + lastStepBoundary: { kind: 'start', seq: event.seq }, + } + case 'step/end': + return { + ...state, + lastStepBoundary: { kind: 'end', seq: event.seq }, + } + default: + return state + } + }, +} satisfies ProjectionDefinition<'turnBoundary', TurnBoundaryProjection> + /** Factory-level ownership: live agent teardowns plus config startup work. */ class FactoryOwnership { private accepting = true @@ -294,7 +353,7 @@ function validateConfiguredAgents(agents: Config['agents']): void { /** Concrete agent factory and driver service. */ export class AgentLoop extends Service implements AgentFactory { - static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'] + static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt', 'sessionProjections'] /** Runtime schema for declarative agents. */ static Config = z.object({ @@ -318,6 +377,9 @@ export class AgentLoop extends Service implements AgentFactory { constructor(ctx: Context, config: Config) { super(ctx, 'agentLoop') + + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + const entry: AgentLoopSettings = { maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls), } diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index 0cd3ac39e4..9444fce0b6 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context, type Fiber } from '@deepseek-ai/cordis' 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 LlmRuntime, { createUserMessage, CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -21,6 +22,7 @@ async function harness(adapter: LlmAdapter): Promise { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) const agentsFiber = await ctx.plugin(AgentRegistry) @@ -121,6 +123,7 @@ describe('AgentLoop initiator scope', () => { const adapter = new OverlapAdapter(ctx) await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -380,6 +383,7 @@ describe('AgentLoop initiator scope', () => { const adapter = new ReloadAdapter() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 29073353e3..fc39e46a28 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' 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 LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -13,6 +14,7 @@ async function harness(adapter: MockAdapter): Promise { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 28423bb430..a7cfcbafd8 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -15,6 +15,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH } 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 { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' function driverDone(agent: Agent): Promise { @@ -25,6 +26,7 @@ async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -514,6 +516,7 @@ describe('Agent.cancel()', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index b08c076dad..a19b5da3f5 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -12,6 +12,7 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import AgentLoop, { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse } from './mock-adapter.ts' const dirs: string[] = [] @@ -29,6 +30,7 @@ async function makeCoreContext(): Promise { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -327,6 +329,7 @@ describe('config-driven session id', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -352,6 +355,7 @@ describe('config-driven session id', () => { const ctx1 = new Context() await ctx1.plugin(LlmRuntime) await ctx1.plugin(SessionStore) + await ctx1.plugin(SessionProjectionRegistry) await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRuntime) await ctx1.plugin(AgentRegistry) @@ -371,6 +375,7 @@ describe('config-driven session id', () => { const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -395,6 +400,7 @@ describe('config-driven session id', () => { const ctx1 = new Context() await ctx1.plugin(LlmRuntime) await ctx1.plugin(SessionStore) + await ctx1.plugin(SessionProjectionRegistry) await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRuntime) await ctx1.plugin(AgentRegistry) @@ -411,6 +417,7 @@ describe('config-driven session id', () => { const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -436,6 +443,7 @@ describe('config-driven session id', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index c085dc7de0..a4aa7ae63f 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -6,6 +6,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { defineContentToolFixture, type PostToolDecision } 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 { ReactLoopAgent } from '../src/agent.ts' import InvariantRegistry from '@deepseek-ai/dsh-invariants' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' @@ -30,6 +31,7 @@ async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -525,6 +527,7 @@ describe('turn numbering continues across seeded sessions', () => { const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -676,6 +679,7 @@ describe('turn and step boundary recovery', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1108,6 +1112,7 @@ describe('disposal and cancellation during pre-step assembly', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1158,6 +1163,7 @@ describe('disposal and cancellation during pre-step assembly', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1208,6 +1214,7 @@ describe('disposal and cancellation during pre-step assembly', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1254,6 +1261,7 @@ describe('disposal and cancellation during pre-step assembly', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1302,6 +1310,7 @@ describe('disposal and cancellation during pre-step assembly', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 21506c9a0b..11a12e807f 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -8,6 +8,7 @@ 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 { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' function driverDone(agent: Agent): Promise { @@ -18,6 +19,7 @@ async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 72e3165f78..d7926a5037 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -16,6 +16,7 @@ import AgentRegistry, { } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' /** @@ -31,6 +32,7 @@ async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 2105b86f42..170bf9c70c 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -7,6 +7,7 @@ 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 { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts' function driverDone(agent: Agent): Promise { @@ -17,6 +18,7 @@ async function harness(adapter: MockAdapter, persona = '') { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1433,6 +1435,7 @@ describe('agent loop', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1457,6 +1460,7 @@ describe('agent loop', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 2757a633be..38d135abb3 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -20,6 +20,7 @@ import ToolRuntime 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 fc from 'fast-check' /** A never-exhausting adapter: every model call returns the same short reply. */ @@ -39,6 +40,7 @@ async function harness() { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index d47fbccbb1..71633906b3 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -8,6 +8,7 @@ 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 * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' /** @@ -41,6 +42,7 @@ async function loopHarness(): Promise { const created = new Context() await created.plugin(LlmRuntime) await created.plugin(SessionStore) + await created.plugin(SessionProjectionRegistry) await created.plugin(SystemPrompt, { persona: SYSTEM }) await created.plugin(ToolRuntime) await created.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts index 9d1c2a42c3..924a915160 100644 --- a/packages/core/agent-loop/tests/request-error.spec.ts +++ b/packages/core/agent-loop/tests/request-error.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LlmRuntime, { createUserMessage, LlmError } from '@deepseek-ai/dsh-llm' import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -13,6 +14,7 @@ async function harness(adapter: MockAdapter): Promise { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 287e73303c..a354356ea2 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -15,6 +15,7 @@ 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 { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter, persona = 'stable base') { @@ -28,6 +29,7 @@ async function harnessRoutes( const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -255,6 +257,7 @@ describe('request stability across the loop', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona: 'stable base' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -373,6 +376,7 @@ describe('request stability across the loop', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona: 'stable base' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 1c140e1b2e..df2849d39d 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -13,6 +13,7 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse } from './mock-adapter.ts' const dirs: string[] = [] @@ -28,6 +29,7 @@ async function mountPersistentHarness(root: string, adapter: MockAdapter): Promi const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -244,6 +246,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -272,6 +275,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -522,6 +526,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -585,6 +590,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -617,6 +623,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -653,6 +660,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -686,6 +694,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -875,6 +884,7 @@ describe('configured-start failure edges', () => { const configured = new Context() await configured.plugin(LlmRuntime) await configured.plugin(SessionStore) + await configured.plugin(SessionProjectionRegistry) await configured.plugin(SystemPrompt) await configured.plugin(ToolRuntime) await configured.plugin(AgentRegistry) @@ -920,6 +930,7 @@ describe('configured-start failure edges', () => { const configured = new Context() await configured.plugin(LlmRuntime) await configured.plugin(SessionStore) + await configured.plugin(SessionProjectionRegistry) await configured.plugin(SystemPrompt) await configured.plugin(ToolRuntime) await configured.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index abe3730fd1..6b78213200 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -10,6 +10,7 @@ import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh import type { Agent } from '@deepseek-ai/dsh-agent' import { scopeOf } from '@deepseek-ai/dsh-scope' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -17,6 +18,7 @@ async function harnessWithLoop(adapter: MockAdapter = new MockAdapter([textRespo const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona: 'You are the deployment.' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/settings.spec.ts b/packages/core/agent-loop/tests/settings.spec.ts index 4b45baab44..aba9cc47b8 100644 --- a/packages/core/agent-loop/tests/settings.spec.ts +++ b/packages/core/agent-loop/tests/settings.spec.ts @@ -8,6 +8,7 @@ import SessionStore 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 SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { SettingsProvider } from '@deepseek-ai/dsh-settings' import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' import AgentLoop, { AGENT_LOOP_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-loop' @@ -34,6 +35,7 @@ async function boot(): Promise<{ ctx: Context; settingsFiber: Fiber; loopFiber: const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 87f69cc7d0..a2fad43978 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -12,6 +12,7 @@ import LlmRuntime from '@deepseek-ai/dsh-llm' import ToolRuntime, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH, TOOL_RUNTIME_SCHEDULER, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse } from './mock-adapter.ts' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' @@ -20,6 +21,7 @@ async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -267,17 +269,24 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => await expect(harness(new MockAdapter([]), 1.5)).rejects.toThrow() }) - it('defensively rejects invalid caps when direct construction bypasses the config schema', () => { - expect(() => new AgentLoop(new Context(), { agents: [], maxParallelToolCalls: 0 })) + it('defensively rejects invalid caps when direct construction bypasses the config schema', async () => { + const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + expect(() => new AgentLoop(ctx, { agents: [], maxParallelToolCalls: 0 })) .toThrow('maxParallelToolCalls must be a positive integer') - expect(() => new AgentLoop(new Context(), { agents: [], maxParallelToolCalls: 1.5 })) + await ctx.fiber.dispose() + const other = new Context() + await other.plugin(SessionProjectionRegistry) + expect(() => new AgentLoop(other, { agents: [], maxParallelToolCalls: 1.5 })) .toThrow('maxParallelToolCalls must be a positive integer') + await other.fiber.dispose() }) it('defaults the cap when direct construction bypasses the config schema', async () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -346,6 +355,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -704,6 +714,7 @@ describe('code-mode native-tool denial through the agent loop', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime, { mode: 'code' }) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- FakeCodeRuntime is an internal test helper with an opaque type shape diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index a9af9f56bb..563e5ba6c3 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -17,12 +17,14 @@ 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 { MockAdapter, textResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['toolOrder']) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona: 'stable base', ...toolOrder !== undefined ? { toolOrder } : {} }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tsconfig.json b/packages/core/agent-loop/tsconfig.json index 1765cb00b3..23dbe218ae 100644 --- a/packages/core/agent-loop/tsconfig.json +++ b/packages/core/agent-loop/tsconfig.json @@ -43,6 +43,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../session/session-projection" } ] } diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 6144f8679a..cd986a99a4 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -43,7 +43,8 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", @@ -53,6 +54,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 81052096dc..ce42e1f898 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -17,6 +17,7 @@ import type { Agent, 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' diff --git a/packages/core/agent/src/projection.ts b/packages/core/agent/src/projection.ts new file mode 100644 index 0000000000..89c10ac858 --- /dev/null +++ b/packages/core/agent/src/projection.ts @@ -0,0 +1,11 @@ +import type { TurnBoundaryProjection } from './types.ts' +import type {} from '@deepseek-ai/dsh-session-projection' + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** The agent session's open/last turn and step boundary facts (whole value). */ + turnBoundary: TurnBoundaryProjection + } +} + +export {} diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index b54e56ea8f..c7c9bfa0f6 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -9,6 +9,20 @@ import type { UserMessage } from '@deepseek-ai/dsh-llm/types' /** One of the two ordered pending-message lists owned by an agent. */ export type InboxTarget = 'next-turn' | 'next-step' +/** Turn and step boundaries folded from one agent session log. */ +export interface TurnBoundaryProjection { + /** Open turn number, or null between turns. */ + readonly openTurn: number | null + /** Seq of the open turn's `turn/start`, or null between turns. */ + readonly openTurnStartSeq: number | null + /** Seq of the latest `step/start` event, or null before the first step. */ + readonly lastStepStartSeq: number | null + /** The latest step boundary (`step/start` or `step/end`) and its seq, or null before the first step boundary. */ + readonly lastStepBoundary: { readonly kind: 'start' | 'end'; readonly seq: number } | null + /** Turn number of the latest `turn/start`; 0 before the first turn. */ + readonly lastTurn: number +} + declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** diff --git a/packages/core/agent/tsconfig.json b/packages/core/agent/tsconfig.json index dfa66f16ff..f7aae0283e 100644 --- a/packages/core/agent/tsconfig.json +++ b/packages/core/agent/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../runtime-diagnostics/invariants" }, + { + "path": "../../session/session-projection" + }, { "path": "../../typert/protocol" } diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index fd27a8822d..23dd010983 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -63,6 +63,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index ec770592a6..ea18cca2ce 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -1,8 +1,11 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { createUserMessage, CallId, HarnessError, type ContentBlock } from '@deepseek-ai/dsh-llm' +import LlmRuntime, { createUserMessage, CallId, HarnessError, type ContentBlock } from '@deepseek-ai/dsh-llm' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' import ToolRuntime, { defineContentToolFixture, defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, @@ -718,19 +721,21 @@ describe('ToolRuntime', () => { }) describe('ask routing through ctx.approval', () => { - /** - * A minimal Agent stand-in — the approval seam reaches - * `agent.session.append` and folds `.events`; the seeded open turn - * satisfies request()'s enclosure precondition. - */ function fakeAgent(): Agent { - return { - session: { events: [{ type: 'turn/start' }], append: () => ({}) }, - } as unknown as Agent + const session = Session.create(SessionId('approval-fake-agent')) + session.append('turn/start', { turn: 1 }) + return { session } as unknown as Agent } async function approvalSetup() { - const ctx = await setup() + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(ApprovalService) ctx.tools.register(echoTool) return ctx diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index de22377405..53dbb684cc 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -71,6 +71,7 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-agent-instructions": "workspace:^", "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 24ec62cc02..bc1bc09dd1 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -9,6 +9,7 @@ import { agentEvents } from '@deepseek-ai/dsh-agent' import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import type { Message } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as acpAgent from '../src/index.ts' /** @@ -32,6 +33,9 @@ async function mount(config: acpAgent.Config, withBash = false): Promise { // `ctx.plugin`, which validates+defaults the config first) with no // persistenceRoot, so the runtime fallback is the one that fires. const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) // No persona: covers the omitted-persona forwarding branch too. await acpAgent.apply(ctx, { provider: 'mock', @@ -154,6 +159,7 @@ describe('dsh-acp-demo composition', () => { it('uses default skill config when apply is called directly without skills', async () => { await withIsolatedSkillHomes(async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await acpAgent.apply(ctx, { provider: 'mock', model: 'mock', workspaceContext: false }) expect(ctx.skills).toBeDefined() expect(await ctx.skills.list()).toEqual([]) diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 9f872e6dd6..aff408bd27 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -92,7 +92,8 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-agent-instructions": "workspace:^", "@deepseek-ai/node-addon-landlock-run": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 87098c38da..f67efff4bb 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -13,6 +13,7 @@ import Timer from '@deepseek-ai/cordis-plugin-timer' import z from '@deepseek-ai/schemastery' import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionTitleService, { type Config as SessionTitleConfig } from '@deepseek-ai/dsh-session-title' import SystemPrompt, { type Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' @@ -220,6 +221,7 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(Timer) ctx.plugin(LlmRuntime) ctx.plugin(SessionStore) + ctx.plugin(SessionProjectionRegistry) ctx.plugin(SessionTitleService, config.sessionTitle ?? EXAMPLE_SESSION_TITLE_CONFIG) // Owner schemas resolve defaults; forward toolOrder only when explicitly set. ctx.plugin(SystemPrompt, { diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 56f7903fcb..9da8db372a 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -8,6 +8,7 @@ import { renderPrompt, TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as agentCore from '../src/index.ts' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' @@ -77,6 +78,9 @@ async function mount(config: agentCore.Config, withBash = false): Promise { // ctx.plugin validates + defaults the bundle config first; a direct apply // skips the schema, so the forwarding `?? []` / `?? ''` are what fire. const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) agentCore.apply(ctx, { workspaceContext: false }) await new Promise(resolve => setTimeout(resolve, 50)) expect(ctx.get('agentLoop')).toBeDefined() @@ -337,6 +342,7 @@ describe('dsh-agent-spine-demo bundle', () => { it('uses owner defaults for a schema-bypassing empty goal opt-in', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) agentCore.apply(ctx, { workspaceContext: false, agents: [{ id: SessionId('defaulted-goal'), provider: 'mock', model: 'mock' }], @@ -772,6 +778,7 @@ describe('dsh-agent-spine-demo bundle', () => { it('uses the default skill config when apply is called directly without skills', async () => { await withIsolatedSkillHomes(async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) agentCore.apply(ctx, { agents: [], workspaceContext: false }) await new Promise(resolve => setTimeout(resolve, 50)) expect(ctx.skills).toBeDefined() @@ -800,6 +807,7 @@ describe('dsh-agent-spine-demo bundle', () => { it('supports direct apply with workspace instructions disabled and no forwarded agents', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) agentCore.apply(ctx, { workspaceContext: false }) await new Promise(resolve => setTimeout(resolve, 50)) diff --git a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts index c9fe5fe799..e291309d6c 100644 --- a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts +++ b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts @@ -12,6 +12,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { SessionId } from '@deepseek-ai/dsh-session' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import type { ToolResult } from '@deepseek-ai/dsh-tools' @@ -52,6 +53,7 @@ beforeEach(async () => { ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: fallbackRoot }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(SandboxBashExecutor, { cwd: fallbackRoot, timeoutMs: 30_000 }) diff --git a/packages/experimental/team/package.json b/packages/experimental/team/package.json index 8a2423093a..f68d1da12f 100644 --- a/packages/experimental/team/package.json +++ b/packages/experimental/team/package.json @@ -51,6 +51,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", diff --git a/packages/experimental/team/tests/persistence.spec.ts b/packages/experimental/team/tests/persistence.spec.ts index 121a98445c..cc649d0f5b 100644 --- a/packages/experimental/team/tests/persistence.spec.ts +++ b/packages/experimental/team/tests/persistence.spec.ts @@ -9,6 +9,7 @@ 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 SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SqliteSessionPersistence from '@deepseek-ai/dsh-session-persistence-sqlite' import SubagentService, { seedDescriptorTurn, snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' @@ -93,6 +94,7 @@ 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(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) diff --git a/packages/experimental/team/tests/team.spec.ts b/packages/experimental/team/tests/team.spec.ts index dd03cd6438..5c36947a48 100644 --- a/packages/experimental/team/tests/team.spec.ts +++ b/packages/experimental/team/tests/team.spec.ts @@ -8,6 +8,7 @@ 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, type Session } 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 * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' @@ -45,6 +46,7 @@ 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 }) @@ -136,6 +138,7 @@ 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 }) @@ -1246,6 +1249,7 @@ 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 }) diff --git a/packages/experimental/tool-team/package.json b/packages/experimental/tool-team/package.json index 3aa6c766e2..49f9af30ce 100644 --- a/packages/experimental/tool-team/package.json +++ b/packages/experimental/tool-team/package.json @@ -47,6 +47,7 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", diff --git a/packages/experimental/tool-team/tests/tool-team.spec.ts b/packages/experimental/tool-team/tests/tool-team.spec.ts index f950af4aad..1e94b6eeef 100644 --- a/packages/experimental/tool-team/tests/tool-team.spec.ts +++ b/packages/experimental/tool-team/tests/tool-team.spec.ts @@ -9,6 +9,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import { CallId } 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 SubagentService from '@deepseek-ai/dsh-subagent' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' @@ -44,6 +45,7 @@ afterEach(() => { async function setup(script: ConstructorParameters[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 }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 283dcde600..1a4c6443ee 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -341,7 +341,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'overrideOf(session: Session): ApprovalPolicy | undefined', - description: 'Read the session override without applying the configured default.', + description: 'Read the projected session override without applying the configured default.', parameters: [{ name: 'session', description: 'session whose log supplies the override.' }], returns: 'the last logged policy, or `undefined` without one.', }, @@ -918,9 +918,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Owns the deployment\'s permission presets and their write path. Requires a confining `ctx.shell` executor and `ctx.approval`; unmatched knob values are reported as CUSTOM_PRESET, not an error.', methods: [ { - signature: 'current(events: readonly SessionEvent[]): string', + signature: 'current(session: Session): string', description: 'Resolve the preset matching the effective knob values. A still-matching last selection wins shared-bundle ties; otherwise the first table match wins, or CUSTOM_PRESET when no entry matches.', - parameters: [{ name: 'events', description: 'the session\'s events in log order.' }], + parameters: [{ name: 'session', description: 'the session whose knob state is read.' }], returns: 'the effective preset name, or `custom` when nothing matches.', }, { @@ -953,7 +953,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'planMode', summary: '`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool.', - description: '`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. UIs observe committed flips through `session/event`; there is no live mirror.', + description: '`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. Client carriers expose the projection\'s cropped `{ active, pending }` view.', methods: [ { signature: 'get(agent: Agent): { active: boolean; pending?: boolean }', @@ -984,8 +984,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'sandboxPolicy', - summary: 'The sandbox-policy service (`ctx.sandboxPolicy`).', - description: 'The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, and current request-time policy section. Tool layers call resolve for each execution so a session\'s mode log and immutable cwd travel together to every enforcing capability.', + summary: 'The sandbox policy seam: the deployment default mode and workspace-write root, with per-session overrides folded from the log.', + description: 'The sandbox policy seam: the deployment default mode and workspace-write root, with per-session overrides folded from the log.', methods: [ { signature: 'readonly defaultMode: SandboxMode', @@ -1110,7 +1110,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'sessionProjections', summary: '`ctx.sessionProjections`: the projection unit table and its drive.', - description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject([\'sessionProjections\'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', + description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. Unit contributors and host readers require this service, so incomplete compositions fail during activation. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', methods: [ { signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: ProjectionDefinition & { wire: NonNullable[\'wire\']> }, ): () => void', @@ -1655,10 +1655,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise', - description: 'Enumerate the parent\'s direct session-backed subagents without loading or resuming an Agent and without any query service: the listing merges the live session store with optional session persistence (live-preferred) and serves each child\'s durable mode/label from the registered `subagent` projection unit down a three-rung ladder — the registry\'s watermark snapshot for a live child; for a cold one, a durable projection-cache row when the optional cache serves an own-suffix identity (its `seq` gate proves the value postdates the fork seed, where a child\'s own descriptor is immutable once appended), else one persistence inspection folded through the registry. The projection fold is the single classification authority; per-child diagnostics relay a fold that served no identity or a failed inspection, never a list-time descriptor parse. Absent persistence, enumeration is live-only (a cold child cannot be resumed then either, so its absence is capability absence, not an error). This service consults no Agent registrations, Activations, or providers.\n\nEvery persistence read receives `signal`, and the listing rechecks cancellation around each of those awaits. Read rejections that settle after an abort become a stable `SubagentError` with code `CANCELLED`.', + description: 'Enumerate the parent\'s direct session-backed subagents without loading or resuming an Agent and without any query service: the listing merges the live session store with optional session persistence (live-preferred) and serves each child\'s durable mode/label from the registered `subagent` projection unit down a three-rung ladder — the registry\'s watermark snapshot for a live child; for a cold one, a durable projection-cache row when the optional cache serves an own-suffix identity (its `seq` gate proves the value postdates the fork seed, where a child\'s own descriptor is immutable once appended), else one persistence inspection folded through the same unit. The projection fold is the single classification authority; per-child diagnostics relay a fold that served no identity or a failed inspection, never a list-time descriptor parse. Absent persistence, enumeration is live-only (a cold child cannot be resumed then either, so its absence is capability absence, not an error). This service consults no Agent registrations, Activations, or providers.\n\nEvery persistence read receives `signal`, and the listing rechecks cancellation around each of those awaits. Read rejections that settle after an abort become a stable `SubagentError` with code `CANCELLED`.', parameters: [{ name: 'parentSessionId', description: 'parent session whose direct children are listed.' }, { name: 'signal', description: 'caller-owned cancellation forwarded to persistence reads and observed around every read await.' }], returns: 'children and per-child diagnostics ordered by `createdAt`, then id.', - throws: ['{@link SubagentError} when the projection registry or the session store is not mounted, or the caller cancels the listing.'], + throws: ['{@link SubagentError} when the session store is not mounted, or the caller cancels the listing.'], }, { signature: 'listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise', @@ -2761,7 +2761,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ApprovalService', - declaration: 'export class ApprovalService extends Service {\n static Config: z;\n constructor(ctx: Context, public config: Config);\n setPolicy(agent: Agent, policy: ApprovalPolicy): void;\n async request(req: ApprovalRequest): Promise;\n overrideOf(session: Session): ApprovalPolicy | undefined;\n}', + declaration: 'export class ApprovalService extends Service {\n static Config: z;\n static inject;\n constructor(ctx: Context, public config: Config);\n setPolicy(agent: Agent, policy: ApprovalPolicy): void;\n async request(req: ApprovalRequest): Promise;\n overrideOf(session: Session): ApprovalPolicy | undefined;\n}', }, { name: 'AskUserQuestionAnswer', @@ -4055,10 +4055,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionTitleProvider', declaration: 'export interface SessionTitleProvider {\n readonly id: SessionTitleProviderId;\n readonly automatic: SessionTitleAutomaticMode;\n generate(request: SessionTitleProviderRequest): Promise;\n}', }, - { - name: 'SessionTitleProviderId', - declaration: 'export type SessionTitleProviderId = Branded<\'SessionTitleProviderId\'>;', - }, { name: 'SessionTitleProviderRequest', declaration: 'export interface SessionTitleProviderRequest {\n readonly session: Session;\n readonly messages: readonly SessionTitleUserMessage[];\n readonly route?: SessionTitleModelProvenance;\n readonly signal: AbortSignal;\n}', @@ -4281,7 +4277,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRuntime', - declaration: 'export class SubagentRuntime extends Service {\n constructor(ctx: Context);\n async startContinuable(spec: ContinuableStartSpec): Promise;\n async followup(parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions): Promise;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async reportFrom(child: Agent, content: ContentBlock[], options: SubagentReportOptions): Promise;\n registerContinuableSetup(contribution: ContinuableSetupContribution): () => void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise;\n async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | undefined;\n list(): string[];\n async start(name: string, request: SubagentStartRequest): Promise;\n}', + declaration: 'export class SubagentRuntime extends Service {\n static inject;\n constructor(ctx: Context);\n async startContinuable(spec: ContinuableStartSpec): Promise;\n async followup(parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions): Promise;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async reportFrom(child: Agent, content: ContentBlock[], options: SubagentReportOptions): Promise;\n registerContinuableSetup(contribution: ContinuableSetupContribution): () => void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise;\n async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | undefined;\n list(): string[];\n async start(name: string, request: SubagentStartRequest): Promise;\n}', }, { name: 'SubagentStartRequest', diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index 674fc4919f..a752df1844 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-sandbox/package.json @@ -45,6 +45,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts index f6aaa0dd81..435086a301 100644 --- a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts +++ b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts @@ -17,6 +17,7 @@ import { Context } from '@deepseek-ai/cordis' import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs' import type { FsTarget } from '@deepseek-ai/dsh-fs' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import { SandboxedFileSystem } from '@deepseek-ai/dsh-fs-sandbox' @@ -29,6 +30,7 @@ let fiber: Awaited> async function boot(mode: SandboxMode): Promise { ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) fiber = await ctx.plugin(SandboxedFileSystem, { cwd: workspace }) fs = ctx.fs as SandboxedFileSystem @@ -171,6 +173,7 @@ describe('workspace-write with the filesystem root as the workspace (a root endi // A degenerate but valid config: the filesystem root containing the target. // It exercises the separator-suffixed-root branch on POSIX and Windows. const rootCtx = new Context() + await rootCtx.plugin(SessionProjectionRegistry) await rootCtx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: parse(base).root }) const rootFiber = await rootCtx.plugin(SandboxedFileSystem, { cwd: workspace }) const rootFs = rootCtx.fs as SandboxedFileSystem diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 65f326ddcb..b021e6b2f8 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -65,6 +65,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/fs/tool-fs/tests/harness.ts b/packages/fs/tool-fs/tests/harness.ts index 2e6dc22cf8..f226e47b71 100644 --- a/packages/fs/tool-fs/tests/harness.ts +++ b/packages/fs/tool-fs/tests/harness.ts @@ -1,6 +1,7 @@ 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' @@ -14,6 +15,7 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' */ export async function fsHarness(fsCwd: string, persona = ''): Promise { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona } }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index cad6531766..14e92c1eaf 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -9,6 +9,7 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node: import { tmpdir } from 'node:os' import { join, resolve, sep } from 'node:path' import { CallId } from '@deepseek-ai/dsh-llm' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { type ToolResult } from '@deepseek-ai/dsh-tools' import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' @@ -31,6 +32,7 @@ import { sessionCwd } from '../src/session-cwd.ts' import ApprovalService from '@deepseek-ai/dsh-user-approval' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' const testToolSignal = new AbortController().signal @@ -792,6 +794,8 @@ describe('sandbox escalation API (write/edit)', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write' }) await ctx.plugin(SandboxingFakeFs) await ctx.plugin(FsPolicy) @@ -806,7 +810,7 @@ describe('sandbox escalation API (write/edit)', () => { id: 'agent-fs-esc', session: { header: { version: 0, id: 'sess-fs-esc', createdAt: 0, cwd: '/session-project' }, - events: [{ type: 'turn/start' }, ...events], + events: [{ type: 'turn/start', data: { turn: 1 } }, ...events], append: (type: string, data: Record) => { events.push({ type, data }) }, }, } diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index 07af01aa95..5ea86454c8 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index e363666606..34dea8ee7d 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -12,6 +12,7 @@ import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy' import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox' import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy' +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' @@ -76,6 +77,9 @@ async function setup( if (options.sandboxMode === undefined) { await ctx.plugin(LocalFileSystem, { cwd: root }) } else { + // SandboxPolicy declares the registry as a required injection; mount it + // before the policy activates. + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicy, { mode: options.sandboxMode, workspaceRoot: root }) await ctx.plugin(SandboxedFileSystem, { cwd: root }) } diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index f21ecb6333..75904fd71d 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 2127844646..ed126cc02e 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -7,6 +7,7 @@ import CommandRuntime from '@deepseek-ai/dsh-commands' import GoalService from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as commandGoal from '@deepseek-ai/dsh-command-goal' interface Harness { @@ -46,6 +47,7 @@ async function harness(): Promise { await ctx.plugin(SessionStore) 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()}`) diff --git a/packages/goal/goal-round-driver/package.json b/packages/goal/goal-round-driver/package.json index c499037847..fd4fa8bf84 100644 --- a/packages/goal/goal-round-driver/package.json +++ b/packages/goal/goal-round-driver/package.json @@ -49,6 +49,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts index 1bd2032600..3d0bc5aca4 100644 --- a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts +++ b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts @@ -10,6 +10,7 @@ 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[]) @@ -89,6 +90,7 @@ async function harness(script: ScriptEntry[]): Promise { 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: [] }) @@ -213,6 +215,7 @@ 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')]) diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index baf8c921a9..045c773bcd 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -13,7 +13,6 @@ import { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' -// Type-only: resolves ctx.sessionProjections for the optional unit child. import type {} from '@deepseek-ai/dsh-session-projection' import { applyGoalEvent, @@ -50,7 +49,7 @@ import type { // The pure payload outlet (./types.ts, ONE home of the `goal` projection-key // declaration) re-exported onto the package root keeps the module edge in // the emitted index.d.ts, so aggregate programs consuming the declarations -// still receive the SessionProjectionMap merge. +// still receive the SessionProjectionStateMap merge. export type * from './types.ts' export type * from './domain.ts' export { GOAL_CHANGE_VERSION, GoalError, GoalId } from './runtime.ts' @@ -181,7 +180,7 @@ function resolveBlockReason(reason: unknown): GoalBlockReason { /** Goal service (`ctx.goals`) backed exclusively by the owning session log. */ export class GoalService extends TypertRemoteService { - static inject = ['agents'] + static inject = ['agents', 'sessionProjections'] static Config: z = z.object({ defaultMaxGoalRounds: z.number().default(256), @@ -198,18 +197,13 @@ export class GoalService extends TypertRemoteService { ctx.on('agent/session-start', ({ agent }) => { this.cache(agent.session).activation = 'disarmed' }) - // The `goal` projection unit: last-wins fold of goal/change whole values - // (see applyGoalProjection). The unit child activates only when a - // projection registry is composed (headless assemblies stay unaffected). - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register<'goal', GoalProjection | null>({ - key: 'goal', - stateSchema: goalProjectionSchema, - init: () => null, - apply: applyGoalProjection, - wire: { viewSchema: goalProjectionSchema, view: state => state }, - stateVersion: 4, - }) + ctx.sessionProjections.register<'goal', GoalProjection | null>({ + key: 'goal', + stateVersion: 4, + stateSchema: goalProjectionSchema, + init: () => null, + apply: applyGoalProjection, + wire: { viewSchema: goalProjectionSchema, view: state => state }, }) } diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index e94e0754a0..1eb0c90d2e 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -4,6 +4,7 @@ import AgentRegistry, { agentEvents, Inbox } 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' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import GoalService, { GoalError, GoalId, @@ -60,6 +61,7 @@ function stubAgent(rawId: string, seed?: readonly import('@deepseek-ai/dsh-sessi async function harness(config: { defaultMaxGoalRounds?: number } = {}) { const ctx = new Context() await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService, config) const stub = stubAgent(`goal-test-${Math.random()}`) ctx.agents.register(stub.agent) @@ -131,6 +133,7 @@ describe('GoalService creation and replay', () => { it('also resolves the default when constructed directly without Cordis config normalization', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) const goals = new GoalService(ctx) const stub = stubAgent('goal-direct-construction') ctx.agents.register(stub.agent) @@ -142,6 +145,7 @@ describe('GoalService creation and replay', () => { it('rejects invalid direct configuration', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) await expect(ctx.plugin(GoalService, { defaultMaxGoalRounds: -1 })).rejects.toThrow(expect.objectContaining({ code: 'GOAL_INVALID_MAX_ROUNDS', })) @@ -155,6 +159,7 @@ describe('GoalService creation and replay', () => { const ctx = new Context() await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) const resumed = stubAgent('seeded-goal', first.session.events) ctx.agents.register(resumed.agent) @@ -169,6 +174,7 @@ describe('GoalService creation and replay', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) const parent = stubAgentForSession(ctx.sessions.create(SessionId('goal-fork-parent'))) ctx.agents.register(parent.agent) @@ -215,6 +221,7 @@ describe('GoalService creation and replay', () => { it('removes the service and its session-start listener with the providing fiber', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(GoalService) const first = ctx.goals const stub = stubAgent('goal-hmr') @@ -423,6 +430,7 @@ describe('GoalService mutations', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) const stub = stubAgentForSession(ctx.sessions.create(SessionId('goal-reentrant-observer'))) ctx.agents.register(stub.agent) @@ -441,6 +449,7 @@ describe('GoalService mutations', () => { it('does not delegate goal persistence to agent injection', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) const stub = stubAgent('goal-independent-injection') stub.agent.inject = () => { throw new Error('injection must not be called') } diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 6282a6f739..e5fb642e84 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -1,12 +1,4 @@ -/** - * The `goal` projection unit: mounting GoalService beside the registry - * serves the current whole goal on the history tail page with a consistent - * asOfSeq; before the first create the value is null; a clear tombstone - * returns it to null; a composition without the goal service has no `goal` - * key; unmounting drops it (HMR safety). Malformed goal-shaped events are - * ignored fail-soft (same-reference return) — strict replay validation - * belongs to the write side and foldGoal, never the projection drive. - */ +/** Goal projection behavior. */ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index 2b20854351..6a3d20f6b9 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -39,7 +39,8 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" @@ -47,12 +48,14 @@ "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index 1fdfbc740a..31d9256b47 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -6,13 +6,11 @@ import type { GoalView } from '@deepseek-ai/dsh-goal' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { ToolRunContext } from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-session-projection' -type TurnStartEvent = Extract - -/** Current open turn plus the events accepted after its start boundary. */ +/** The calling agent plus the events accepted after its open turn's start boundary. */ export interface GoalToolExecution { readonly agent: Agent - readonly start: TurnStartEvent readonly events: readonly SessionEvent[] } @@ -26,19 +24,19 @@ function reject(message: string, code = 'GOAL_TOOL_AUTHORITY_REQUIRED'): never { throw new HarnessError(message, code) } -/** Locate the open turn enclosing a model tool call. */ -function openTurn(agent: Agent): { start: TurnStartEvent; events: readonly SessionEvent[] } { +/** + * The event window of the open turn enclosing a model tool call. The + * open-turn boundary comes from the `turnBoundary` projection (one O(1) + * snapshot read); the suffix is the raw event window after the open + * `turn/start` seq. + */ +function openTurnEvents(ctx: Context, agent: Agent): readonly SessionEvent[] { const events = agent.session.events - for (let index = events.length - 1; index >= 0; index -= 1) { - const boundary = events[index] - if (boundary?.type === 'turn/end') { - reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') - } - if (boundary?.type === 'turn/start') { - return { start: boundary, events: events.slice(index + 1) } - } + const boundary = ctx.sessionProjections.stateOf(agent.session, 'turnBoundary') + if (boundary === undefined || boundary.openTurnStartSeq === null) { + reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') } - return reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') + return events.slice(boundary.openTurnStartSeq + 1) } /** @@ -59,7 +57,7 @@ export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolE 'GOAL_TOOL_DRIVER_REQUIRED', ) } - return { agent, ...openTurn(agent) } + return { agent, events: openTurnEvents(ctx, agent) } } /** diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 903190de4d..b60468e329 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -20,7 +20,7 @@ import { import { renderWrapupContext } from './wrapup.ts' export const name = 'tool-goal' -export const inject = ['agents', 'goals', 'tools', 'systemPrompt'] +export const inject = ['agents', 'goals', 'tools', 'systemPrompt', 'sessionProjections'] /** Model policy and hard lower bounds for goal-state updates. */ export interface Config { diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index cb5a2a9874..bced8b3eb8 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -3,6 +3,7 @@ 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 { 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, CallId } from '@deepseek-ai/dsh-llm' @@ -10,6 +11,7 @@ import type { MessageSource } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' 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' @@ -74,6 +76,8 @@ async function harness(config: toolGoal.Config = {}) { 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()}`) @@ -164,7 +168,7 @@ describe('goal tool registration and presentation', () => { it('has the Loader-safe namespace export shape', () => { expect('default' in toolGoal).toBe(false) expect(toolGoal.name).toBe('tool-goal') - expect(toolGoal.inject).toEqual(['agents', 'goals', 'tools', 'systemPrompt']) + expect(toolGoal.inject).toEqual(['agents', 'goals', 'tools', 'systemPrompt', 'sessionProjections']) const loader = Object.create(Loader.prototype) as Loader expect(loader.unwrapExports(toolGoal)).toBe(toolGoal) }) diff --git a/packages/goal/tool-goal/tsconfig.json b/packages/goal/tool-goal/tsconfig.json index 1570cd7d2d..1933a0c8b3 100644 --- a/packages/goal/tool-goal/tsconfig.json +++ b/packages/goal/tool-goal/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../session/session-projection" } ] } diff --git a/packages/guard/repeat-tool-reminder/package.json b/packages/guard/repeat-tool-reminder/package.json index 447bc2e08f..133bbc4ffe 100644 --- a/packages/guard/repeat-tool-reminder/package.json +++ b/packages/guard/repeat-tool-reminder/package.json @@ -48,6 +48,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts index c2dda01558..b78b547bd9 100644 --- a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts +++ b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts @@ -6,6 +6,7 @@ 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' @@ -24,6 +25,8 @@ const testToolSignal = new AbortController().signal async function harness(config: Config = {}): Promise { 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' }] } })) @@ -370,6 +373,7 @@ describe('config validation fails loud', () => { async function spine(): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) return ctx } diff --git a/packages/hooks/hooks-claude-code/package.json b/packages/hooks/hooks-claude-code/package.json index 4b393fc8d9..647f99dddf 100644 --- a/packages/hooks/hooks-claude-code/package.json +++ b/packages/hooks/hooks-claude-code/package.json @@ -43,7 +43,8 @@ "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -60,6 +61,7 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/hooks/hooks-claude-code/src/index.ts b/packages/hooks/hooks-claude-code/src/index.ts index 79c2df194c..9e9bb2b40e 100644 --- a/packages/hooks/hooks-claude-code/src/index.ts +++ b/packages/hooks/hooks-claude-code/src/index.ts @@ -13,6 +13,7 @@ import { readFileSync } from 'node:fs' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-session-projection' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' @@ -37,9 +38,9 @@ import type { SubagentRunId } from '@deepseek-ai/dsh-subagent' import { parseClaudeCodeConfig, type ClaudeCodeHookConfig } from './config.ts' export const name = 'hooks-claude-code' -// `bash` is required to run hooks; the rest are read opportunistically via -// ctx.get so a deployment can load this bridge without every extension point present. -export const inject = ['shell'] +// `shell` runs hooks and `sessionProjections` supplies turn numbers; the rest +// are read opportunistically via ctx.get so a deployment can omit them. +export const inject = ['shell', 'sessionProjections'] /** Plugin config: where the CC hook config lives + substitution roots. */ export interface Config { @@ -236,7 +237,7 @@ export function apply(ctx: Context, config: Config): void { // --- PreToolUse → PreToolDecision. Matcher subject is the tool name. --- ctx.on('tools/pre-execute', async (exec, next): Promise => { - const turn = lastTurn(exec.agent) + const turn = lastTurn(ctx, exec.agent) const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} } @@ -245,7 +246,7 @@ export function apply(ctx: Context, config: Config): void { // --- PostToolUse → PostToolDecision. Matcher subject is the tool name. --- ctx.on('tools/post-execute', async (exec, result, next): Promise => { - const turn = lastTurn(exec.agent) + const turn = lastTurn(ctx, exec.agent) const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) const context = contextFrom(merged) if (merged.decision === 'deny') { @@ -307,11 +308,9 @@ const SUBAGENT_TYPE = 'general-purpose' // hook input schema; this is the part a bridge owns. --- /** The last open turn number in the agent's log, or 0 without an agent. */ -function lastTurn(agent: Agent | undefined): number { +function lastTurn(ctx: Context, agent: Agent | undefined): number { if (!agent) return 0 - const last = [...agent.session.events].findLast(e => e.type === 'turn/start') - /* v8 ignore next -- agent-present callers are tool/stop extension points inside an open turn. */ - return last?.type === 'turn/start' ? last.data.turn : 0 + return ctx.sessionProjections.stateOf(agent.session, 'turnBoundary')?.lastTurn ?? 0 } /** Flatten content blocks to the text a hook payload carries (the common case). */ diff --git a/packages/hooks/hooks-claude-code/tests/bridge.spec.ts b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts index 670cfd9b04..e58519b577 100644 --- a/packages/hooks/hooks-claude-code/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts @@ -15,6 +15,7 @@ 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' /** @@ -57,6 +58,7 @@ 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 }) @@ -355,6 +357,7 @@ 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 }) @@ -415,6 +418,7 @@ 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 }) @@ -435,12 +439,12 @@ describe('hooks-claude-code bridge — load resilience', () => { // "cannot get property … without inject". Guard the shape directly. expect('default' in HooksClaude).toBe(false) expect(HooksClaude.name).toBe('hooks-claude-code') - expect(HooksClaude.inject).toEqual(['shell']) + expect(HooksClaude.inject).toEqual(['shell', 'sessionProjections']) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(HooksClaude) as Record expect(unwrapped).toBe(HooksClaude) expect(unwrapped.name).toBe('hooks-claude-code') - expect(unwrapped.inject).toEqual(['shell']) + expect(unwrapped.inject).toEqual(['shell', 'sessionProjections']) expect(typeof unwrapped.apply).toBe('function') }) }) diff --git a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts index 08e20e3e49..71926a0ed1 100644 --- a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts @@ -15,6 +15,7 @@ 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 @@ -41,6 +42,7 @@ type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxC async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise { 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) @@ -368,6 +370,7 @@ 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 }) @@ -667,6 +670,7 @@ 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) @@ -696,6 +700,7 @@ 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) diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 9b40e43fb6..42b69ae8d5 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -42,7 +42,8 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -58,6 +59,7 @@ "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index a76965c970..3d4d41d8e6 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -16,6 +16,7 @@ import { readFileSync } from 'node:fs' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-session-projection' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' @@ -38,7 +39,7 @@ import { parseCodexConfig, type CodexHookConfig } from './config.ts' /* jscpd:ignore-end */ export const name = 'hooks-codex' -export const inject = ['shell'] +export const inject = ['shell', 'sessionProjections'] /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ export interface Config { @@ -223,7 +224,7 @@ export function apply(ctx: Context, config: Config): void { // PreToolUse → PreToolDecision. Codex blocks only (no allow/ask honored). ctx.on('tools/pre-execute', async (exec, next): Promise => { - const turn = lastTurn(exec.agent) + const turn = lastTurn(ctx, exec.agent) const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) /* jscpd:ignore-end */ if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } @@ -232,7 +233,7 @@ export function apply(ctx: Context, config: Config): void { // PostToolUse → PostToolDecision (block with feedback, or attach context). ctx.on('tools/post-execute', async (exec, result, next): Promise => { - const turn = lastTurn(exec.agent) + const turn = lastTurn(ctx, exec.agent) /* jscpd:ignore-start */ const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) const context = contextFrom(merged) @@ -276,11 +277,9 @@ export function apply(ctx: Context, config: Config): void { // These small payload helpers intentionally remain next to the dialect shape; // sharing them would pull bridge-only agent/LLM dependencies into hook-protocol. /* jscpd:ignore-start */ -function lastTurn(agent: Agent | undefined): number { +function lastTurn(ctx: Context, agent: Agent | undefined): number { if (!agent) return 0 - const last = [...agent.session.events].findLast(e => e.type === 'turn/start') - /* v8 ignore next -- agent-present turnBase callers are tool/stop extension points inside an open turn. */ - return last?.type === 'turn/start' ? last.data.turn : 0 + return ctx.sessionProjections.stateOf(agent.session, 'turnBoundary')?.lastTurn ?? 0 } function blocksToText(content: ContentBlock[]): string { @@ -304,7 +303,7 @@ function base(ctx: Context, agent: Agent | undefined, event: string, model: stri /** Base + turn_id, for the turn-scoped events (PreToolUse/PostToolUse/UserPromptSubmit/Stop). */ function turnBase(ctx: Context, agent: Agent | undefined, event: string, model: string): Record { - return { ...base(ctx, agent, event, model), turn_id: String(lastTurn(agent)) } + return { ...base(ctx, agent, event, model), turn_id: String(lastTurn(ctx, agent)) } } /** Extract a `command` string from a tool call's parsed arguments, else ''. */ diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index d35cfe7993..bac95b7eed 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -13,6 +13,7 @@ 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' /** @@ -42,6 +43,7 @@ function writeHooks(dir: string, hooks: unknown): void { async function harness(dir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise { 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 }) @@ -182,6 +184,7 @@ 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 }) @@ -205,6 +208,7 @@ 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 }) @@ -227,12 +231,12 @@ describe('hooks-codex bridge', () => { it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => { expect('default' in HooksCodex).toBe(false) expect(HooksCodex.name).toBe('hooks-codex') - expect(HooksCodex.inject).toEqual(['shell']) + expect(HooksCodex.inject).toEqual(['shell', 'sessionProjections']) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(HooksCodex) as Record expect(unwrapped).toBe(HooksCodex) expect(unwrapped.name).toBe('hooks-codex') - expect(unwrapped.inject).toEqual(['shell']) + expect(unwrapped.inject).toEqual(['shell', 'sessionProjections']) expect(typeof unwrapped.apply).toBe('function') }) }) diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 53742e2e96..214b8484ea 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -13,6 +13,7 @@ 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 @@ -31,6 +32,7 @@ type HarnessOpts = { stderrSummaryMaxChars?: number; sessionRoot?: string } async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise { 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) @@ -314,6 +316,7 @@ 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 }) @@ -624,6 +627,7 @@ 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 }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 5826f75f75..d1c57e6738 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 607cd4e4176631b64daf4a298b5d86a75ccdce68 -README.zh.md: cdfb5aa65b3ad5b00596487aae6c99e2f9d4e433 +README.md: febc0933270dfe03301bd13552d433bf141e6ce3 +README.zh.md: 9bd9a108b26c1c00257cbf307332b0e63da83c2d diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 607cd4e417..febc093327 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -26,7 +26,7 @@ Question responses are validated against their pending request before the first `session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only `compaction/summary` record on the same page as the replacement that cites it. -`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds no other domain's knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. The gateway owns two units: `sessionListMetadata` caches the monotonic blank-to-nonblank transition and latest human prompt time used by `session.list`, while `imageLimits` publishes the attachments config enforced at prompt admission as a per-boot constant (`apply` keeps the state reference, so baselines alone carry it — no change frames) so clients can refuse an over-limit intake before submit and label upload affordances; the latter activates only while both the registry and the attachments service are composed. +`session.history`'s tail page (`beforeSeq` absent) additionally carries a `projections` block — the watermark snapshot of every client-visible unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). `ApiProxyService` requires the registry; only the lower-level `createApiProxy` factory may omit the block and frames for isolated tests and diagnostics. The carrier holds no other domain's knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block. The gateway owns two units: `sessionListMetadata` caches the monotonic blank-to-nonblank transition and latest human prompt time used by `session.list`, while `imageLimits` publishes the attachments config enforced at prompt admission as a per-boot constant (`apply` keeps the state reference, so baselines alone carry it — no change frames) so clients can refuse an over-limit intake before submit and label upload affordances; the latter activates only while both the registry and the attachments service are composed. Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`, and every image any included log references under `media/.` (read and verified from the attachment store; a shared image appears once). `HEAD` runs the same root preparation and returns its status and headers without a response body, so browser clients can detect pre-stream failures before handing the GET to the native download manager. Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API at validated `sessionExportCompressionLevel` 0–9 (default 6), so deployments can trade CPU and latency against archive size; the response is chunked as it is produced and the host never holds the whole archive in one buffer. Once the response queue reaches its 64 KiB byte high-water mark, production waits until consumer pull restores positive capacity; fflate's synchronous callback can overshoot that bound only by the output of one bounded input push. Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index cdfb5aa65b..9bd9a108b2 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -26,7 +26,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 `session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent,然后按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志 `compaction/summary` 记录与引用它的替换留在同一页。 -`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有其他领域的知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。网关拥有两个单元:`sessionListMetadata` 缓存用于 `session.list` 的单调 blank→nonblank 转换与最新真人 prompt 时间;`imageLimits` 则把 prompt 准入时执行的 attachments 配置作为每次启动恒定的值发布(`apply` 保持状态引用不变,因此只靠基线携带、绝不产生变更帧),供客户端在提交前拒绝超限的加入并给上传入口标注上限,后者仅在注册表与 attachments 服务同时组合时激活。 +`session.history` 的尾页(不带 `beforeSeq`)额外携带一个 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个客户端可见单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。`ApiProxyService` 要求该注册表;只有较低层的 `createApiProxy` factory 可在隔离测试和诊断中省略投影块与帧。载体不持有其他领域的知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块。网关拥有两个单元:`sessionListMetadata` 缓存用于 `session.list` 的单调 blank→nonblank 转换与最新真人 prompt 时间;`imageLimits` 则把 prompt 准入时执行的 attachments 配置作为每次启动恒定的值发布(`apply` 保持状态引用不变,因此只靠基线携带、绝不产生变更帧),供客户端在提交前拒绝超限的加入并给上传入口标注上限,后者仅在注册表与 attachments 服务同时组合时激活。 会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下,每个被任何包含的日志引用的图片放在 `media/.` 下(从附件存储读取并校验;共享图片只出现一次)。`HEAD` 会执行相同的根工件准备,并在没有响应 body 的情况下返回状态与响应头,使浏览器 Client 可以在把 GET 交给原生下载管理器前发现流式传输前的失败。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧使用 fflate 流式 Zip API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区。响应队列达到 64 KiB 字节高水位后,生产会等待 Consumer pull 恢复正容量;fflate 的同步回调最多只会让该界限多出一次有界输入 push 的输出。请求中止或响应 body 取消会停止血缘与工件工作、终止活跃压缩器,并继续按取消传播,而不会变成 HTTP 500。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 946e27ded3..8895834eae 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -77,12 +77,14 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 97ce024ec0..05f6485d44 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -883,15 +883,6 @@ function subagentPromptError( return err(request, { code: 'internal', message: 'subagent prompt failed', details: {} }) } -/** Stable RPC face of the missing projections capability, shared by every catalog read path. */ -function projectionsUnavailableError(): RpcError { - return { - code: 'internal', - message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', - details: {}, - } -} - /** Verify one address and mode against the complete direct-child catalog. */ async function catalogChild( ctx: Context, @@ -928,9 +919,6 @@ async function catalogChild( if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) { return { error: { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} } } } - if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') { - return { error: projectionsUnavailableError() } - } return { error: { code: 'internal', message: 'subagent catalog read failed', details: {} } } } } @@ -1370,31 +1358,30 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // the signal fired — never invoked, entry pending forever, zombie frame // on every mux replay. Settle synchronously instead of publishing. if (req.signal?.aborted === true) return Promise.resolve('cancelled') - // The audit pair `approval/asked` is already appended by the service + // The audit pair `approval/asked` is already folded by the service // before dispatch, but dispatch rides a microtask: parallel tool calls - // can append several asked events before any answerer runs. THIS - // request's event is therefore the newest asked event that is still - // undecided, unclaimed by another pending entry, and — when the ask - // names a call — carries the same callId. - const events = req.agent.session.events + // can add several pending records before any answerer runs. THIS + // request's record is therefore the newest one that is still unclaimed + // by another pending entry and — when the ask names a call — carries + // the same callId. + const pending = ctx.get('sessionProjections')?.stateOf(req.agent.session, 'approvalPending') const claimed = new Set() for (const entry of pendingApprovals.values()) claimed.add(entry.approvalId) - const decided = new Set() let approvalId: ApprovalRequestId | undefined - for (let i = events.length - 1; i >= 0; i -= 1) { - const event = events[i] as SessionEvent - if (event.type === 'approval/decided') { - decided.add(event.data.id) - } else if (event.type === 'approval/asked') { - if (decided.has(event.data.id) || claimed.has(event.data.id)) continue + let bestSeq = -1 + if (pending !== undefined) { + for (const [id, record] of Object.entries(pending)) { + if (claimed.has(id as ApprovalRequestId)) continue // Symmetric pairing: a callId-bearing ask only takes its own call's // record, and a callId-less ask only takes a callId-less record — // so neither shape can steal the other's audit id under parallel // asks. (Today every producer — the tool executor — passes callId; // the callId-less arm guards any future non-tool asker.) - if ((req.callId ?? null) !== (event.data.callId ?? null)) continue - approvalId = event.data.id - break + if ((req.callId ?? null) !== record.callId) continue + if (record.seq > bestSeq) { + bestSeq = record.seq + approvalId = id as ApprovalRequestId + } } } // No asked event means the request bypassed the service's audit path — @@ -2570,9 +2557,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: {}, }) } - if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') { - return err(request, projectionsUnavailableError()) - } return err(request, { code: 'internal', message: 'subagent catalog read failed', diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index ac6c770801..c8b50e2c01 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -68,8 +68,8 @@ export interface Config { */ export class ApiProxyService extends Service implements ApiProxy { static inject = [ - 'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery', - 'tools', 'userQuestions', 'workspaceRegistry', + 'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'sessionProjections', + 'subagents', 'sessionQuery', 'tools', 'userQuestions', 'workspaceRegistry', ] static Config: z = z.object({ diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts index 88ab5ede42..823f638fe9 100644 --- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts @@ -11,6 +11,8 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import UserQuestionService from '@deepseek-ai/dsh-user-questions' import ApprovalService from '@deepseek-ai/dsh-user-approval' @@ -23,6 +25,10 @@ import { createApiProxy } from '../src/api-proxy.ts' async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + // No loop in this bench: register the turnBoundary unit so the approval + // service's turn-enclosure gate sees the open turn. + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(UserQuestionService) await ctx.plugin(AgentRegistry) @@ -211,6 +217,8 @@ describe('approval pending registry', () => { // effect while an ask is still pending. const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(UserQuestionService) await ctx.plugin(AgentRegistry) diff --git a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts index 28eaae1b0b..36c131ebe2 100644 --- a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' @@ -30,6 +31,7 @@ function request

(payload: P): RpcRequest

{ async function composed(withTitles = true): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) if (withTitles) { diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index a11e2a0416..ec3df497d7 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -233,33 +233,6 @@ describe('subagent gateway', () => { expect(inspect).not.toHaveBeenCalled() }) - it('maps the missing projections capability to one wire face on list, history, and prompt', async () => { - const listError = () => new SubagentError( - 'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', - 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE', - ) - const expected = { - code: 'internal', - message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', - } - - const list = bench({ listError: listError() }) - expect((await list.api.subagents.list(request({ parentSessionId: PARENT }))).result) - .toMatchObject({ ok: false, error: expected }) - - const history = bench({ listError: listError() }) - expect((await history.api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', - }))).result).toMatchObject({ ok: false, error: expected }) - expect(history.inspect).not.toHaveBeenCalled() - - const prompt = bench({ listError: listError() }) - expect((await prompt.api.subagents.prompt(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [], - }), new AbortController().signal)).result).toMatchObject({ ok: false, error: expected }) - expect(prompt.followup).not.toHaveBeenCalled() - }) - it('routes human content through the exact live parent with rpc attribution', async () => { const { api, parent, followup } = bench() const content = [{ type: 'text' as const, text: '继续' }] diff --git a/packages/interaction/permission-presets/README.i18n.yaml b/packages/interaction/permission-presets/README.i18n.yaml index 7e7359526c..072babf07b 100644 --- a/packages/interaction/permission-presets/README.i18n.yaml +++ b/packages/interaction/permission-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/interaction/permission-presets/README.md -README.md: 2b671f9e6e835c529453dc7d4ca7bc2eff01f2b6 -README.zh.md: 686138c1f2b0b3770771d0e6d6a785ed17cc8621 +README.md: a59b10809c4c93cc5d4b4c3715a17b4661f9e9fe +README.zh.md: 2e215ca7785c1b08f1bb4c8607aa5e34cf8deb9b diff --git a/packages/interaction/permission-presets/README.md b/packages/interaction/permission-presets/README.md index 2b671f9e6e..a59b10809c 100644 --- a/packages/interaction/permission-presets/README.md +++ b/packages/interaction/permission-presets/README.md @@ -4,13 +4,13 @@ English | [中文](README.zh.md) User-facing permission presets through `ctx.permissionPresets` ([`PermissionPresetService`](src/index.ts)). Each configured name bundles `sandbox/mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). UI adapters may expose the table as one selector, while sandbox execution and approval continue to consume their own knobs. -`set(session, name)` records a changed selection in a log-only `permissionPresets/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. +`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(session)` reads the required `permissions` projection state, prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. The service owns the `permissionPresets` Settings namespace. Its `defaultPreset` is the default for future sessions: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. A committed Settings change is read when the next session is created; creation pins `permissionPresets/preset`, `sandbox/mode`, and `approval/policy` into that session, so later changes never alter an existing session. A resumed seed, including an explicitly empty one marked by `session/end-seed`, preserves its effective permission and receives only missing durable facts rather than the latest user default. Mounting the service also sweeps already-live sessions, so an HMR replacement pins any session created while the plugin was absent. The service requires a confining `ctx.shell` executor and `ctx.approval`. A table entry named `custom` throws at load. When composition defaults match no preset, the plugin requires an explicit `defaultPreset`; an independently constructed zero-event session may still derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). -Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permissionPresets` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed. +The required `permissions` session-projection unit folds the three whole-value knob events and exposes the full knob state to host readers through `stateOf()`; its client view is the select over composition defaults, with table options plus a current-only `custom`. The optional `/permissionPresets` command activates when `ctx.commands` is composed; bare invocation reports the current preset and table, and a preset argument switches through `set`. ## Model Experience diff --git a/packages/interaction/permission-presets/README.zh.md b/packages/interaction/permission-presets/README.zh.md index 686138c1f2..2e215ca778 100644 --- a/packages/interaction/permission-presets/README.zh.md +++ b/packages/interaction/permission-presets/README.zh.md @@ -4,13 +4,13 @@ 通过 `ctx.permissionPresets`([`PermissionPresetService`](src/index.ts))提供面向用户的权限预设。每个配置名称都会将 `sandbox/mode` 与 `approval/policy` 组成一组;默认项为 `workspace-write`(`workspace-write` + `ask`)和 `danger-full-access`(`danger-full-access` + `never`)。UI 适配器可以将该表作为单个选择器公开,而沙箱执行与审批仍分别消费各自的调节项。 -`set(session, name)` 会先在仅写日志的 `permissionPresets/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个预设共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 +`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个预设共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(session)` 读取必需的 `permissions` 投影状态,优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 该服务拥有 `permissionPresets` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permissionPresets/preset`、`sandbox/mode` 和 `approval/policy` 固定到该会话中,因此后续变更绝不会改变现有会话。恢复的 seed,包括由 `session/end-seed` 标记的显式空 seed,都会保留其有效权限,只补齐缺失的持久事实,而不会采用最新的用户默认值。挂载服务时还会遍历所有已存活会话,因此 HMR(热模块替换)会固定插件缺席期间创建的所有会话。 该服务要求存在具有约束能力的 `ctx.shell` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 -两个可选子功能在同一服务之上提供产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元以组合默认值为基础折叠三个全量值可调参数事件,并生成选择器视图,其中包含表内选项和仅作当前值的 `custom`)与 `/permissionPresets` 命令(不带参数调用时报告当前预设与表;预设参数经 `set` 切换)。每个子功能仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。 +必需的 `permissions` 会话投影单元折叠三个全量值 knob 事件,并通过 `stateOf()` 向 host 读取方提供完整 knob 状态;其客户端视图是基于组合默认值的选择器,包含表内选项与仅作当前值的 `custom`。可选的 `/permissionPresets` 命令在组合了 `ctx.commands` 时激活;不带参数调用会报告当前预设与表,预设参数经 `set` 切换。 ## 模型体验 diff --git a/packages/interaction/permission-presets/src/index.ts b/packages/interaction/permission-presets/src/index.ts index 7f06f6e8e6..d871e819c4 100644 --- a/packages/interaction/permission-presets/src/index.ts +++ b/packages/interaction/permission-presets/src/index.ts @@ -5,7 +5,7 @@ * and replay keep reading their knob folds. The preset event preserves user * intent when two presets share a bundle. The read side ships as the * `permissions` session projection; the write side ships as the - * `/permission` command — both optional children over the same service. + * `/permission` command. * * @module dsh-permission-presets */ @@ -15,22 +15,18 @@ import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' -import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import { SANDBOX_MODES, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' // Side-effect type import: declaration-merges `ctx.shell` (the capability fact // `sandboxMode` this service reads), without a value dependency on the seam. import type {} from '@deepseek-ai/dsh-shell' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' -import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import { APPROVAL_POLICIES, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' -// Type-only: resolves ctx.sessionProjections / ctx.commands for the optional children. +// Type-only: resolves ctx.sessionProjections and the optional command child. import type {} from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-commands' -import type { PermissionSelect, PresetOption } from './types.ts' +import type { KnobState, PermissionSelect, PresetOption } from './types.ts' -// The `permissions` projection-key declaration lives in src/types.ts (its one -// home); this re-export projects the type face onto the package root AND -// keeps the module edge in the emitted index.d.ts, so aggregate programs -// consuming the declarations still receive the SessionProjectionMap merge. export type * from './types.ts' declare module '@deepseek-ai/cordis' { @@ -44,7 +40,7 @@ declare module '@deepseek-ai/dsh-session/types' { /** * Records the selected preset as durable, log-only user intent. The knob * events follow in the same turn and control execution; this event stays - * out of the model transcript and lets {@link effectivePermissionPreset} + * out of the model transcript and lets the permission projection unit * preserve a selection when bundles match. */ 'permission/preset': { preset: string } @@ -72,48 +68,19 @@ export const CUSTOM_PRESET = 'custom' /** Settings namespace carrying the default for future sessions. */ export const PERMISSION_SETTINGS_NAMESPACE = settingsNamespace('permission') -/** - * Fold the last selected preset from the durable log; replay needs no catch-up - * state. - * @param events - session events in log order; other event types are ignored. - * @returns the last selected preset, or undefined when none was recorded. - */ -export function effectivePermissionPreset(events: readonly SessionEvent[]): string | undefined { - for (let index = events.length - 1; index >= 0; index -= 1) { - const event = events[index] as SessionEvent - if (event.type === 'permission/preset') return event.data.preset - } - return undefined -} +const sandboxField = zod.union([ + zod.literal('read-only'), + zod.literal('workspace-write'), + zod.literal('danger-full-access'), +]).nullable() -/** - * The projection unit's state: the last seen value of each knob event, null - * before an override (composition defaults apply at view time). Plain JSON - * (persisted-cache precondition). - */ -export interface KnobState { - /** Last `permission/preset` payload, or null. */ - preset: string | null - /** Last `sandbox/mode` payload, or null. */ - sandbox: SandboxMode | null - /** Last `approval/policy` payload, or null. */ - approval: ApprovalPolicy | null -} - -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - permissions: KnobState - } -} +const approvalField = zod.union([zod.literal('ask'), zod.literal('never')]).nullable() const knobStateSchema: zod.ZodType = zod.object({ + /** Last `permission/preset` payload, or null. */ preset: zod.string().nullable(), - sandbox: zod.union([ - zod.literal('read-only'), - zod.literal('workspace-write'), - zod.literal('danger-full-access'), - ]).nullable(), - approval: zod.union([zod.literal('ask'), zod.literal('never')]).nullable(), + sandbox: sandboxField, + approval: approvalField, }) /** State for the empty log: every knob at its composition default. */ @@ -139,13 +106,6 @@ export function applyKnobEvent(state: KnobState, event: SessionEvent): KnobState } } -/** Whole-log knob fold (the cold-read parallel of {@link applyKnobEvent}). */ -function foldKnobs(events: readonly SessionEvent[]): KnobState { - let state = EMPTY_KNOBS - for (const event of events) state = applyKnobEvent(state, event) - return state -} - /** User setting resolved when a new session receives its initial permission. */ export interface PermissionSettings { /** Preset pinned into a newly created session. */ @@ -193,7 +153,7 @@ export class PermissionPresetService extends Service { defaultPreset: z.string(), }) - static inject = ['shell', 'approval', 'sessions'] + static inject = ['shell', 'approval', 'sessions', 'sessionProjections'] private readonly presets: Record private defaultSettings: () => PermissionSettings @@ -240,10 +200,6 @@ export class PermissionPresetService extends Service { this.pinInitialPermission(session) } - // The permissions projection unit: fold the three whole-value knob - // events; view derives the select over the composition defaults this - // service already owns. The unit child activates only when a projection - // registry is composed (headless assemblies stay unaffected). // zod `.optional()` types the key `string | undefined` while the domain // says `description?: string`; on the JSON wire the two serialize // identically (absent), so the cast records exactly that @@ -256,15 +212,13 @@ export class PermissionPresetService extends Service { })), currentValue: zod.string().min(1), }) as unknown as zod.ZodType - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register<'permissions', KnobState>({ - key: 'permissions', - stateSchema: knobStateSchema, - init: () => EMPTY_KNOBS, - apply: applyKnobEvent, - wire: { viewSchema: selectSchema, view: state => this.selectFor(state) }, - stateVersion: 1, - }) + ctx.sessionProjections.register({ + key: 'permissions', + stateVersion: 1, + stateSchema: knobStateSchema, + init: () => EMPTY_KNOBS, + apply: applyKnobEvent, + wire: { viewSchema: selectSchema, view: state => this.selectFor(state) }, }) // The /permission command: the one write path a web client uses (the @@ -281,7 +235,7 @@ export class PermissionPresetService extends Service { handler: ({ agent, rawInput }) => { const name = rawInput.trim() if (name === '') { - return { kind: 'success', text: `current preset ${this.current(agent.session.events)} (available: ${this.names.join(', ')})` } + return { kind: 'success', text: `current preset ${this.current(agent.session)} (available: ${this.names.join(', ')})` } } if (!this.names.includes(name)) { return { kind: 'error', text: `unknown preset "${name}" (available: ${this.names.join(', ')})` } @@ -310,15 +264,19 @@ export class PermissionPresetService extends Service { return this.defaultSettings().defaultPreset } + private knobs(session: Session): KnobState { + return this.ctx.sessionProjections.stateOf(session, 'permissions') ?? EMPTY_KNOBS + } + /** * Resolve the preset matching the effective knob values. A still-matching * last selection wins shared-bundle ties; otherwise the first table match * wins, or {@link CUSTOM_PRESET} when no entry matches. - * @param events - the session's events in log order. + * @param session - the session whose knob state is read. * @returns the effective preset name, or `custom` when nothing matches. */ - current(events: readonly SessionEvent[]): string { - return this.derive(foldKnobs(events)) + current(session: Session): string { + return this.derive(this.knobs(session)) } /** Resolve the preset for one folded knob state (the shared mathematics of `current` and the projection unit). */ @@ -395,14 +353,14 @@ export class PermissionPresetService extends Service { /** Apply one preset with the caller-selected live or initialization policy writer. */ private apply(session: Session, name: string, setApproval: (policy: ApprovalPolicy) => void): void { const spec = this.resolve(name) - if (this.current(session.events) !== name) { + if (this.current(session) !== name) { session.append('permission/preset', { preset: name }) } - const events = session.events - if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.shell.sandboxMode)) { + const knobs = this.knobs(session) + if (spec.sandbox !== (knobs.sandbox ?? this.ctx.shell.sandboxMode)) { setSandboxMode(session, spec.sandbox) } - if (spec.approval !== (effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask')) { + if (spec.approval !== (knobs.approval ?? this.ctx.approval.config.policy ?? 'ask')) { setApproval(spec.approval) } } @@ -414,12 +372,12 @@ export class PermissionPresetService extends Service { * the missing durable facts. */ private pinInitialPermission(session: Session): void { - const events = session.events - const selected = effectivePermissionPreset(events) - const sandbox = effectiveSandboxMode(events) - const approval = effectiveApprovalPolicy(events) - const seeded = events.some(event => event.type === 'session/end-seed') - if (selected === undefined && sandbox === undefined && approval === undefined && !seeded) { + const state = this.knobs(session) + const selected = state.preset + const sandbox = state.sandbox + const approval = state.approval + const seeded = session.events.some(event => event.type === 'session/end-seed') + if (selected === null && sandbox === null && approval === null && !seeded) { const name = this.defaultPreset const spec = this.resolve(name) session.append('permission/preset', { preset: name }) @@ -428,19 +386,14 @@ export class PermissionPresetService extends Service { return } - const state: KnobState = { - preset: selected ?? null, - sandbox: sandbox ?? null, - approval: approval ?? null, - } const effective = this.derive(state) - if (selected === undefined && effective !== CUSTOM_PRESET) { + if (selected === null && effective !== CUSTOM_PRESET) { session.append('permission/preset', { preset: effective }) } - if (sandbox === undefined) { + if (sandbox === null) { setSandboxMode(session, this.ctx.shell.sandboxMode as SandboxMode) } - if (approval === undefined) { + if (approval === null) { setApprovalPolicy(session, this.ctx.approval.config.policy ?? 'ask') } } diff --git a/packages/interaction/permission-presets/src/types.ts b/packages/interaction/permission-presets/src/types.ts index a978e3b5e8..2c9ef57acc 100644 --- a/packages/interaction/permission-presets/src/types.ts +++ b/packages/interaction/permission-presets/src/types.ts @@ -2,13 +2,15 @@ * Pure types of the permission domain: the ONE home of the `permissions` * projection-key declaration plus its payload types, free of this package's * host-side value imports (cordis, schemastery). Two namespace projections - * serve it — the package root re-export for host consumers, `./client` (the - * browser half-entry's re-export) for client aggregates — with zero content - * duplication. + * serve it — the package root re-export for host consumers and `./client` for + * client aggregates — with zero content duplication. * * @module @deepseek-ai/dsh-permission-presets/types */ +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' + /** The select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */ export interface PresetOption { /** Stable option value: the table key, or `custom`. */ @@ -31,7 +33,21 @@ export interface PermissionSelect { currentValue: string } +/** Latest logged permission overrides. */ +export interface KnobState { + /** Last `permission/preset` payload, or null. */ + preset: string | null + /** Last `sandbox/mode` payload, or null. */ + sandbox: SandboxMode | null + /** Last `approval/policy` payload, or null. */ + approval: ApprovalPolicy | null +} + declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** Latest logged permission overrides. */ + permissions: KnobState + } interface SessionProjectionMap { /** * The session's permission select, folded from the three whole-value diff --git a/packages/interaction/permission-presets/tests/permission-presets.spec.ts b/packages/interaction/permission-presets/tests/permission-presets.spec.ts index 1f0e8760b8..251a917dde 100644 --- a/packages/interaction/permission-presets/tests/permission-presets.spec.ts +++ b/packages/interaction/permission-presets/tests/permission-presets.spec.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import PermissionPresetService, { - CUSTOM_PRESET, effectivePermissionPreset, PERMISSION_SETTINGS_NAMESPACE, + CUSTOM_PRESET, PERMISSION_SETTINGS_NAMESPACE, } from '@deepseek-ai/dsh-permission-presets' import type { Config } from '@deepseek-ai/dsh-permission-presets' import { SettingsProvider } from '@deepseek-ai/dsh-settings' @@ -32,6 +33,7 @@ async function mounted(options: { } = {}): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) ctx.provide('shell', { sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write', resolve() { throw new Error('permission tests do not execute bash') }, @@ -50,6 +52,7 @@ function freshSession(id: string): Session { async function mountedStore(options: { approvalDefault?: ApprovalPolicy | undefined } = {}): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(MemorySettings) ctx.provide('shell', { sandboxMode: 'workspace-write', @@ -64,16 +67,18 @@ async function mountedStore(options: { approvalDefault?: ApprovalPolicy | undefi return ctx } -describe('effectivePermissionPreset', () => { - it('folds to the last event, or undefined without one', () => { +describe('permission preset fold', () => { + it('folds the latest preset selection and steps over unrelated events', async () => { + const ctx = await mounted() const session = freshSession('sess-fold') - expect(effectivePermissionPreset(session.events)).toBeUndefined() + const presetOf = () => ctx.sessionProjections.stateOf(session, 'permissions')?.preset ?? null + expect(presetOf()).toBeNull() session.append('permission/preset', { preset: 'danger-full-access' }) session.append('permission/preset', { preset: 'workspace-write' }) - expect(effectivePermissionPreset(session.events)).toBe('workspace-write') - // The backward scan steps over non-preset events to the latest selection. + expect(presetOf()).toBe('workspace-write') + // The knob fold steps over non-preset events to the latest selection. session.append('sandbox/mode', { mode: 'read-only' }) - expect(effectivePermissionPreset(session.events)).toBe('workspace-write') + expect(presetOf()).toBe('workspace-write') }) }) @@ -88,18 +93,18 @@ describe('PermissionPresetService', () => { it('current() derives from the effective knobs: composition defaults hit workspace-write, a switch hits its preset', async () => { const ctx = await mounted() const session = freshSession('sess-current') - expect(ctx.permissionPresets.current(session.events)).toBe('workspace-write') + expect(ctx.permissionPresets.current(session)).toBe('workspace-write') ctx.permissionPresets.set(session, 'danger-full-access') - expect(ctx.permissionPresets.current(session.events)).toBe('danger-full-access') + expect(ctx.permissionPresets.current(session)).toBe('danger-full-access') }) it('a knob state matching no table entry derives custom — a state, not an error', async () => { const ctx = await mounted() const session = freshSession('sess-custom') session.append('sandbox/mode', { mode: 'read-only' }) - expect(ctx.permissionPresets.current(session.events)).toBe(CUSTOM_PRESET) + expect(ctx.permissionPresets.current(session)).toBe(CUSTOM_PRESET) ctx.permissionPresets.set(session, 'danger-full-access') - expect(ctx.permissionPresets.current(session.events)).toBe('danger-full-access') + expect(ctx.permissionPresets.current(session)).toBe('danger-full-access') expect(() => ctx.permissionPresets.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/) }) @@ -109,7 +114,7 @@ describe('PermissionPresetService', () => { config: { defaultPreset: 'workspace-write' }, }) const session = freshSession('sess-defaults-custom') - expect(ctx.permissionPresets.current(session.events)).toBe(CUSTOM_PRESET) + expect(ctx.permissionPresets.current(session)).toBe(CUSTOM_PRESET) }) it('the fold breaks bundle ties; a stale fold no longer matching falls back to table order', async () => { @@ -120,10 +125,10 @@ describe('PermissionPresetService', () => { } } }) const session = freshSession('sess-tie') ctx.permissionPresets.set(session, 'agentish') - expect(ctx.permissionPresets.current(session.events)).toBe('agentish') + expect(ctx.permissionPresets.current(session)).toBe('agentish') session.append('approval/policy', { policy: 'never' }) session.append('sandbox/mode', { mode: 'danger-full-access' }) - expect(ctx.permissionPresets.current(session.events)).toBe('danger-full-access') + expect(ctx.permissionPresets.current(session)).toBe('danger-full-access') }) it('set() writes through: one preset event plus both knob events', async () => { @@ -188,7 +193,7 @@ describe('PermissionPresetService', () => { const session = freshSession('sess-standin') ctx.permissionPresets.set(session, 'workspace-write') expect(session.events).toHaveLength(0) - expect(ctx.permissionPresets.current(session.events)).toBe('workspace-write') + expect(ctx.permissionPresets.current(session)).toBe('workspace-write') }) }) @@ -207,8 +212,8 @@ describe('new-session default', () => { }) expect(ctx.permissionPresets.defaultPreset).toBe('danger-full-access') const second = ctx.sessions.create(SessionId('second')) - expect(ctx.permissionPresets.current(first.events)).toBe('workspace-write') - expect(ctx.permissionPresets.current(second.events)).toBe('danger-full-access') + expect(ctx.permissionPresets.current(first)).toBe('workspace-write') + expect(ctx.permissionPresets.current(second)).toBe('danger-full-access') expect(second.events.map(event => event.type)).toEqual([ 'permission/preset', 'sandbox/mode', 'approval/policy', ]) @@ -223,7 +228,7 @@ describe('new-session default', () => { legacy.append('turn/start', { turn: 1 }) legacy.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.events }) - expect(ctx.permissionPresets.current(resumed.events)).toBe('workspace-write') + expect(ctx.permissionPresets.current(resumed)).toBe('workspace-write') expect(resumed.events.slice(-3).map(event => event.type)).toEqual([ 'permission/preset', 'sandbox/mode', 'approval/policy', ]) @@ -235,7 +240,7 @@ describe('new-session default', () => { defaultPreset: 'danger-full-access', }) const resumed = ctx.sessions.create(SessionId('empty-resumed'), { seed: [] }) - expect(ctx.permissionPresets.current(resumed.events)).toBe('workspace-write') + expect(ctx.permissionPresets.current(resumed)).toBe('workspace-write') expect(resumed.events.map(event => event.type)).toEqual([ 'session/end-seed', 'permission/preset', 'sandbox/mode', 'approval/policy', ]) @@ -244,6 +249,7 @@ describe('new-session default', () => { it('pins sessions that already exist when the service remounts', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) ctx.provide('shell', { sandboxMode: 'workspace-write', resolve() { throw new Error('permission tests do not execute bash') }, @@ -258,7 +264,7 @@ describe('new-session default', () => { expect(existing.events.map(event => event.type)).toEqual([ 'permission/preset', 'sandbox/mode', 'approval/policy', ]) - expect(ctx.permissionPresets.current(existing.events)).toBe('workspace-write') + expect(ctx.permissionPresets.current(existing)).toBe('workspace-write') }) it('fills only missing legacy facts and preserves an unmatched seeded combination', async () => { @@ -276,7 +282,7 @@ describe('new-session default', () => { custom.append('sandbox/mode', { mode: 'read-only' }) custom.append('approval/policy', { policy: 'never' }) const unmatched = ctx.sessions.create(SessionId('custom-resumed'), { seed: custom.events }) - expect(ctx.permissionPresets.current(unmatched.events)).toBe(CUSTOM_PRESET) + expect(ctx.permissionPresets.current(unmatched)).toBe(CUSTOM_PRESET) expect(unmatched.events.at(-1)?.type).toBe('session/end-seed') }) diff --git a/packages/interaction/permission-presets/tests/projection.spec.ts b/packages/interaction/permission-presets/tests/projection.spec.ts index f9bba33958..d9fa287b6b 100644 --- a/packages/interaction/permission-presets/tests/projection.spec.ts +++ b/packages/interaction/permission-presets/tests/projection.spec.ts @@ -60,10 +60,9 @@ describe('permissions projection unit', () => { changes.push({ key, value, seq }) }) ctx.permissionPresets.set(session, 'danger-full-access') - // set() appends preset + sandbox/mode + approval/policy: three knob transitions. - expect(changes).toHaveLength(3) - expect(changes.at(-1)).toMatchObject({ key: 'permissions', value: { currentValue: 'danger-full-access' } }) - // Unrelated event: same-reference apply, no notification. + const permissionChanges = changes.filter(change => change.key === 'permissions') + expect(permissionChanges).toHaveLength(3) + expect(permissionChanges.at(-1)).toMatchObject({ key: 'permissions', value: { currentValue: 'danger-full-access' } }) session.append('turn/start', { turn: 1 }) expect(changes).toHaveLength(3) }) @@ -80,7 +79,8 @@ describe('permissions projection unit', () => { const { ctx, session } = await harness({ withPermission: false }) expect('permissions' in ctx.sessionProjections.snapshot(session).values).toBe(false) const fiber = await ctx.plugin(PermissionPresetService, {}) - expect(ctx.sessionProjections.snapshot(session).values.permissions).toMatchObject({ currentValue: 'workspace-write' }) + expect(ctx.sessionProjections.snapshot(session).values.permissions) + .toMatchObject({ currentValue: 'workspace-write' }) await fiber.dispose() expect('permissions' in ctx.sessionProjections.snapshot(session).values).toBe(false) }) @@ -92,7 +92,7 @@ describe('/permission command', () => { const { agent, inject } = await agentFor(ctx, session) const execution = await ctx.commands.execute(agent, '/permission danger-full-access', [], new AbortController().signal) expect(execution?.result).toEqual({ kind: 'success', text: 'preset danger-full-access' }) - expect(ctx.permissionPresets.current(session.events)).toBe('danger-full-access') + expect(ctx.permissionPresets.current(session)).toBe('danger-full-access') expect(inject.mock.calls[0]?.[0]).toMatchObject({ content: [{ type: 'text', diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index 06f72c1e2f..3eb84ad80e 100644 --- a/packages/interaction/user-approval/package.json +++ b/packages/interaction/user-approval/package.json @@ -44,10 +44,12 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -57,6 +59,8 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^" } } diff --git a/packages/interaction/user-approval/src/index.ts b/packages/interaction/user-approval/src/index.ts index b0618f6b3d..51712ecc1c 100644 --- a/packages/interaction/user-approval/src/index.ts +++ b/packages/interaction/user-approval/src/index.ts @@ -7,11 +7,13 @@ import { randomUUID } from 'node:crypto' import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { z as zod } from 'zod' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/cordis' { @@ -31,6 +33,33 @@ declare module '@deepseek-ai/cordis' { } } +const approvalPolicyStateSchema = zod.union([ + zod.literal('ask'), + zod.literal('never'), +]).nullable() + +type ApprovalPolicyState = zod.infer + +const approvalPendingStateSchema = zod.record(zod.string(), zod.object({ + callId: zod.string().nullable(), + seq: zod.number().int().nonnegative(), +})) + +type ApprovalPendingState = zod.infer + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** Last logged approval-policy override, or null before one (composition default applies at read time). */ + approvalPolicy: ApprovalPolicyState + /** + * Outstanding `approval/asked` records not yet resolved by their + * `approval/decided` — the audit-pair pending set (the carrier's + * approval/request correlation reads it). + */ + approvalPending: ApprovalPendingState + } +} + declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** @@ -60,7 +89,8 @@ declare module '@deepseek-ai/dsh-session/types' { * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy * from the runtime-context snapshot and live switch notices). The LAST - * such event is the session's override ({@link effectiveApprovalPolicy}). + * such event is the session's override (folded by the approvalPolicy + * projection unit). * `source: 'delegation'` marks an override seeded into a child; an absent * source is a runtime switch. */ @@ -101,38 +131,6 @@ const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions t /** Model-facing statement for an interactive policy that may still fail closed. */ const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.' -/** - * The session's approval-policy override: the last `approval/policy` event in - * the log, or undefined when the session never switched (callers apply the - * plugin's configured default). The pure fold — resume needs no catch-up - * machinery because replaying the log IS the state. - * @param events - session events in log order (other event types are skipped). - * @returns the policy of the last switch event, or undefined without one. - */ -export function effectiveApprovalPolicy(events: readonly SessionEvent[]): ApprovalPolicy | undefined { - for (let index = events.length - 1; index >= 0; index -= 1) { - const event = events[index] as SessionEvent - if (event.type === 'approval/policy') return event.data.policy - } - return undefined -} - -/** - * Whether the log currently sits inside an open turn (a `turn/start` not yet - * closed by a `turn/end`) — the {@link ApprovalService.request} precondition. - * The audit pair must be turn-enclosed: the turn is the durable log's - * commit/replay boundary, so a bare event appended between turns is - * indistinguishable from a crash tail and silently dropped on reload. - */ -function hasOpenTurn(events: readonly SessionEvent[]): boolean { - for (let index = events.length - 1; index >= 0; index -= 1) { - const type = (events[index] as SessionEvent).type - if (type === 'turn/start') return true - if (type === 'turn/end') return false - } - return false -} - /** * Append the sole durable representation of a session policy override. Invalid * values throw before the log changes; consumers fold the new value on each read. @@ -194,11 +192,39 @@ export class ApprovalService extends Service { policy: z.union(['ask', 'never'] as const).default('ask'), }) + static inject = ['sessionProjections'] + constructor(ctx: Context, public config: Config) { super(ctx, 'approval') const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent.session) + ctx.sessionProjections.register({ + key: 'approvalPolicy', + stateVersion: 1, + stateSchema: approvalPolicyStateSchema, + init: () => null, + apply: (state, event) => (event.type === 'approval/policy' ? event.data.policy : state), + }) + ctx.sessionProjections.register({ + key: 'approvalPending', + stateVersion: 1, + stateSchema: approvalPendingStateSchema, + init: () => ({}), + apply: (state, event) => { + if (event.type === 'approval/asked') { + if (state[event.data.id] !== undefined) return state + return { ...state, [event.data.id]: { callId: event.data.callId ?? null, seq: event.seq } } + } + if (event.type === 'approval/decided') { + if (state[event.data.id] === undefined) return state + const { [event.data.id]: _decided, ...next } = state + return next + } + return state + }, + }) + // The complete current value travels after retained history, so switching // policy does not rewrite the stable system-prompt cache prefix. ctx.inject(['systemPrompt'], (scope: Context) => { @@ -256,7 +282,7 @@ export class ApprovalService extends Service { */ async request(req: ApprovalRequest): Promise { const session = req.agent.session - if (!hasOpenTurn(session.events)) { + if ((this.ctx.sessionProjections.stateOf(session, 'turnBoundary')?.openTurn ?? null) === null) { throw new Error( 'approval.request() outside an open turn: the approval/asked + approval/decided audit pair ' + 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). ' @@ -276,7 +302,7 @@ export class ApprovalService extends Service { } /** - * The session's effective policy: its own `approval/policy` fold, else the + * The session's effective policy: its projected `approval/policy` state, else the * configured default (the schema already defaulted an omitted policy to * `'ask'`; the `??` only narrows the optional-input TYPE). * @param session - the exact accepted session whose policy applies. @@ -287,12 +313,12 @@ export class ApprovalService extends Service { } /** - * Read the session override without applying the configured default. + * Read the projected session override without applying the configured default. * @param session - session whose log supplies the override. * @returns the last logged policy, or `undefined` without one. */ overrideOf(session: Session): ApprovalPolicy | undefined { - return effectiveApprovalPolicy(session.events) + return this.ctx.sessionProjections.stateOf(session, 'approvalPolicy') ?? undefined } /** diff --git a/packages/interaction/user-approval/tests/approval.spec.ts b/packages/interaction/user-approval/tests/approval.spec.ts index 3271ecc7a5..906551f229 100644 --- a/packages/interaction/user-approval/tests/approval.spec.ts +++ b/packages/interaction/user-approval/tests/approval.spec.ts @@ -6,8 +6,14 @@ import { carrierKeyOf, createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ApprovalService, { ApprovalOutcome, ApprovalRequest, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import ApprovalService, { ApprovalOutcome, ApprovalRequest, ApprovalRequestId, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' + +function registerTurnBoundary(ctx: Context): void { + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) +} /** * A minimal Agent stand-in — the service only reaches `agent.session.append` @@ -15,7 +21,10 @@ import ApprovalService, { ApprovalOutcome, ApprovalRequest, effectiveApprovalPol * turn-enclosure precondition); pass `seed` to stage idle/closed logs. * Returns the recorded audit appends alongside the fake. */ -function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { type: 'user/message' }]): { agent: Agent; appended: Array<{ type: string; data: Record }> } { +function fakeAgent(seed: Array<{ type: string; data?: Record; seq?: number }> = [ + { type: 'turn/start', seq: 0, data: { turn: 1 } }, + { type: 'user/message', seq: 1, data: {} }, +]): { agent: Agent; appended: Array<{ type: string; data: Record }> } { const appended: Array<{ type: string; data: Record }> = [] const agent = { session: { @@ -31,6 +40,8 @@ function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { ty async function mounted(): Promise { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(ApprovalService) return ctx } @@ -50,7 +61,10 @@ describe('ApprovalService.request', () => { it('throws between turns — a closed turn does not satisfy the enclosure precondition', async () => { const ctx = await mounted() - const { agent, appended } = fakeAgent([{ type: 'turn/start' }, { type: 'turn/end' }]) + const { agent, appended } = fakeAgent([ + { type: 'turn/start', seq: 0, data: { turn: 1 } }, + { type: 'turn/end', seq: 1, data: { turn: 1, reason: { kind: 'completed' } } }, + ]) await expect(ctx.approval.request(requestOf(agent))).rejects.toThrow(/outside an open turn/) expect(appended).toHaveLength(0) @@ -116,6 +130,8 @@ describe('ApprovalService.request', () => { it('contains an approval/asked observer throw after append and still completes the pair', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(ApprovalService) const session = ctx.sessions.create(SessionId('asked-observer-throw')) session.append('turn/start', { turn: 1 }) @@ -139,6 +155,8 @@ describe('ApprovalService.request', () => { it('contains an approval/decided observer throw after append and still resolves', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(ApprovalService) const session = ctx.sessions.create(SessionId('decided-observer-throw')) session.append('turn/start', { turn: 1 }) @@ -164,7 +182,7 @@ describe('ApprovalService.request', () => { const failure = new Error('append failed before log growth') const agent = { session: { - events: [{ type: 'turn/start' }], + events: [{ type: 'turn/start', seq: 0, data: { turn: 1 } }], append: () => { throw failure }, }, } as unknown as Agent @@ -364,15 +382,45 @@ describe('approval policy (the approval/policy fold)', () => { return { agent, session } } - it('folds to the last event, or undefined without one', () => { + it('folds to the last event, or undefined without one', async () => { + const ctx = await mounted() const { session } = sessionAgent('sess-fold') - expect(effectiveApprovalPolicy(session.events)).toBeUndefined() + expect(ctx.approval.overrideOf(session)).toBeUndefined() setApprovalPolicy(session, 'never') setApprovalPolicy(session, 'ask') - expect(effectiveApprovalPolicy(session.events)).toBe('ask') + expect(ctx.approval.overrideOf(session)).toBe('ask') expect(session.events.at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } }) }) + it('folds the pending audit pair without double-counting duplicates', async () => { + const ctx = await mounted() + const { session } = sessionAgent('sess-pending') + const asked = session.append('approval/asked', { + id: ApprovalRequestId('pending-1'), + callId: CallId('call-1'), + toolName: 'echo', + }) + session.append('approval/asked', { + id: ApprovalRequestId('pending-1'), + callId: CallId('call-1'), + toolName: 'echo', + }) + const pending = ctx.sessionProjections.snapshot(session).values.approvalPending ?? {} + expect(pending).toEqual({ + 'pending-1': { callId: 'call-1', seq: asked.seq }, + }) + }) + + it('ignores a decided event without a pending asked record', async () => { + const ctx = await mounted() + const { session } = sessionAgent('sess-unknown-decide') + session.append('approval/decided', { + id: ApprovalRequestId('never-asked'), + outcome: 'rejected', + }) + expect(ctx.sessionProjections.snapshot(session).values.approvalPending).toEqual({}) + }) + it('rejects a policy outside the closed vocabulary before appending', () => { const append = vi.fn() const session = { append } as unknown as Session @@ -386,6 +434,8 @@ describe('approval policy (the approval/policy fold)', () => { // Direct construction bypasses the plugin schema (the SystemPrompt-test // precedent for covering a defaulted Config field's type-narrowing ??). const ctx = new Context() + new SessionProjectionRegistry(ctx) + registerTurnBoundary(ctx) const service = new ApprovalService(ctx, {}) const { agent } = sessionAgent('sess-bare-config') ctx.on('approval/request', () => Promise.resolve('allowed-once')) @@ -394,6 +444,8 @@ describe('approval policy (the approval/policy fold)', () => { it('contains an answerer that throws SYNCHRONOUSLY as unavailable', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(ApprovalService) const { agent } = sessionAgent('sess-syncthrow') ctx.on('approval/request', () => { throw new Error('sync bug') }) @@ -402,6 +454,8 @@ describe('approval policy (the approval/policy fold)', () => { it('a never config rejects deterministically without consulting any answerer', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(ApprovalService, { policy: 'never' }) const consulted = vi.fn() ctx.on('approval/request', (_req, next) => { consulted(); return next() }) @@ -416,6 +470,8 @@ describe('approval policy (the approval/policy fold)', () => { it('the gate decides FIRST even against an answerer registered before the service (prepend)', async () => { const ctx = new Context() ctx.on('approval/request', () => Promise.resolve('allowed-once')) + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(ApprovalService, { policy: 'never' }) const { agent } = sessionAgent('sess-gate-2') await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected') @@ -426,6 +482,8 @@ describe('approval policy (the approval/policy fold)', () => { // service could register — which is exactly why the 'never' decision lives inside request() // instead. This eager grant would bypass a listener-based gate and therefore must never run. const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(ApprovalService, { policy: 'never' }) const consulted = vi.fn() ctx.on('approval/request', () => { consulted(); return Promise.resolve('allowed-once') }, { prepend: true }) @@ -437,6 +495,8 @@ describe('approval policy (the approval/policy fold)', () => { it('a session override outranks the configured default, in both directions', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(ApprovalService, { policy: 'never' }) ctx.on('approval/request', () => Promise.resolve('allowed-once')) const { agent, session } = sessionAgent('sess-gate-3') @@ -450,6 +510,8 @@ describe('approval policy (the approval/policy fold)', () => { it('queues a live policy switch for the next model step', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(ApprovalService) const { agent, session } = sessionAgent('sess-policy-notice') const inject = vi.fn() @@ -458,7 +520,7 @@ describe('approval policy (the approval/policy fold)', () => { ctx.approval.setPolicy(liveAgent, 'never') ctx.approval.setPolicy(liveAgent, 'never') - expect(effectiveApprovalPolicy(session.events)).toBe('never') + expect(ctx.approval.overrideOf(session)).toBe('never') expect(inject).toHaveBeenCalledOnce() expect(inject.mock.calls[0]?.[0]).toMatchObject({ content: [{ @@ -472,6 +534,8 @@ describe('approval policy (the approval/policy fold)', () => { it('contributes the complete current ask or never policy as cache-safe context', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(ApprovalService) const askAgent = sessionAgent('sess-sect-ask').agent const { agent: neverAgent, session } = sessionAgent('sess-sect-never') @@ -487,6 +551,8 @@ describe('approval policy (the approval/policy fold)', () => { it('reflects the latest durable switch in cache-safe context and stays byte-stable while unchanged', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(ApprovalService) const { agent, session } = sessionAgent('sess-context-switch') const contextFor = async () => @@ -503,6 +569,8 @@ describe('approval policy (the approval/policy fold)', () => { it('disposes the runtime-context contribution with the service', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) const fiber = await ctx.plugin(ApprovalService) const { agent } = sessionAgent('sess-hmr-service-live') const contextFor = async () => diff --git a/packages/interaction/user-approval/tsconfig.json b/packages/interaction/user-approval/tsconfig.json index 2c8d26e1f6..7adfb948c7 100644 --- a/packages/interaction/user-approval/tsconfig.json +++ b/packages/interaction/user-approval/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../session/session-projection" } ] } diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 44386ce78b..27fcaf37ae 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -42,10 +42,12 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -64,6 +66,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 45db304059..95568bab0a 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -8,9 +8,10 @@ import { randomUUID } from 'node:crypto' import type { Context, Events } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { z as zod } from 'zod' import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent' import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-projection' import { RetryId } from './brand.ts' import type { LlmRetryEventData } from './types.ts' @@ -18,7 +19,7 @@ export type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts' export { RetryId } from './brand.ts' export const name = 'llm-retry' -export const inject = ['agents'] +export const inject = ['agents', 'sessionProjections'] /** This policy executor has no config; providers own `retryPolicy`. */ export type Config = Readonly> @@ -75,6 +76,10 @@ function retryPolicyKey(policy: ResolvedRetryPolicy): string { ]) } +function retryStateKey(provider: string, policyKey: string): string { + return JSON.stringify([provider, policyKey]) +} + function cancellableDelay(delayMs: number, signal: AbortSignal): Promise { if (signal.aborted) return Promise.resolve(false) return new Promise((resolve) => { @@ -96,7 +101,39 @@ function cancellableDelay(delayMs: number, signal: AbortSignal): Promise + +const llmRetryStateSchema: zod.ZodType = zod.record(zod.string(), zod.object({ + retry: zod.number().int().nonnegative(), + retryId: zod.string(), +})) as unknown as zod.ZodType +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** Retry state for the current step by provider and policy. */ + llmRetry: LlmRetryState + } +} + export function apply(ctx: Context, config: Config = {}, internals: RetryInternals = {}): void { + ctx.sessionProjections.register({ + key: 'llmRetry', + stateVersion: 2, + stateSchema: llmRetryStateSchema, + init: () => ({}), + apply: (state, event) => { + if (event.type === 'step/start' || event.type === 'turn/end') return Object.keys(state).length === 0 ? state : {} + if (event.type !== 'llm/retry') return state + const key = retryStateKey(event.data.provider, event.data.policyKey) + const entry = state[key] + if (entry?.retry === event.data.retry && entry.retryId === event.data.retryId) return state + return { ...state, [key]: { retry: event.data.retry, retryId: event.data.retryId } } + }, + }) validateConfig(config) const random = internals.random ?? Math.random const lifetime = new AbortController() @@ -179,17 +216,12 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna } const policyKey = retryPolicyKey(policy) - const priorPolicyRetry = agent.session.events.findLast((event): event is SessionEvent<'llm/retry'> => - event.type === 'llm/retry' - && event.data.turn === turn - && event.data.step === step - && event.data.provider === provider - && event.data.policyKey === policyKey, - ) - const previousRetry = priorPolicyRetry?.data.retry ?? 0 + const retryState = ctx.sessionProjections.stateOf(agent.session, 'llmRetry') + const previous = retryState?.[retryStateKey(provider, policyKey)] + const previousRetry = previous?.retry ?? 0 if (policy.mode === 'normal' && previousRetry >= policy.maxRetries) return next() const retry = previousRetry + 1 - const retryId = priorPolicyRetry?.data.retryId ?? RetryId(randomUUID()) + const retryId = previous?.retryId ?? RetryId(randomUUID()) let delayMs: number if (failure.providerRetryAfterMs !== undefined && Number.isFinite(failure.providerRetryAfterMs) diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index 47d6d2a812..0c6c9c6b6b 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -4,7 +4,10 @@ import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' import { createUserMessage, ProviderRequestId } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import AgentRegistry from '@deepseek-ai/dsh-agent' import * as RetryInvariant from '@deepseek-ai/dsh-llm-retry/invariant' +import * as retry from '../src/index.ts' import { RetryId } from '@deepseek-ai/dsh-llm-retry' import { providerForOpenStep } from '../src/history.ts' @@ -58,6 +61,28 @@ const always = { failure, } +describe('llm-retry projection fold', () => { + it('keeps one retry count per dispatch key across duplicate records', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(retry) + const session = ctx.sessions.create(SessionId('retry-dup')) + session.append('llm/retry', { turn: 1, step: 1, ...normal }) + const first = ctx.sessionProjections.stateOf(session, 'llmRetry') + session.append('llm/retry', { turn: 1, step: 1, ...normal }) + const second = ctx.sessionProjections.stateOf(session, 'llmRetry') + expect(second).toBe(first) + expect(second).toEqual({ + '["mock","normal-policy"]': { + retry: 1, + retryId: 'normal-retry-chain', + }, + }) + }) +}) + describe('llm-retry invariants', () => { it('has no provider without the requested open step or a route marker', () => { expect(providerForOpenStep([], 1, 1)).toBeUndefined() diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts index 367e8f46f4..b4bef4c239 100644 --- a/packages/llm/llm-retry/tests/loader-composition.spec.ts +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -11,6 +11,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import LlmRuntime, { createUserMessage, LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, ResolvedRetryPolicy, StreamChunk } 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 * as retry from '../src/index.ts' @@ -60,6 +61,7 @@ async function loadYaml(lines: readonly string[]): Promise { const modules = new Map([ ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], ['@deepseek-ai/dsh-agent', AgentRegistry], @@ -89,6 +91,7 @@ describe('real Loader composition', () => { const loaded = await loadYaml([ "- name: '@deepseek-ai/dsh-llm'", "- name: '@deepseek-ai/dsh-session'", + "- name: '@deepseek-ai/dsh-session-projection'", "- name: '@deepseek-ai/dsh-system-prompt'", "- name: '@deepseek-ai/dsh-tools'", "- name: '@deepseek-ai/dsh-agent'", diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index c2643a6d45..600700d9e3 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -12,6 +12,7 @@ import type { StreamChunk, } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session' import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -107,6 +108,7 @@ async function harness( const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1025,14 +1027,20 @@ describe('provider-routed retry policy', () => { it('rejects retry policy configured on the executor instead of a provider', () => { expectTypeOf<{}>().toExtend() expectTypeOf<{ retryPolicy: { mode: 'always' } }>().not.toExtend() + const ctx = new Context() + // `apply` registers its projection unit first; the registry is a required + // injection, so the direct-apply path must carry it too. + new SessionProjectionRegistry(ctx) expect(() => { - retry.apply(new Context(), { retryPolicy: { mode: 'always' } } as unknown as retry.Config) + retry.apply(ctx, { retryPolicy: { mode: 'always' } } as unknown as retry.Config) }).toThrow(/retryPolicy belongs under each provider/) }) it('rejects unknown executor config', () => { + const ctx = new Context() + new SessionProjectionRegistry(ctx) expect(() => { - retry.apply(new Context(), { retryPolciy: {} } as unknown as retry.Config) + retry.apply(ctx, { retryPolciy: {} } as unknown as retry.Config) }).toThrow(/unknown key "retryPolciy"/) }) }) diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index fb830f9868..7591f8fdf2 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -10,6 +10,7 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { MockLlmBehavior, MockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' import { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as Retry from '../src/index.ts' let context: Context | undefined @@ -37,6 +38,7 @@ 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, diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml index 44db6530cd..156c101f90 100644 --- a/packages/llm/token-meter/README.i18n.yaml +++ b/packages/llm/token-meter/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/token-meter/README.md -README.md: a2deab11a31285ba598b8864d3a734ecf7c56620 -README.zh.md: e0c3a8aa123ca0acc374277cd92b415e0b0b3cb5 +README.md: 26bf9d4bc89b5ea1be72bb7d51aa6e6859bfd075 +README.zh.md: f018d22cee94d631bdfcdc26a3aa328e5d91152b diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index a2deab11a3..26bf9d4bc8 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -23,7 +23,7 @@ Usage accounting sums disjoint input, cache-read, cache-write, and output bucket ## Session projections -When the composition provides `ctx.sessionProjections`, token-meter registers three units through an optional child fiber. +Token-meter requires `ctx.sessionProjections` and registers three units. `tokenUsage` carries the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. Usage chunks are counted even when a request later fails; a final assistant-message usage for the same `(turn, step)` replaces that sample instead of double-counting it. Reasoning remains an output subdivision. The single last-sample slot relies on a session-log ordering property: once a later step reports usage, a legal log never reports usage for an earlier step again. diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md index e0c3a8aa12..f018d22cee 100644 --- a/packages/llm/token-meter/README.zh.md +++ b/packages/llm/token-meter/README.zh.md @@ -23,7 +23,7 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成 ## 会话投影 -当组合提供 `ctx.sessionProjections` 时,token-meter 会通过一个可选子 fiber 注册三个单元。 +Token-meter 要求组合提供 `ctx.sessionProjections`,并注册三个单元。 `tokenUsage` 携带完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 和 `cacheWriteTokens`。即使请求随后失败,用量分片仍会计入;同一 `(turn, step)` 的最终 assistant 消息用量会替换该样本,而不是重复计数。推理仍是输出的一个细分项。只保留单个最新样本,依赖的是会话日志的一条顺序性质:一旦某个更晚的步骤报告了用量,合法日志就绝不会再为更早的步骤报告用量。 diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 2fa53f78f1..47df376a75 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -10,7 +10,6 @@ import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm' import type { Message, TokenUsage } from '@deepseek-ai/dsh-llm' import type { EpochHeader, Session, SessionEvent } from '@deepseek-ai/dsh-session' import { canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session' -// Type-only: resolves the optional projection registry Context declaration. import type {} from '@deepseek-ai/dsh-session-projection' import type { TokenMeasurement, @@ -24,6 +23,11 @@ import { estimateContent, estimateHeader, estimateMessage, ROLE_OVERHEAD } from import { foldSurfaceTokens } from './surface-fold.ts' export type * from './types.ts' +// Module-edge re-export: forces the emitted index.d.ts to import the +// projection-unit modules, so their SessionProjectionStateMap augmentations load +// in aggregate programs that only import the package root. +export type * from './usage-projection.ts' +export type * from './breakdown-projection.ts' interface MeasurementAnchor { readonly header: EpochHeader | undefined @@ -76,19 +80,17 @@ export class TokenMeter extends Service { // the public type excludes settings while validateConfigKeys rejects them. static Config: z = z.object({}) as unknown as z + static inject = ['sessionProjections'] + private readonly states = new WeakMap() constructor(ctx: Context, config: TokenMeterConfig = {}) { super(ctx, 'tokenMeter') validateConfigKeys(config) - // Projection registration is an optional child: compositions without the - // generic registry keep the meter's standalone read shape. - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register(tokenUsageProjectionDefinition) - projectionCtx.sessionProjections.register(contextPressureProjectionDefinition) - projectionCtx.sessionProjections.register(contextBreakdownProjectionDefinition) - }) + ctx.sessionProjections.register(tokenUsageProjectionDefinition) + ctx.sessionProjections.register(contextPressureProjectionDefinition) + ctx.sessionProjections.register(contextBreakdownProjectionDefinition) // Readers catch up independently, while eager observation bounds ordinary // read latency without creating state for sessions no consumer has read. diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index 56ba67ee80..d72ba7f1f6 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -8,7 +8,6 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeter from '@deepseek-ai/dsh-token-meter' import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client' import { CompactionId } from '@deepseek-ai/dsh-compaction' -import type {} from '../src/usage-projection.ts' const ZERO: TokenUsageProjection = { uncachedInputTokens: 0, diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index c12c5c248f..4d58cd9c94 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/plan/plan-mode/README.md -README.md: 3eabe2cb3f04b434b7f908f7beca869f1022a59e -README.zh.md: f7d6a1f8e9f5ba95f8aad9457f3dde5fc415fdcf +README.md: 76575d603279c699112feeac82344a504af05a87 +README.zh.md: deada84a896edb0f6a0e597d25cead0f347bf543 diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 3eabe2cb3f..76575d6032 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -6,7 +6,7 @@ Logged, per-agent plan collaboration state with deployment-owned guidance, direc ## Durable state -`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. `foldPlanMode(events)` returns the last logged value or `false`, so resume, fork, and compaction recover plan state directly from the session log. UIs observe committed flips through `session/event`. +`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. The required `plan` projection unit folds committed mode, command settlement, and the mode at the latest request header, so resume, fork, and compaction recover the state directly from the session log. `ctx.planMode.set(agent, active)` appends the standalone `plan/mode` event immediately when the agent is idle, because no in-turn pre-step runs before the next prompt. While the agent is running, it holds a pending selection for the next accepted in-turn pre-step. It returns which happened (`committed`/`queued`), a `cancelled` reversal, or a `noop`. `get(agent)` returns `{ active, pending? }`, separating the logged state used to assemble the current step from a user's mid-turn selection. Initial and continuation pre-steps both apply pending selections; a same-step request-recovery retry reuses its frozen assembly and leaves the selection pending for the next pre-step. A changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state (both commit paths). @@ -22,7 +22,7 @@ The Web client consumes the plugin-owned `/plan` command; other entry points may ## Session projection -When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. A `command/run` record named `plan` with recorded `args` starts a candidate target (`off` → inactive, anything else → active); its paired `command/done` retains a successful selection and drops an error; `plan/mode` commits the logged state and clears the retained selection. Every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an unsettled or successful selection differs from the logged state. This remains a pure replay quantity, so host restarts, other tabs, and cold reads recover it from the log alone, and a rejected `/plan off` with images cannot leave a pending exit. The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected. +This package requires `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)) and registers the `plan` projection unit. A `command/run` record named `plan` with recorded `args` starts a candidate target (`off` → inactive, anything else → active); its paired `command/done` retains a successful selection and drops an error; `plan/mode` commits the logged state and clears the retained selection; `request/header` records which mode assembled the latest request. Every other event returns the same state reference. Host logic reads the full state through `stateOf()`. The client `view` remains `{ active, pending }`, where `pending` is true only while an unsettled or successful selection differs from the logged state. The key merges into `SessionProjectionStateMap` for host state and `SessionProjectionMap` for the client view; carriers serve the latter on the history tail page and the `session/projection` push frame. ## Configuration diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index f7d6a1f8e9..deada84a89 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -6,7 +6,7 @@ ## 持久状态 -`plan/mode`(`{ active: boolean }`)是一个仅存在于日志中、每次以完整值替换的 `SessionEventMap` 成员。`foldPlanMode(events)` 返回最后记录的值,如果没有则返回 `false`,因此恢复、fork 和压缩(compaction)都能直接从会话日志恢复 plan 状态。UI 通过 `session/event` 观察已提交的切换。 +`plan/mode`(`{ active: boolean }`)是一个仅存在于日志中、每次以完整值替换的 `SessionEventMap` 成员。必需的 `plan` 投影单元折叠已提交模式、命令结算结果和最近一次请求头对应的模式,因此恢复、fork 和压缩(compaction)都能直接从会话日志恢复该状态。 `ctx.planMode.set(agent, active)` 会在 agent 空闲时立即追加独立的 `plan/mode` 事件,因为下一个提示词之前不会运行轮内 pre-step。agent 运行时,该方法会保留待生效选择,直到下一个被接受的轮内 pre-step。返回值区分 `committed`、`queued`、表示反转的 `cancelled` 和 `noop`。`get(agent)` 返回 `{ active, pending? }`,将用于组装当前步骤的日志状态与用户的轮中选择分开。初始与续步 pre-step 都会应用待生效选择;同一步骤的请求恢复重试会复用已冻结的 assembly,并将该选择保留到下一个被接受的轮内 pre-step。当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条提交路径皆然)。 @@ -22,7 +22,7 @@ Web 客户端使用该插件提供的 `/plan` 命令;其他入口可以直接 ## 会话投影 -当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会开始一个候选目标(`off` → 未激活,其余 → 激活);与它配对的 `command/done` 保留成功选择并丢弃错误选择;`plan/mode` 提交已记录状态并清除已保留的选择。其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true。该值仍完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它,被拒绝的带图 `/plan off` 也不会留下待退出状态。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。 +本包要求组合提供 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)),并注册 `plan` 投影单元。名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会开始一个候选目标(`off` → 未激活,其余 → 激活);与它配对的 `command/done` 保留成功选择并丢弃错误选择;`plan/mode` 提交已记录状态并清除已保留的选择;`request/header` 记录最近一次请求由哪种模式组装。其他任何事件都返回同一个状态引用。host 逻辑通过 `stateOf()` 读取完整状态。客户端 `view` 仍为 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true。该 key 分别合并到 host 状态的 `SessionProjectionStateMap` 与客户端视图的 `SessionProjectionMap`;载体通过历史尾页和 `session/projection` 推送帧提供后者。 ## 配置 diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index b7b94bf6cf..676baef697 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -6,8 +6,8 @@ * policy enforce restrictions independently and do not read or write plan * state. * - * The state in force is folded from the session log (`plan/mode`, last one - * wins), so resume and fork restore it without a live mirror. User selections + * The required `plan` projection folds the session log, so resume and fork + * restore the state without rescanning it in each consumer. User selections * remain pending until the next accepted in-turn pre-step. The service includes * the selected state in the proposed step assembly, then appends `plan/mode` * from `agent/pre-step` only when the step is accepted. Same-step request @@ -28,19 +28,14 @@ import { z as zod } from 'zod' import type { ZodType } from 'zod' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent, UserMessage } from '@deepseek-ai/dsh-session' +import type { Session, UserMessage } from '@deepseek-ai/dsh-session' import { defineTool } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-system-prompt' import { UserQuestionError } from '@deepseek-ai/dsh-user-questions' -// Type-only edge: resolves `ctx.commands` for the optional command child. import type {} from '@deepseek-ai/dsh-commands' -// Type-only: resolves ctx.sessionProjections for the optional unit child. import type {} from '@deepseek-ai/dsh-session-projection' -import type { PlanProjection } from './types.ts' -// The `plan` projection-key declaration lives in src/types.ts (its one home); -// this re-export projects the type face onto the package root AND keeps the -// module edge in the emitted index.d.ts, so aggregate programs consuming the -// declarations still receive the SessionProjectionMap merge. +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type { PlanProjection, PlanUnitState } from './types.ts' export type * from './types.ts' declare module '@deepseek-ai/dsh-session/types' { @@ -48,7 +43,7 @@ declare module '@deepseek-ai/dsh-session/types' { /** * Whether plan mode is in force from this point on: log-only, non-surface, * whole-value replace. The last `plan/mode` wins; a log with none folds to - * inactive through {@link foldPlanMode}. + * inactive through the projection unit's fold. */ 'plan/mode': { active: boolean } } @@ -118,49 +113,11 @@ export function resolveConfig(config: PlanModeConfig): PlanModeConfig { return { section } } -/** - * Whether plan mode is active after the first `end` events. The last - * `plan/mode` wins; a prefix with none is inactive. - * - * @param events The session log or any prefix of it. - * @param end Fold `events[0, end)`; defaults to the whole log. - * @returns Whether plan mode is active. - */ -export function foldPlanMode(events: readonly SessionEvent[], end = events.length): boolean { - let active = false - let index = 0 - for (const event of events) { - if (index >= end) break - index++ - if (event.type === 'plan/mode') active = event.data.active - } - return active -} - -/** - * Projection unit state: the logged mode, the latest successful `/plan` - * selection not yet resolved by a `plan/mode` commit, and an execution whose - * paired `command/done` has not settled. Plain JSON (persisted-cache - * precondition). - */ -interface PlanUnitState { - active: boolean - /** The selection's target mode; null when no selection is outstanding. */ - wanted: boolean | null - /** The latest plan command awaiting its paired settlement. */ - running: { commandId: string; wanted: boolean } | null -} - -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - plan: PlanUnitState - } -} - -const planUnitStateSchema: ZodType = zod.object({ +const planUnitStateSchema = zod.object({ active: zod.boolean(), wanted: zod.boolean().nullable(), running: zod.object({ commandId: zod.string(), wanted: zod.boolean() }).nullable(), + activeAtLastHeader: zod.boolean().nullable(), }) /** Wire payload schema of the `plan` projection. */ @@ -169,35 +126,48 @@ const planProjectionSchema: ZodType = zod.object({ pending: zod.boolean(), }) -/** Whether the log holds an opened turn without its closing `turn/end`. */ -function hasOpenTurn(events: readonly SessionEvent[]): boolean { - let open = false - for (const event of events) { - if (event.type === 'turn/start') open = true - else if (event.type === 'turn/end') open = false - } - return open -} - -/** Plan state at the last logged request header, or `undefined` before the first header. */ -function planModeAtLastHeader(events: readonly SessionEvent[]): boolean | undefined { - let lastHeader = -1 - let index = 0 - for (const event of events) { - if (event.type === 'request/header') lastHeader = index - index++ - } - if (lastHeader < 0) return undefined - return foldPlanMode(events, lastHeader + 1) -} +/** Projection of logged plan selections and committed mode. */ +export const planProjectionDefinition = { + key: 'plan', + stateVersion: 3, + stateSchema: planUnitStateSchema, + init: () => ({ active: false, wanted: null, running: null, activeAtLastHeader: null }), + apply: (state, event) => { + if (event.type === 'command/run' && event.data.name === 'plan') { + if (event.data.args === undefined) return state + const wanted = event.data.args.trim() !== 'off' + return { ...state, running: { commandId: event.data.commandId, wanted } } + } + if (event.type === 'command/done' && event.data.commandId === state.running?.commandId) { + const wanted = event.data.kind === 'success' && state.running.wanted !== state.active + ? state.running.wanted + : null + return { ...state, wanted, running: null } + } + if (event.type === 'plan/mode') { + return { ...state, active: event.data.active, wanted: null } + } + if (event.type === 'request/header') { + return { ...state, activeAtLastHeader: state.active } + } + return state + }, + wire: { + viewSchema: planProjectionSchema, + view: (state) => { + const wanted = state.running?.wanted ?? state.wanted + return { active: state.active, pending: wanted !== null && wanted !== state.active } + }, + }, +} satisfies ProjectionDefinition<'plan', PlanUnitState> /** * `ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, * the `plan:policy` section, the `/plan` command, and the stable exit tool. - * UIs observe committed flips through `session/event`; there is no live mirror. + * Client carriers expose the projection's cropped `{ active, pending }` view. */ export class PlanModeController extends Service { - static inject = ['tools', 'systemPrompt'] + static inject = ['tools', 'systemPrompt', 'sessionProjections'] /** Validated deployment-owned guidance. */ private readonly section: string @@ -243,50 +213,11 @@ export class PlanModeController extends Service { text: (context) => { if (context.agent === undefined) return '' const pending = this.pendingIntents.get(context.agent.session) - return (pending?.active ?? foldPlanMode(context.agent.session.events)) ? this.section : '' + return (pending?.active ?? this.loggedActive(context.agent.session)) ? this.section : '' }, }) - // The plan projection unit (session-projection RFC): a pure event fold - // serving clients the whole {active, pending} value. `command/run` - // records the user's logged /plan selection, its paired `command/done` - // keeps only successful selections, and `plan/mode` records that - // selection and clears it. Pending is thereby a pure - // replay quantity: host restarts, other tabs, and cold reads all recover - // it from the log alone. The unit child activates only when a projection - // registry is composed (headless assemblies stay unaffected). - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register<'plan', PlanUnitState>({ - key: 'plan', - stateSchema: planUnitStateSchema, - init: () => ({ active: false, wanted: null, running: null }), - apply: (state, event) => { - if (event.type === 'command/run' && event.data.name === 'plan') { - if (event.data.args === undefined) return state - const wanted = event.data.args.trim() !== 'off' - return { ...state, running: { commandId: event.data.commandId, wanted } } - } - if (event.type === 'command/done' && event.data.commandId === state.running?.commandId) { - const wanted = event.data.kind === 'success' && state.running.wanted !== state.active - ? state.running.wanted - : null - return { ...state, wanted, running: null } - } - if (event.type === 'plan/mode') { - return { ...state, active: event.data.active, wanted: null } - } - return state - }, - wire: { - viewSchema: planProjectionSchema, - view: (state) => { - const wanted = state.running?.wanted ?? state.wanted - return { active: state.active, pending: wanted !== null && wanted !== state.active } - }, - }, - stateVersion: 2, - }) - }) + ctx.sessionProjections.register(planProjectionDefinition) // The command child activates only when a command registry is composed. ctx.inject(['commands'], (commandCtx) => { @@ -311,7 +242,7 @@ export class PlanModeController extends Service { // Repeat the queued wording while an exit still awaits the // next accepted pre-step; only a truly inactive session reads // idempotent. - return foldPlanMode(agent.session.events) + return this.loggedActive(agent.session) ? { kind: 'success', text: 'Leaving plan mode (applies from the next step).' } : { kind: 'success', text: 'Plan mode is already inactive.' } } @@ -355,7 +286,7 @@ export class PlanModeController extends Service { execute: async (args, exec) => { const agent = exec.agent if (agent === undefined) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`) - if (!foldPlanMode(agent.session.events)) { + if (!this.loggedActive(agent.session)) { throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`) } if (!/^#\s+\S/.test(args.plan.trim())) { @@ -427,6 +358,19 @@ export class PlanModeController extends Service { })) } + private loggedActive(session: Session): boolean { + /* v8 ignore next -- plan-mode registers its own plan unit, so the key is always present */ + return this.ctx.sessionProjections.stateOf(session, 'plan')?.active ?? false + } + + private hasOpenTurn(session: Session): boolean { + return (this.ctx.sessionProjections.stateOf(session, 'turnBoundary')?.openTurn ?? null) !== null + } + + private loggedActiveAtLastHeader(session: Session): boolean | undefined { + return this.ctx.sessionProjections.stateOf(session, 'plan')?.activeAtLastHeader ?? undefined + } + /** * Read the logged plan state and any selected state awaiting the next * accepted in-turn pre-step. @@ -435,7 +379,7 @@ export class PlanModeController extends Service { * @returns Current logged state plus a pending selection, when present. */ get(agent: Agent): { active: boolean; pending?: boolean } { - const active = foldPlanMode(agent.session.events) + const active = this.loggedActive(agent.session) const pending = this.pendingIntents.get(agent.session) return pending === undefined ? { active } : { active, pending: pending.active } } @@ -459,15 +403,15 @@ export class PlanModeController extends Service { set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop' { const session = agent.session const pending = this.pendingIntents.get(session) - const target = pending?.active ?? foldPlanMode(session.events) + const target = pending?.active ?? this.loggedActive(session) if (active === target) return 'noop' - if (hasOpenTurn(session.events)) { + if (this.hasOpenTurn(session)) { this.pendingIntents.set(session, { active, narrate: true }) - return foldPlanMode(session.events) === active ? 'cancelled' : 'queued' + return this.loggedActive(session) === active ? 'cancelled' : 'queued' } // No open turn: commit now. Delete only after append succeeds so a // failed durable write leaves the selection retryable, not dropped. - if (active === foldPlanMode(session.events)) { + if (active === this.loggedActive(session)) { this.pendingIntents.delete(session) return 'cancelled' } @@ -483,7 +427,7 @@ export class PlanModeController extends Service { const pending = this.pendingIntents.get(session) if (pending === undefined) return const target = pending.active - if (target === foldPlanMode(session.events)) { + if (target === this.loggedActive(session)) { this.pendingIntents.delete(session) return } @@ -495,7 +439,7 @@ export class PlanModeController extends Service { /** Build a user-switch notice when the last logged header described the other mode. */ private narration(session: Session, target: boolean): UserMessage | undefined { - const told = planModeAtLastHeader(session.events) + const told = this.loggedActiveAtLastHeader(session) if (told === undefined || told === target) return const text = target ? 'The user switched this session to plan mode.' diff --git a/packages/plan/plan-mode/src/types.ts b/packages/plan/plan-mode/src/types.ts index 8efd32f22a..ffb19aee1e 100644 --- a/packages/plan/plan-mode/src/types.ts +++ b/packages/plan/plan-mode/src/types.ts @@ -1,9 +1,9 @@ /** * Pure types of the plan domain: the ONE home of the `plan` projection-key - * declaration, free of this package's host-side value imports (cordis - * service, dsh-tools, dsh-agent). Two namespace projections serve it — - * `./types` for host consumers, `./client` for client aggregates — with zero - * content duplication. + * declaration, free of this package's host-side value imports (cordis, + * dsh-tools, dsh-agent). Two namespace projections serve it — `./types` for + * host consumers and `./client` for client aggregates — with zero content + * duplication. * * @module @deepseek-ai/dsh-plan-mode/types */ @@ -21,7 +21,23 @@ export interface PlanProjection { pending: boolean } +/** Host state used to derive {@link PlanProjection}. */ +export interface PlanUnitState { + /** Logged plan mode. */ + active: boolean + /** The selection's target mode; null when no selection is outstanding. */ + wanted: boolean | null + /** The latest plan command awaiting its paired settlement. */ + running: { commandId: string; wanted: boolean } | null + /** Active state recorded by the latest `request/header`, or null. */ + activeAtLastHeader: boolean | null +} + declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** Host plan-mode fold state. */ + plan: PlanUnitState + } interface SessionProjectionMap { /** Plan collaboration state folded from the plan command lifecycle and `plan/mode` events. */ plan: PlanProjection diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 77efc3beb3..aa43e9ed1c 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -6,7 +6,9 @@ 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 PlanModeController, { foldPlanMode } from '@deepseek-ai/dsh-plan-mode' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import PlanModeController, { planProjectionDefinition } from '@deepseek-ai/dsh-plan-mode' +import type { PlanUnitState } from '@deepseek-ai/dsh-plan-mode/types' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' const PLAN_CONFIG = { section: 'Test plan mode instructions.' } @@ -23,6 +25,7 @@ async function harness(adapter: MockAdapter): Promise { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -51,6 +54,13 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { }) } +/** The logged-mode fold the plan projection unit serves: last `plan/mode` wins. */ +function foldPlanMode(events: readonly SessionEvent[]): boolean { + let state: PlanUnitState = planProjectionDefinition.init() + for (const event of events) state = planProjectionDefinition.apply(state, event) + return state.active +} + function findEvent( log: readonly SessionEvent[], type: T, diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 8285147953..3a1602430b 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools' -import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session' +import { Session, SessionId, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { createScope } from '@deepseek-ai/dsh-scope' import UserQuestionService, { @@ -11,8 +11,11 @@ import UserQuestionService, { } from '@deepseek-ai/dsh-user-questions' import CommandRuntime from '@deepseek-ai/dsh-commands' import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime' -import PlanModeController, { EXIT_PLAN_MODE, foldPlanMode, resolveConfig } from '../src/index.ts' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' +import PlanModeController, { EXIT_PLAN_MODE, planProjectionDefinition, resolveConfig } from '../src/index.ts' import type { PlanModeConfig } from '../src/index.ts' +import type { PlanUnitState } from '../src/types.ts' const TEST_PLAN_SECTION = 'Test plan mode instructions.' const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig @@ -65,8 +68,25 @@ function assembleFor(ctx: Context, agent: Agent) { return ctx.systemPrompt.assemble({ agent, scope: agent }) } +function foldPlanMode(events: readonly SessionEvent[], end = events.length): boolean { + let state: PlanUnitState = planProjectionDefinition.init() + let index = 0 + for (const event of events) { + if (index >= end) break + index++ + state = planProjectionDefinition.apply(state, event) + } + return state.active +} + +async function mountProjectionSeam(ctx: Context): Promise { + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) +} + async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise { const ctx = new Context() + await mountProjectionSeam(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(PlanModeController, config) @@ -282,6 +302,7 @@ describe('the boundary flush', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await mountProjectionSeam(ctx) const fiber = await ctx.plugin(PlanModeController, PLAN_CONFIG) const agent = await agentWithSession(ctx) openTurn(agent.session) @@ -434,6 +455,7 @@ describe('the soft layer', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await mountProjectionSeam(ctx) ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { const final = await next() final.tools = [...final.tools, { name: 'added-later', description: 'added after next()', parameters: {} }] @@ -461,6 +483,7 @@ describe('the soft layer', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime, { mode: 'code' }) await ctx.plugin(FakeRuntime) + await mountProjectionSeam(ctx) await ctx.plugin(PlanModeController, PLAN_CONFIG) registerNamedTools(ctx, ['read', 'write']) const agent = await agentWithSession(ctx, 'agent-1', { active: true }) @@ -482,6 +505,7 @@ describe('the soft layer', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime, { mode: 'both' }) await ctx.plugin(FakeRuntime) + await mountProjectionSeam(ctx) await ctx.plugin(PlanModeController, PLAN_CONFIG) registerNamedTools(ctx, ['read', 'write']) const agent = await agentWithSession(ctx, 'agent-1', { active: true }) @@ -503,6 +527,7 @@ describe('the soft layer', () => { await withPlanMode.plugin(SystemPrompt) await withPlanMode.plugin(ToolRuntime, { mode: 'code' }) await withPlanMode.plugin(FakeRuntime) + await mountProjectionSeam(withPlanMode) await withPlanMode.plugin(PlanModeController, PLAN_CONFIG) registerNamedTools(withPlanMode, ['read', 'write']) const agent = await agentWithSession(withPlanMode) @@ -716,6 +741,7 @@ describe('/plan', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(CommandRuntime) + await mountProjectionSeam(ctx) const fiber = await ctx.plugin(PlanModeController, PLAN_CONFIG) await new Promise(resolve => setImmediate(resolve)) const agent = await agentWithSession(ctx) @@ -862,6 +888,7 @@ describe('exit_plan_mode', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime, { mode: 'code' }) await ctx.plugin(ExitRuntime) + await mountProjectionSeam(ctx) await ctx.plugin(PlanModeController, PLAN_CONFIG) await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) @@ -1037,6 +1064,7 @@ describe('exit_plan_mode', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await mountProjectionSeam(ctx) const fiber = await ctx.plugin(PlanModeController, PLAN_CONFIG) await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) @@ -1101,6 +1129,7 @@ describe('HMR disposal', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await mountProjectionSeam(ctx) const fiber = await ctx.plugin(PlanModeController, PLAN_CONFIG) const agent = await agentWithSession(ctx, 'disposed-recovery') openTurn(agent.session) diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index e0ad20148c..e4f92cef03 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -1,14 +1,4 @@ -/** - * The `plan` projection unit (session-projection RFC's complete example): a - * event fold over the session log. `command/run` records named `plan` with - * recorded input set the candidate target (`off` → false, anything else → - * true); `command/done` keeps successful candidates and drops failures; - * `plan/mode` commits and clears a selection. `view` reports pending only while - * an outstanding selection differs from the logged state. - * Pending is thereby a pure replay quantity — a cold fold answers it without - * the service's in-memory intent. Composition without plan-mode has no `plan` - * key; unloading the fiber removes it (HMR safety). - */ +/** Plan projection behavior. */ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -48,20 +38,13 @@ async function harness(withPlanMode: boolean): Promise { } /** Append one logged /plan selection record (the executor's command/run shape). */ -function runPlanCommand(session: Session, args: string, index: number): CommandId { - const commandId = CommandId(`plan-proj-${String(index)}`) +function runPlanCommand(session: Session, args: string, index: number): void { session.append('command/run', { - commandId, + commandId: CommandId(`plan-proj-${String(index)}`), name: 'plan', args, source: { kind: 'user' }, }) - return commandId -} - -/** Append the paired settlement for one projected plan command. */ -function settlePlanCommand(session: Session, commandId: CommandId, kind: 'success' | 'error'): void { - session.append('command/done', { commandId, kind }) } /** Commit one plan/mode flip inside an open turn (the invariant's turn-enclosure rule). */ @@ -79,23 +62,15 @@ describe('plan projection unit', () => { it('a logged /plan selection reads pending until plan/mode records it', async () => { const bench = await harness(true) - const commandId = runPlanCommand(bench.session, '', 0) + runPlanCommand(bench.session, '', 0) expect(bench.values().plan).toEqual({ active: false, pending: true }) - settlePlanCommand(bench.session, commandId, 'success') + // A repeated identical selection returns the same state reference (no frame). + runPlanCommand(bench.session, '', 1) expect(bench.values().plan).toEqual({ active: false, pending: true }) commitPlanMode(bench.session, true, 0) expect(bench.values().plan).toEqual({ active: true, pending: false }) }) - it('drops a plan selection when its command settles with an error', async () => { - const bench = await harness(true) - commitPlanMode(bench.session, true, 0) - const commandId = runPlanCommand(bench.session, 'off', 0) - expect(bench.values().plan).toEqual({ active: true, pending: true }) - settlePlanCommand(bench.session, commandId, 'error') - expect(bench.values().plan).toEqual({ active: true, pending: false }) - }) - it('folds `off` args and non-plan commands correctly, and a matching selection is not pending', async () => { const bench = await harness(true) commitPlanMode(bench.session, true, 0) @@ -124,6 +99,28 @@ describe('plan projection unit', () => { expect(bench.values().plan).toEqual({ active: false, pending: true }) }) + it('freezes the active state across a request/header into activeAtLastHeader', async () => { + const bench = await harness(true) + commitPlanMode(bench.session, true, 0) + expect(bench.values().plan).toEqual({ active: true, pending: false }) + expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBeNull() + bench.session.append('request/header', { + header: { config: { provider: 'test', model: 'test-model' } }, + reason: 'initial', + }) + expect(bench.values().plan).toEqual({ active: true, pending: false }) + expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBe(true) + commitPlanMode(bench.session, false, 1) + expect(bench.values().plan).toEqual({ active: false, pending: false }) + expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBe(true) + bench.session.append('request/header', { + header: { config: { provider: 'test', model: 'test-model' } }, + reason: 'change', + }) + expect(bench.values().plan).toEqual({ active: false, pending: false }) + expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBe(false) + }) + it('has no plan key when plan-mode is not composed', async () => { const bench = await harness(false) expect('plan' in bench.values()).toBe(false) @@ -144,7 +141,7 @@ describe('plan projection unit', () => { // memory involved, the fold alone answers {active:false, pending:true}. const cold = await harness(true) for (const event of bench.session.events) { - if (event.type === 'command/run' || event.type === 'command/done' || event.type === 'plan/mode') { + if (event.type === 'command/run' || event.type === 'plan/mode') { cold.session.append(event.type, event.data) } } diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index a8f374d765..78c45d8d8e 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -64,6 +64,7 @@ "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-settings-file": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts index dda3644f55..6d26250fe1 100644 --- a/packages/preset/agent-presets/tests/invariant.spec.ts +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -7,6 +7,7 @@ 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 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' @@ -30,6 +31,7 @@ async function harness(roster: Partial = {}): Promise { await ctx.plugin(SystemPrompt, { persona: '' }) 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, includeUserRoot: false, ...roster }) await ctx.plugin(InvariantRegistry) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 824308e009..f5016d33df 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -9,6 +9,7 @@ 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 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 { beforeEach, describe, expect, it, vi } from 'vitest' @@ -48,6 +49,7 @@ async function harness(roster: Config = { default: 'standard', roots: ROOTS, inc await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentPresets, roster) return ctx @@ -417,6 +419,7 @@ describe('the preset file is an input, never a persistence target', () => { await scoped.plugin(SystemPrompt, { persona: '' }) 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 }], includeUserRoot: false }) @@ -582,6 +585,7 @@ describe('replacing a composition', () => { await scoped.plugin(SystemPrompt, { persona: '' }) 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 }], includeUserRoot: false }) const handle = await scoped.agents.create({ diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts index e3af9e1ec1..436b344e38 100644 --- a/packages/preset/agent-presets/tests/settings.spec.ts +++ b/packages/preset/agent-presets/tests/settings.spec.ts @@ -15,6 +15,7 @@ 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 SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import FileSettingsProvider from '@deepseek-ai/dsh-settings-file' @@ -46,6 +47,7 @@ async function harness( await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) const settingsFiber = ctx.plugin(FileSettingsProvider, { path: settingsFile, watch: false }) await settingsFiber diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index f12249398a..cce8fe1531 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-policy/README.md -README.md: c85420019b490d311360f310c04212ac6cd737d1 -README.zh.md: 58354adf55a55951cad1ea9adb88aea2457c7f7b +README.md: 2e83fb5ffebb024cb6d9dc94708520f4130d10e9 +README.zh.md: cc0e24febadaa2f0232f9fb10158790ff5a9af19 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index c85420019b..2e83fb5ffe 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -18,7 +18,7 @@ Filesystem tools, one-shot bash commands, and terminal sessions may enforce the - `ctx.sandboxPolicy.resolve({ session?, mode? })` — resolves one complete per-call policy. An explicit approved mode outranks the session's last `sandbox/mode` event, which outranks `defaultMode`; the session's immutable `cwd` is canonicalized with filesystem semantics before becoming `workspaceRoot`, otherwise the configured fallback applies. Canonicalization precedes lexical normalization so `symlink/..` agrees with process working-directory resolution. - `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default and fallback root used by `resolve()`. - `sandbox:policy` — a request-time cache-safe context contribution derived directly from `resolve({ session })`. It states the mode's capability-neutral file-effect contract and the canonical session workspace under `workspace-write`; tool owners retain operation-specific denial and escalation guidance. -- `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`), used inside `resolve()`. +- `overrideOf(session)` — the last `sandbox/mode` value from the required `sandboxMode` projection, or `undefined`, used inside `resolve()`. - `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band. - `SANDBOX_MODES` — every mode, for option advertisement and runtime validation. @@ -26,7 +26,7 @@ The optional `./invariant` companion rejects a forged durable `sandbox/mode` eve ## The per-session store -A runtime switch is one log-only `sandbox/mode` event on the session it applies to. `effective = explicit grant ?? fold(events) ?? deployment default`, so an override survives restart by replay and two sessions never see each other's state. Workspace identity does not need another event: the immutable `SessionHeader.cwd` recorded at creation is the root for every call in that session. The event stays log-only; before the next request, the owner contributes the current fact to the full runtime-context snapshot. +A runtime switch is one log-only `sandbox/mode` event on the session it applies to. `effective = explicit grant ?? projection state ?? deployment default`, so an override survives restart by replay and two sessions never see each other's state. Workspace identity does not need another event: the immutable `SessionHeader.cwd` recorded at creation is the root for every call in that session. The event stays log-only; before the next request, the owner contributes the current fact to the full runtime-context snapshot. ## Model Experience diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index 58354adf55..cc0e24feba 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -18,7 +18,7 @@ - `ctx.sandboxPolicy.resolve({ session?, mode? })`:解析一项完整的逐调用策略。显式批准的模式优先于会话最后一条 `sandbox/mode` 事件,后者又优先于 `defaultMode`;会话不可变的 `cwd` 会先按文件系统语义规范化,再成为 `workspaceRoot`,否则使用配置的回退值。规范化先于词法归一化,因此 `symlink/..` 与进程工作目录解析保持一致。 - `ctx.sandboxPolicy.defaultMode`/`ctx.sandboxPolicy.workspaceRoot`:`resolve()` 使用的部署默认值与回退根目录。 - `sandbox:policy`:直接派生自 `resolve({ session })` 的请求时缓存安全上下文贡献。它说明该模式中与具体能力无关的文件操作约定,以及 `workspace-write` 下规范化的会话工作区;工具归属方仍负责特定于操作的拒绝与升权引导。 -- `effectiveSandboxMode(events)`:会话 `sandbox/mode` 事件的纯 fold(最后一次切换胜出,没有则为 `undefined`),在 `resolve()` 内使用。 +- `overrideOf(session)`:从必需的 `sandboxMode` 投影读取最后一项 `sandbox/mode` 值,没有则为 `undefined`;在 `resolve()` 内使用。 - `setSandboxMode(session, mode)`:逐会话覆盖的唯一写入路径:恰好追加一条 `sandbox/mode` 事件。切换本身就是事件;不会在带外修改模式。 - `SANDBOX_MODES`:所有模式,用于选项展示与运行时验证。 @@ -26,7 +26,7 @@ ## 逐会话存储 -运行时切换是在对应会话日志中追加的一条 `sandbox/mode` 事件。`effective = explicit grant ?? fold(events) ?? deployment default`,因此覆盖会通过回放跨重启保留,两个会话也绝不会看到彼此状态。工作区标识无需另一条事件:创建时记录的不可变 `SessionHeader.cwd` 是该会话每次调用使用的根。该事件仍只进入日志;在下一次请求前,归属方会将当前事实贡献给完整运行时上下文快照。 +运行时切换是在对应会话日志中追加的一条 `sandbox/mode` 事件。`effective = explicit grant ?? projection state ?? deployment default`,因此覆盖会通过回放跨重启保留,两个会话也绝不会看到彼此状态。工作区标识无需另一条事件:创建时记录的不可变 `SessionHeader.cwd` 是该会话每次调用使用的根。该事件仍只进入日志;在下一次请求前,归属方会将当前事实贡献给完整运行时上下文快照。 ## 模型体验 diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index 3038e99718..21488a8275 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -37,10 +37,12 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -48,6 +50,7 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index eee5a43669..5f3fa72382 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -20,14 +20,15 @@ import { resolve as resolvePath } from 'node:path' import { Context, Service } from '@deepseek-ai/cordis' +import { z as zod } from 'zod' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent' import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { Session } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-system-prompt' -import { effectiveSandboxMode } from './session-mode.ts' -export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts' +export { SANDBOX_MODES, setSandboxMode } from './session-mode.ts' /** Resolve filesystem identity before lexical normalization can erase symlink-sensitive components. */ function resolveWorkspaceRoot(path: string): string { @@ -88,6 +89,22 @@ export interface SandboxPolicyRequest { * section. Tool layers call {@link resolve} for each execution so a session's * mode log and immutable cwd travel together to every enforcing capability. */ +/** The sandbox-mode projection's state schema (state equals the public shape). */ +const sandboxModeStateSchema = zod.union([ + zod.literal('read-only'), + zod.literal('workspace-write'), + zod.literal('danger-full-access'), +]).nullable() + +type SandboxModeState = zod.infer +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** Last logged sandbox-mode override, or null before one (deployment default applies at resolve time). */ + sandboxMode: SandboxModeState + } +} + +/** The sandbox policy seam: the deployment default mode and workspace-write root, with per-session overrides folded from the log. */ export class SandboxPolicyService extends Service { // Inline schema call: the config catalog walks `static Config` statically. static Config: z = z.object({ @@ -97,6 +114,8 @@ export class SandboxPolicyService extends Service { workspaceRoot: z.string(), }) + static inject = ['sessionProjections'] + /** The deployment default mode — the fallback beneath a session override. */ readonly defaultMode: SandboxMode /** The absolute `workspace-write` fallback root for calls without a session cwd. */ @@ -109,6 +128,14 @@ export class SandboxPolicyService extends Service { this.defaultMode = config.mode as SandboxMode this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot ?? process.cwd()) + ctx.sessionProjections.register({ + key: 'sandboxMode', + stateVersion: 1, + stateSchema: sandboxModeStateSchema, + init: () => null, + apply: (state, event) => (event.type === 'sandbox/mode' ? event.data.mode : state), + }) + ctx.inject(['systemPrompt'], (scope: Context) => { scope.systemPrompt.context({ name: 'sandbox:policy', @@ -147,7 +174,7 @@ export class SandboxPolicyService extends Service { * @returns the last logged mode, or `undefined` without one. */ overrideOf(session: Session): SandboxMode | undefined { - return effectiveSandboxMode(session.events) + return this.ctx.sessionProjections.stateOf(session, 'sandboxMode') ?? undefined } } diff --git a/packages/sandbox/sandbox-policy/src/session-mode.ts b/packages/sandbox/sandbox-policy/src/session-mode.ts index 165a462046..0bc7110efc 100644 --- a/packages/sandbox/sandbox-policy/src/session-mode.ts +++ b/packages/sandbox/sandbox-policy/src/session-mode.ts @@ -2,7 +2,7 @@ * Per-session sandbox-mode override: the session log as the store. A runtime * switch (a UI policy control or test scenario) is recorded as one * `sandbox/mode` event on the session it applies to; - * `effective = fold(events) ?? the deployment default`, so an override + * `effective = projection state ?? the deployment default`, so an override * survives restart by replay, two sessions can never see each other's state, * and there is no external config store. The event is log-only (the * `approval/*` precedent): the policy owner projects the fold into each model @@ -18,7 +18,7 @@ * @module dsh-sandbox-policy/session-mode */ -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' declare module '@deepseek-ai/dsh-session/types' { @@ -27,7 +27,7 @@ declare module '@deepseek-ai/dsh-session/types' { * The session's sandbox mode was switched — log-only (like `approval/*`; * NOT a surface event, carries no `surfaceOp`): durable and replayable, * never in the model transcript. The LAST such event is the session's - * override ({@link effectiveSandboxMode}). `source: 'delegation'` marks + * override (folded by the sandboxMode projection unit). `source: 'delegation'` marks * an override seeded into a child; an absent source is a runtime switch. */ 'sandbox/mode': { @@ -41,27 +41,11 @@ declare module '@deepseek-ai/dsh-session/types' { /** Every {@link SandboxMode}, for option advertisement and runtime validation of untrusted mode strings. */ export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-write', 'danger-full-access'] -/** - * The session's sandbox-mode override: the last `sandbox/mode` event in the - * log, or undefined when the session never switched (callers apply the - * deployment default). The pure fold — resume needs no catch-up machinery - * because replaying the log IS the state. - * @param events - session events in log order (other event types are skipped). - * @returns the mode of the last switch event, or undefined without one. - */ -export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMode | undefined { - for (let index = events.length - 1; index >= 0; index -= 1) { - const event = events[index] as SessionEvent - if (event.type === 'sandbox/mode') return event.data.mode - } - return undefined -} - /** * THE write path for a session's sandbox-mode override: appends exactly one * `sandbox/mode` event — the switch IS its event; nothing mutates mode state * out of band. Takes effect on the session's next confined call (bash or fs) - * — the consumers fold on every read. + * — consumers read the shared projection state. * @param session - the session the override belongs to. * @param mode - the mode every subsequent confined call in this session runs * under (until the next switch). diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 0a239114aa..86c9fa3905 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -11,11 +11,13 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import SandboxPolicyService, { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import SandboxPolicyService, { SANDBOX_MODES, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt, { renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt' async function mounted(config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access'; workspaceRoot?: string } = {}) { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, config) return ctx } @@ -125,6 +127,9 @@ describe('SandboxPolicyService', () => { it('rejects a mode outside the closed vocabulary at load', async () => { const ctx = new Context() + // Config validation runs when the fiber activates, and the policy seam + // requires the projection registry (mandatory injection) to activate. + await ctx.plugin(SessionProjectionRegistry) // schemastery rejects the union violation when the plugin loads. await expect(ctx.plugin(SandboxPolicyService, { mode: 'yolo' as never })).rejects.toThrow() }) @@ -132,6 +137,7 @@ describe('SandboxPolicyService', () => { it('disposes the service and context contribution from a child fiber (HMR safety)', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) + await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(SandboxPolicyService, {}) expect(ctx.sandboxPolicy).toBeDefined() expect(await policyContext(ctx, session('sess-hmr'))).toContain('read-only') @@ -145,6 +151,7 @@ describe('sandbox:policy request context', () => { async function promptMounted(config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access'; workspaceRoot?: string } = {}): Promise { const ctx = new Context() await ctx.plugin(SystemPrompt) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, config) return ctx } @@ -212,12 +219,13 @@ describe('the sandbox/mode session kit', () => { expect(SANDBOX_MODES).toEqual(['read-only', 'workspace-write', 'danger-full-access']) }) - it('effectiveSandboxMode folds to the last switch, or undefined without one', () => { + it('the sandboxMode projection folds to the last switch, or null without one', async () => { + const ctx = await mounted() const session = Session.create(SessionId('sess-fold')) - expect(effectiveSandboxMode(session.events)).toBeUndefined() + expect(ctx.sessionProjections.snapshot(session).values.sandboxMode).toBeNull() setSandboxMode(session, 'workspace-write') setSandboxMode(session, 'read-only') - expect(effectiveSandboxMode(session.events)).toBe('read-only') + expect(ctx.sessionProjections.snapshot(session).values.sandboxMode).toBe('read-only') }) it('setSandboxMode appends exactly one sandbox/mode event per switch', () => { diff --git a/packages/sandbox/sandbox-policy/tsconfig.json b/packages/sandbox/sandbox-policy/tsconfig.json index e909a20be1..3a08a053b6 100644 --- a/packages/sandbox/sandbox-policy/tsconfig.json +++ b/packages/sandbox/sandbox-policy/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../session/session-projection" } ] } diff --git a/packages/schedule/schedule/tests/jsonl-restart.spec.ts b/packages/schedule/schedule/tests/jsonl-restart.spec.ts index 23371feee6..d18e35aac9 100644 --- a/packages/schedule/schedule/tests/jsonl-restart.spec.ts +++ b/packages/schedule/schedule/tests/jsonl-restart.spec.ts @@ -7,6 +7,7 @@ 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' @@ -51,6 +52,7 @@ async function mountRuntime(root: string, adapter: RecordingAdapter): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(PersistenceProbe) ctx.on('session/flush', () => {}) await ctx.plugin(AgentLoop, { agents: [] }) diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index ad55c29dd8..2216ee6b4c 100644 --- a/packages/sdk/server/package.json +++ b/packages/sdk/server/package.json @@ -57,6 +57,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts index 495f1d90f4..c5f0019aa5 100644 --- a/packages/sdk/server/tests/server.spec.ts +++ b/packages/sdk/server/tests/server.spec.ts @@ -9,6 +9,7 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -61,6 +62,7 @@ async function mockCompletionServer(): Promise<{ url: string; requests: unknown[ async function makeHarness(storageDir: string) { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(agentCore, { workspaceContext: false }) await ctx.plugin(SubagentRuntime) await ctx.plugin(JsonlSessionPersistence, { root: storageDir }) diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index 4bab825e49..9926aead94 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -53,6 +53,7 @@ "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 1c4569df4c..4c528e9601 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -194,7 +194,7 @@ interface CursorPayload { /** Concrete SQLite owner of the combined `ctx.sessionQuery` service. */ export class SqliteSessionQueryEngine extends SessionQueryEngine { - static override inject = ['sessions'] + static override inject = ['sessions', 'sessionProjections'] static Config: z = z.object({ path: z.string().required(), diff --git a/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts b/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts index b68ac7072a..f38a4d53bb 100644 --- a/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts +++ b/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts @@ -8,8 +8,8 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' -import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SqliteSessionPersistence from '@deepseek-ai/dsh-session-persistence-sqlite' import SqliteSessionQueryEngine, * as queryModule from '@deepseek-ai/dsh-session-query-sqlite' import { mkdtemp, rm } from 'node:fs/promises' @@ -35,6 +35,7 @@ describe('dsh-session-query-sqlite real Loader path', () => { const persistencePath = await temporaryPath('canonical.db') const searchPath = await temporaryPath('derived.db') const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionStore) const persistence = await ctx.plugin(SqliteSessionPersistence, { path: persistencePath }) diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 0a565fe021..93f5edf30a 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -6,6 +6,7 @@ import { chmod, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' import type { SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' @@ -182,6 +183,7 @@ class TestPersistence extends SessionPersistence { async function liveContext(config: ConstructorParameters[1] = { path: ':memory:' }): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SqliteSessionQueryEngine, config) return ctx } @@ -221,6 +223,7 @@ describe('SQLite session search', () => { const path = await temporaryPath('unopened.db') const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const search = await ctx.plugin(SqliteSessionQueryEngine, { path, openAt: 'first-search', @@ -235,6 +238,7 @@ describe('SQLite session search', () => { const path = await temporaryPath('never-mode.db') const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const search = await ctx.plugin(SqliteSessionQueryEngine, { path, openAt: 'never' }) const service = ctx.sessionQuery as SqliteSessionQueryEngine expect(service.config.openAt).toBe('never') @@ -264,6 +268,7 @@ describe('SQLite session search', () => { it('opens once on the first search and reuses readiness for later searches', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SqliteSessionQueryEngine, { path: ':memory:', openAt: 'first-search', @@ -281,6 +286,7 @@ describe('SQLite session search', () => { it('shares one readiness promise across concurrent first searches', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SqliteSessionQueryEngine, { path: ':memory:', openAt: 'first-search', @@ -1097,6 +1103,7 @@ describe('SQLite reconciliation and source lifecycle', () => { ]) const first = new Context() await first.plugin(SessionStore) + await first.plugin(SessionProjectionRegistry) const firstPersistence = await first.plugin(TestPersistence) const firstSearch = await first.plugin(SqliteSessionQueryEngine, { path }) await first.sessionQuery.searchSessions({ query: 'needle' }) @@ -1117,6 +1124,7 @@ describe('SQLite reconciliation and source lifecycle', () => { TestPersistence.set({ meta: added, events: messageEvents('added needle') }) const second = new Context() await second.plugin(SessionStore) + await second.plugin(SessionProjectionRegistry) const secondPersistence = await second.plugin(TestPersistence) const secondSearch = await second.plugin(SqliteSessionQueryEngine, { path }) const result = await second.sessionQuery.searchSessions({ query: 'needle' }) @@ -1146,6 +1154,7 @@ describe('SQLite reconciliation and source lifecycle', () => { TestPersistence.reset([{ meta: shared, events: messageEvents('persisted needle') }]) const first = new Context() await first.plugin(SessionStore) + await first.plugin(SessionProjectionRegistry) const persistence = await first.plugin(TestPersistence) const live = first.sessions.create(shared.id, { seed: messageEvents('live needle'), meta: { createdAt: 10 } }) const search = await first.plugin(SqliteSessionQueryEngine, { path }) @@ -1155,6 +1164,7 @@ describe('SQLite reconciliation and source lifecycle', () => { const second = new Context() await second.plugin(SessionStore) + await second.plugin(SessionProjectionRegistry) const persistenceAgain = await second.plugin(TestPersistence) const searchAgain = await second.plugin(SqliteSessionQueryEngine, { path }) await expect(second.sessionQuery.searchSessions({ query: 'live' })).resolves.toEqual({ items: [] }) @@ -1263,6 +1273,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const path = `${await temporaryPath()}\0` const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await expect(ctx.plugin(SqliteSessionQueryEngine, { path })).rejects.toMatchObject({ code: 'SESSION_QUERY_INDEX_FAILED', @@ -1297,6 +1308,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => augmented.close() const augmentedCtx = new Context() await augmentedCtx.plugin(SessionStore) + await augmentedCtx.plugin(SessionProjectionRegistry) await expect(augmentedCtx.plugin(SqliteSessionQueryEngine, { path: augmentedPath })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) expect(augmentedCtx.sessionQuery).toBeUndefined() @@ -1314,6 +1326,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => currentAugmented.close() const currentAugmentedCtx = new Context() await currentAugmentedCtx.plugin(SessionStore) + await currentAugmentedCtx.plugin(SessionProjectionRegistry) await expect(currentAugmentedCtx.plugin(SqliteSessionQueryEngine, { path: currentAugmentedPath, journalMode: 'delete', @@ -1334,6 +1347,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => foreign.close() const foreignCtx = new Context() await foreignCtx.plugin(SessionStore) + await foreignCtx.plugin(SessionProjectionRegistry) await expect(foreignCtx.plugin(SqliteSessionQueryEngine, { path: foreignPath, journalMode: 'delete' })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) expect(foreignCtx.sessionQuery).toBeUndefined() @@ -1350,6 +1364,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => wildcard.close() const wildcardCtx = new Context() await wildcardCtx.plugin(SessionStore) + await wildcardCtx.plugin(SessionProjectionRegistry) await expect(wildcardCtx.plugin(SqliteSessionQueryEngine, { path: wildcardPath, journalMode: 'delete', @@ -1368,6 +1383,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => otherApp.close() const otherAppCtx = new Context() await otherAppCtx.plugin(SessionStore) + await otherAppCtx.plugin(SessionProjectionRegistry) await expect(otherAppCtx.plugin(SqliteSessionQueryEngine, { path: otherAppPath })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) expect(otherAppCtx.sessionQuery).toBeUndefined() @@ -1384,6 +1400,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => try { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await expect(ctx.plugin(SqliteSessionQueryEngine, { path })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) await new Promise((resolve) => { setImmediate(resolve) }) @@ -1402,6 +1419,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const lazyCtx = new Context() await lazyCtx.plugin(SessionStore) + await lazyCtx.plugin(SessionProjectionRegistry) const lazy = await lazyCtx.plugin(SqliteSessionQueryEngine, { path, openAt: 'first-search', @@ -1413,6 +1431,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const eagerCtx = new Context() await eagerCtx.plugin(SessionStore) + await eagerCtx.plugin(SessionProjectionRegistry) await expect(eagerCtx.plugin(SqliteSessionQueryEngine, { path })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) expect(eagerCtx.sessionQuery).toBeUndefined() @@ -1732,6 +1751,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => TestPersistence.reset() const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const search = await ctx.plugin(SqliteSessionQueryEngine, { path: ':memory:' }) const persistence = await ctx.plugin(TestPersistence) const optional = (ctx.sessionQuery as unknown as { @@ -1755,6 +1775,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const searchPath = await temporaryPath('derived.db') const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const persistence = await ctx.plugin(SqliteSessionPersistence, { path: persistencePath }) const search = await ctx.plugin(SqliteSessionQueryEngine, { path: searchPath }) const meta = header('real', 10, { cwd: '/work' }) @@ -1780,6 +1801,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const first = new Context() await first.plugin(SessionStore) + await first.plugin(SessionProjectionRegistry) const persistenceA = await first.plugin(SqliteSessionPersistence, { path: persistencePathA }) await first.sessionPersistence.create(shared) await first.sessionPersistence.append(shared.id, messageEvents('alpha source')) @@ -1793,6 +1815,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const reopened = new Context() await reopened.plugin(SessionStore) + await reopened.plugin(SessionProjectionRegistry) const persistenceAAgain = await reopened.plugin(SqliteSessionPersistence, { path: persistencePathA }) const reopenedInspect = vi.spyOn(reopened.sessionPersistence, 'inspect') const searchAAgain = await reopened.plugin(SqliteSessionQueryEngine, { path: searchPath }) @@ -1804,6 +1827,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const second = new Context() await second.plugin(SessionStore) + await second.plugin(SessionProjectionRegistry) const persistenceB = await second.plugin(SqliteSessionPersistence, { path: persistencePathB }) await second.sessionPersistence.create(shared) await second.sessionPersistence.append(shared.id, messageEvents('bravo source')) diff --git a/packages/session-query/session-query/tests/search-helpers.spec.ts b/packages/session-query/session-query/tests/search-helpers.spec.ts index eb82890646..4eb75cda1e 100644 --- a/packages/session-query/session-query/tests/search-helpers.spec.ts +++ b/packages/session-query/session-query/tests/search-helpers.spec.ts @@ -5,6 +5,7 @@ import SessionStore, { SESSION_FORMAT_VERSION, SessionId, } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { buildSessionEventRecords, @@ -274,6 +275,7 @@ describe('session-query document and filter helpers', () => { it('exposes the scan path on the combined query service', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(TestSessionQueryEngine) const session = ctx.sessions.create(id) session.append('user/message', createUserMessage({ @@ -290,6 +292,7 @@ describe('session-query document and filter helpers', () => { it('registers exact and abstract search behavior under one ctx key', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(TestSessionQueryEngine) const session = ctx.sessions.create(id) await expect(ctx.sessionQuery.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] }) diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 119188eb5a..83f5e1d040 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -2,6 +2,7 @@ import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from '@deepseek-ai/cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' import SessionPersistence, { SessionPersistenceCorruptionError, SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' import SessionQueryEngine, { @@ -9,9 +10,11 @@ import SessionQueryEngine, { type SessionEventSurface, type SessionQueryErrorCode, } from '@deepseek-ai/dsh-session-query' -import { SessionTitleProviderId } from '@deepseek-ai/dsh-session-title' +import { SessionTitleProviderId, SessionTitleService } from '@deepseek-ai/dsh-session-title' import { TestSessionQueryEngine } from './test-service.ts' +const TITLE_SERVICE_CONFIG = { fallbackMaxWords: 8, fallbackMaxBytes: 64, maxTitleBytes: 256 } + function header(id: string, createdAt = 1, extra: Partial = {}): SessionHeader { return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra } } @@ -125,6 +128,7 @@ class TestPersistence extends SessionPersistence { async function liveContext(config: ConstructorParameters[1] = {}): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(TestSessionQueryEngine, config) return ctx } @@ -487,6 +491,7 @@ describe('session-query exact reads', () => { }, ]) const ctx = await liveContext() + await ctx.plugin(SessionTitleService, TITLE_SERVICE_CONFIG) const shared = ctx.sessions.create(sharedHeader.id, { meta: { createdAt: 3 } }) shared.append('session/title', { title: 'Live title', @@ -525,6 +530,7 @@ describe('session-query exact reads', () => { { meta: second, events: [titleEvent('Second title', 20)] }, ]) const ctx = await liveContext() + await ctx.plugin(SessionTitleService, TITLE_SERVICE_CONFIG) await ctx.plugin(TestPersistence) const signal = new AbortController().signal const missing = SessionId('batch-title-missing') @@ -748,6 +754,7 @@ describe('session-query exact reads', () => { { meta: malformed, events: [malformedTitle] }, ]) const ctx = await liveContext() + await ctx.plugin(SessionTitleService, TITLE_SERVICE_CONFIG) await ctx.plugin(TestPersistence) TestPersistence.inspectOverride = (id) => { if (id === failed.id) return Promise.reject(inspectFailure) @@ -784,7 +791,7 @@ describe('session-query exact reads', () => { }) expect(results[2]).toMatchObject({ sessionId: malformed.id, status: 'rejected' }) if (results[2]?.status !== 'rejected') throw new Error('expected malformed title rejection') - expect(results[2].reason).toBeInstanceOf(TypeError) + expect(results[2].reason).toBeInstanceOf(Error) }) it('preserves live batch results across missing persistence, listing failure, and late attachment', async () => { @@ -1192,6 +1199,7 @@ describe('session-query exact reads', () => { it('leaves the optional persistence dependency optional', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(TestSessionQueryEngine) expect(ctx.sessionQuery).toBeInstanceOf(TestSessionQueryEngine) await fiber.dispose() @@ -1202,6 +1210,7 @@ describe('session-query exact reads', () => { TestPersistence.reset() const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const query = await ctx.plugin(TestSessionQueryEngine) const persistence = await ctx.plugin(TestPersistence) const optional = (ctx.sessionQuery as unknown as { diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index 0bff6c1154..5a38eec21f 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -2,6 +2,7 @@ import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Session, SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' import SessionPersistence from '@deepseek-ai/dsh-session-persistence' import { type SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query' @@ -99,6 +100,7 @@ class TracePersistence extends SessionPersistence { async function queryContext(): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(TestSessionQueryEngine) return ctx } diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index 8c069a5907..3b9fea73b1 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -39,7 +39,9 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" @@ -58,6 +60,8 @@ "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^" } } diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts index d204184cfe..c36355d728 100644 --- a/packages/session-query/tool-session-query/src/index.ts +++ b/packages/session-query/tool-session-query/src/index.ts @@ -17,7 +17,7 @@ import { presentation } from './presentation.ts' export const name = 'tool-session-query' /** Capability services required by the model-facing consumer. */ -export const inject = ['tools', 'systemPrompt', 'sessionQuery'] +export const inject = ['tools', 'systemPrompt', 'sessionQuery', 'sessionProjections'] /** Default maximum number of authorized search hits returned by one call. */ export const DEFAULT_MAX_SEARCH_RESULTS = 100 diff --git a/packages/session-query/tool-session-query/src/operations.ts b/packages/session-query/tool-session-query/src/operations.ts index a8010c0e90..88107783cb 100644 --- a/packages/session-query/tool-session-query/src/operations.ts +++ b/packages/session-query/tool-session-query/src/operations.ts @@ -57,7 +57,7 @@ async function executeSessionSearch( exec: ToolRunContext, maxResults: number, ): Promise { - const caller = workspaceAccess.callerOf(exec) + const caller = workspaceAccess.callerOf(exec, ctx) const cwd = caller.header.cwd if (cwd === undefined) { throw new HarnessError( @@ -119,20 +119,22 @@ async function executeEventSearch( exec: ToolRunContext, maxResults: number, ): Promise { - const caller = workspaceAccess.callerOf(exec) + const caller = workspaceAccess.callerOf(exec, ctx) const sessionId = workspaceAccess.targetId(args, caller) await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) const query = toolInput.normalizeQuery(args.query) const range = toolInput.sequenceRange(args.seq_from, args.seq_to) if (sessionId === caller.id) { - const stepStart = caller.events.findLast(event => event.type === 'step/start') - if (stepStart === undefined) { + const stepStartSeq = caller.boundary?.lastStepStartSeq + if (stepStartSeq === undefined) { throw new HarnessError( 'current-session search requires an active step boundary', 'SESSION_QUERY_TOOL_NO_CURRENT_STEP', ) } - range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, stepStart.seq - 1) + // `null` (no step started yet) caps the range to a degenerate `to` the + // filter validation rejects — the loop never runs tools outside a step. + range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, (stepStartSeq ?? 0) - 1) } const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) if (range.from !== undefined && range.to !== undefined && range.from > range.to) { @@ -170,7 +172,7 @@ async function executeSessionTrace( args: SessionTargetArgs, exec: ToolRunContext, ): Promise { - const caller = workspaceAccess.callerOf(exec) + const caller = workspaceAccess.callerOf(exec, ctx) const sessionId = workspaceAccess.targetId(args, caller) await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) const trace = await serviceBoundary.call(ctx, exec.signal, 'session lineage trace', () => @@ -203,7 +205,7 @@ async function executeEventTrace( exec: ToolRunContext, ): Promise { toolInput.assertNonNegativeSafeInteger('seq', args.seq) - const caller = workspaceAccess.callerOf(exec) + const caller = workspaceAccess.callerOf(exec, ctx) const sessionId = workspaceAccess.targetId(args, caller) await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) const trace = await serviceBoundary.call(ctx, exec.signal, 'event trace', () => @@ -221,7 +223,7 @@ async function executeEventRead( toolInput.assertNonNegativeSafeInteger('seq', args.seq) if (args.before !== undefined) toolInput.assertNonNegativeSafeInteger('before', args.before) if (args.after !== undefined) toolInput.assertNonNegativeSafeInteger('after', args.after) - const caller = workspaceAccess.callerOf(exec) + const caller = workspaceAccess.callerOf(exec, ctx) const sessionId = workspaceAccess.targetId(args, caller) await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) const window = await serviceBoundary.call(ctx, exec.signal, 'event read', () => diff --git a/packages/session-query/tool-session-query/src/workspace-access.ts b/packages/session-query/tool-session-query/src/workspace-access.ts index a6c7b1ce40..08d07d500b 100644 --- a/packages/session-query/tool-session-query/src/workspace-access.ts +++ b/packages/session-query/tool-session-query/src/workspace-access.ts @@ -12,17 +12,21 @@ import { type SessionHeader, type SessionId as SessionIdValue, } from '@deepseek-ai/dsh-session' +import type { TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' import type { SessionLineageNode, SessionRecord, } from '@deepseek-ai/dsh-session-query' import type { ToolRunContext } from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-session-projection' import { serviceBoundary } from './service-boundary.ts' interface Caller { readonly id: SessionIdValue readonly header: SessionHeader readonly events: readonly SessionEvent[] + /** The caller's own-session boundary fold (the `turnBoundary` projection). */ + readonly boundary: TurnBoundaryProjection | undefined } interface TitleView { @@ -51,7 +55,7 @@ interface DescendantVisit { readonly next: DescendantVisit | undefined } -function callerOf(exec: ToolRunContext): Caller { +function callerOf(exec: ToolRunContext, ctx: Context): Caller { const agent = exec.agent if (agent === undefined) { throw new HarnessError( @@ -63,6 +67,7 @@ function callerOf(exec: ToolRunContext): Caller { id: agent.session.id, header: agent.session.header, events: agent.session.events, + boundary: ctx.sessionProjections.stateOf(agent.session, 'turnBoundary'), } } diff --git a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts index 500d7de3a6..d585bfd270 100644 --- a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts +++ b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts @@ -10,6 +10,8 @@ import SessionStore, { SessionId, type Session, } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SqliteSessionQueryEngine from '@deepseek-ai/dsh-session-query-sqlite' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -30,6 +32,10 @@ function fakeAgent(session: Session): Agent { return { id: session.id, session } as unknown as Agent } +function registerTurnBoundary(ctx: Context): void { + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) +} + describe('tool-session-query with the real SQLite provider', () => { it('searches live prior-step history and a persisted same-workspace log', { timeout: 20_000 }, async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-tool-session-query-')) @@ -37,6 +43,8 @@ describe('tool-session-query with the real SQLite provider', () => { const ctx = new Context() contexts.push(ctx) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) @@ -106,6 +114,8 @@ describe('tool-session-query with the real SQLite provider', () => { const ctx = new Context() contexts.push(ctx) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index 7d213f9591..25923c9247 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -11,6 +11,8 @@ import SessionStore, { type SessionHeader, type SessionId as SessionIdValue, } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SessionQueryEngine, { SessionQueryError, SessionSearchCursor, @@ -195,6 +197,10 @@ interface Mounted { call(name: string, args: unknown, options?: { agent?: Agent; signal?: AbortSignal }): Promise } +function registerTurnBoundary(ctx: Context): void { + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) +} + async function mount( config: ToolSessionQuery.Config = {}, callerCwd: string | null = '/work', @@ -203,6 +209,8 @@ async function mount( const ctx = new Context() activeContexts.push(ctx) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) if (enforceTimeout) await ctx.plugin(TimeoutPolicy) @@ -1612,7 +1620,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => { query: 'q' }, { agent: fakeAgent(noStep) }, ) - expect(errorCode(missing)).toBe('SESSION_QUERY_TOOL_NO_CURRENT_STEP') + expect(errorCode(missing)).toBe('SESSION_QUERY_INVALID_FILTER') const other = createSession(mounted.ctx, 'paged-events', '/work') const cursor = SessionSearchCursor('events-next') @@ -1634,6 +1642,26 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => expect(text(result)).toContain('Result cap reached') }) + it('rejects current-session search without the turnBoundary fold', async () => { + const ctx = new Context() + activeContexts.push(ctx) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(FakeQuery) + await ctx.plugin(ToolSessionQuery) + const session = createSession(ctx, 'no-boundary-caller', '/work') + const result = await ctx.tools.execute({ + name: 'session_event_search', + arguments: { query: 'q' }, + callId: CallId('call-no-boundary'), + signal: new AbortController().signal, + agent: fakeAgent(session), + }) + expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_NO_CURRENT_STEP') + }) + it('preserves base results when a title read fails, annotates the code, and logs the full error', async () => { const mounted = await mount() const hit = createSession(mounted.ctx, 'hit', '/work') diff --git a/packages/session-query/tool-session-query/tsconfig.json b/packages/session-query/tool-session-query/tsconfig.json index d3ef530901..d9accb9886 100644 --- a/packages/session-query/tool-session-query/tsconfig.json +++ b/packages/session-query/tool-session-query/tsconfig.json @@ -4,7 +4,9 @@ "rootDir": "src", "outDir": "lib/types" }, - "include": ["src"], + "include": [ + "src" + ], "references": [ { "path": "../../../vendor/cosmokit" @@ -35,6 +37,12 @@ }, { "path": "../../util/timeout" + }, + { + "path": "../../session/session-projection" + }, + { + "path": "../../core/agent" } ] } diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index 1273bf3ce3..6479295b12 100644 --- a/packages/session/session-checkpoint-policy/package.json +++ b/packages/session/session-checkpoint-policy/package.json @@ -50,6 +50,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts b/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts index 9557746db9..4be7094ee5 100644 --- a/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts +++ b/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts @@ -2,6 +2,7 @@ import { writeFile } from 'node:fs/promises' 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 { createUserMessage, CallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -37,6 +38,7 @@ class CrashAdapter extends LlmAdapter { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) +await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(JsonlSessionPersistence, { root: persistenceRoot, compression: 'none' }) await ctx.plugin(checkpointPolicy) diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index c1f659857b..a6f003b550 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-projection/README.md -README.md: 7ef1daedad2790bac31c48315b322f19914a29af -README.zh.md: 928a2ea88fbc0c8e8fd3398fb0c87a7a8ed3b6e7 +README.md: bbdb9fc172bc3be36a7594772eeef5111f922eaf +README.zh.md: 058fa6da0b1d43a438552d2c554e9f968d50ebb6 diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 7ef1daedad..bbdb9fc172 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -27,7 +27,7 @@ Session-projection Service Definition and drive registry. It owns `ctx.sessionPr - **Synchronous unit discipline.** `init`/`apply`/`wire.view` MUST be synchronous; carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut. An accidentally async view returns a Promise, which fails `wire.viewSchema.parse`. - **State is validated plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows and validates `val` with `stateSchema` before use; bump `stateVersion` whenever the state fields or fold semantics change. Client-visible units persist automatically; a host-only unit opts in with `persist: true`. - **No wire vocabulary here.** The registry exposes only the change feed and the snapshot read face; carriers (api-proxy) mint their own frames (`session/projection`) and blocks from them. -- **Optional capability.** Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected; carriers use `ctx.get('sessionProjections')` and omit their block/frames entirely when the registry is absent. +- **Required for units and host reads.** Plugins that contribute or read projection units inject `sessionProjections`, so incomplete compositions fail during activation. The lower-level api-proxy factory remains tolerant for isolated tests and diagnostics and omits projection blocks and frames when the registry is absent. ## Role diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index 928a2ea88f..058fa6da0b 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -27,7 +27,7 @@ - **单元的同步纪律。**`init`/`apply`/`wire.view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。 - **状态是经校验的纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存存储 `(sessionId, key, ver, seq, val)` 行,并在使用前以 `stateSchema` 校验 `val`;状态字段或折叠语义一旦变化就递增 `stateVersion`。client-visible 单元自动持久化;host-only 单元以 `persist: true` 选择持久化。 - **本层没有协议词汇。** 注册表只暴露变更流与快照读取面;载体(api-proxy)据此自铸各自的帧(`session/projection`)与块。 -- **可选能力。** 领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响;载体使用 `ctx.get('sessionProjections')`,注册表缺席时完全省略自己的块与帧。 +- **单元与 host 读取方必需。** 贡献或读取投影单元的插件注入 `sessionProjections`,因此不完整的组合会在激活时失败。较低层的 api-proxy factory 仍对隔离测试和诊断保持容错,注册表缺席时省略投影块与帧。 ## 职责 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index aa785cd996..d9d758c5ae 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -176,9 +176,9 @@ interface Registration { * older than the registry, folds `init` over the in-memory log on first * touch (event or read). Registration is an effect (disposer rides the * calling fiber): an unloaded domain plugin's key disappears from snapshots - * and clients read it as capability absence. Domain - * plugins register under `ctx.inject(['sessionProjections'], …)` so headless - * assemblies without the registry stay unaffected. Registrants sharing a key + * and clients read it as capability absence. Unit contributors and host readers + * require this service, so incomplete compositions fail during activation. + * Registrants sharing a key * share one unit and are counted: the same tool package mounted in N agent * presets registers N times, and the key survives until the last one * unloads. diff --git a/packages/session/session-stats/README.i18n.yaml b/packages/session/session-stats/README.i18n.yaml index d12db01c69..43d5996252 100644 --- a/packages/session/session-stats/README.i18n.yaml +++ b/packages/session/session-stats/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-stats/README.md -README.md: 81b0de17e335b67936afd6fb11f15411beee3b76 -README.zh.md: 606628ea09bc34203a5374e49db62c1646293846 +README.md: bb6e6611d53678a900f48b4a9f7341aa73072015 +README.zh.md: 8cfb95cba7b73a770b876b9de0509ef9aa999378 diff --git a/packages/session/session-stats/README.md b/packages/session/session-stats/README.md index 81b0de17e3..bb6e6611d5 100644 --- a/packages/session/session-stats/README.md +++ b/packages/session/session-stats/README.md @@ -21,7 +21,7 @@ Function plugin registering the `sessionStats` projection unit: whole-log conver name: '@deepseek-ai/dsh-session-stats' ``` -Injects `sessionProjections` — the plugin's whole purpose; in assemblies without the registry the fiber stays pending and nothing registers. +Requires `sessionProjections` — the plugin's whole purpose — so an incomplete assembly fails during activation. ## Model Experience diff --git a/packages/session/session-stats/README.zh.md b/packages/session/session-stats/README.zh.md index 606628ea09..8cfb95cba7 100644 --- a/packages/session/session-stats/README.zh.md +++ b/packages/session/session-stats/README.zh.md @@ -21,7 +21,7 @@ name: '@deepseek-ai/dsh-session-stats' ``` -注入 `sessionProjections`——这是插件的全部用途;在没有 registry 的装配中 fiber 保持挂起,不注册任何内容。 +要求注入 `sessionProjections`——这是插件的全部用途;不完整的装配会在激活时失败。 ## 模型体验 diff --git a/packages/session/session-title-all-prompts-llm/package.json b/packages/session/session-title-all-prompts-llm/package.json index 6287d55029..e35f9d3f8d 100644 --- a/packages/session/session-title-all-prompts-llm/package.json +++ b/packages/session/session-title-all-prompts-llm/package.json @@ -43,6 +43,8 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-llm": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^" } } diff --git a/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts b/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts index c1f51c654b..8c358bfc35 100644 --- a/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts +++ b/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest' import LlmRuntime, { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SessionTitleService from '@deepseek-ai/dsh-session-title' import * as providerPlugin from '@deepseek-ai/dsh-session-title-all-prompts-llm' @@ -44,6 +46,8 @@ describe('all-messages LLM title provider', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SessionTitleService, TITLE_CONFIG) const adapter = new RecordingAdapter() ctx.llm.registerAdapter(['current-route'], adapter) diff --git a/packages/session/session-title-first-prompt-llm/package.json b/packages/session/session-title-first-prompt-llm/package.json index a11c796906..1c7b8419cf 100644 --- a/packages/session/session-title-first-prompt-llm/package.json +++ b/packages/session/session-title-first-prompt-llm/package.json @@ -46,6 +46,8 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-llm": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^" } } diff --git a/packages/session/session-title-first-prompt-llm/tests/loader-composition.spec.ts b/packages/session/session-title-first-prompt-llm/tests/loader-composition.spec.ts index 12ff310ae9..7d003ecb73 100644 --- a/packages/session/session-title-first-prompt-llm/tests/loader-composition.spec.ts +++ b/packages/session/session-title-first-prompt-llm/tests/loader-composition.spec.ts @@ -9,6 +9,7 @@ import { pathToFileURL } from 'node:url' import LlmRuntime, { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionTitleService from '@deepseek-ai/dsh-session-title' import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-prompt-llm' @@ -38,6 +39,7 @@ async function loadComposition(): Promise { await writeFile(configPath, [ "- name: '@deepseek-ai/dsh-llm'", "- name: '@deepseek-ai/dsh-session'", + "- name: '@deepseek-ai/dsh-session-projection'", "- name: '@deepseek-ai/dsh-session-title'", ' config:', ' fallbackMaxWords: 5', @@ -62,6 +64,7 @@ async function loadComposition(): Promise { const modules = new Map([ ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-session-title', SessionTitleService], ['@deepseek-ai/dsh-session-title-first-prompt-llm', providerPlugin], ]) diff --git a/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts b/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts index 0bcdc98535..3b16d7349b 100644 --- a/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts +++ b/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts @@ -5,6 +5,7 @@ import LlmRuntime from '@deepseek-ai/dsh-llm' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionTitleService from '@deepseek-ai/dsh-session-title' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as FirstMessageTitleProvider from '@deepseek-ai/dsh-session-title-first-prompt-llm' const contexts: Context[] = [] @@ -20,6 +21,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('first-prompt title provider with await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { thinking: 'disabled' }) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, diff --git a/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts b/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts index c4fae95c87..083682bfa3 100644 --- a/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts +++ b/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts @@ -3,6 +3,8 @@ import { describe, expect, it, vi } from 'vitest' import LlmRuntime, { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SessionTitleService, { type SessionTitleProvider } from '@deepseek-ai/dsh-session-title' import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-prompt-llm' @@ -36,6 +38,8 @@ describe('first-prompt LLM title provider', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SessionTitleService, TITLE_CONFIG) let registered: SessionTitleProvider | undefined vi.spyOn(ctx.sessionTitle, 'register').mockImplementation((provider) => { @@ -55,6 +59,8 @@ describe('first-prompt LLM title provider', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SessionTitleService, TITLE_CONFIG) const adapter = new RecordingAdapter() ctx.llm.registerAdapter(['title-route'], adapter) diff --git a/packages/session/session-title/README.i18n.yaml b/packages/session/session-title/README.i18n.yaml index bbe1cfc651..c3210ca41a 100644 --- a/packages/session/session-title/README.i18n.yaml +++ b/packages/session/session-title/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-title/README.md -README.md: e923bd9700180214f235c4971d4b020565bfee43 -README.zh.md: 8e84ac27d9f1509c8530b838eeb5eb2216d87199 +README.md: 7949dbdb73d33c6c7ba1cccd469a6b22732aa95d +README.zh.md: 24014485050be9e6aa736ac6c686416c3778ac10 diff --git a/packages/session/session-title/README.md b/packages/session/session-title/README.md index e923bd9700..7949dbdb73 100644 --- a/packages/session/session-title/README.md +++ b/packages/session/session-title/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Log-backed session titles with an immediate deterministic fallback and one optional asynchronous provider. Every accepted revision is a log-only `session/title` event; `foldSessionTitle()` and `ctx.sessionTitle.get()` select the latest event and return its event seq and timestamp. +Log-backed session titles with an immediate deterministic fallback and one optional asynchronous provider. Every accepted revision is a log-only `session/title` event. The required `title` projection retains the full latest snapshot for `ctx.sessionTitle.get()` while its client view remains `string | null`; `foldSessionTitle()` remains the direct fold for detached logs. Only text blocks from human `user/message` events are eligible. The first eligible prompt schedules a fallback from its first words within the configured UTF-8 byte limit. Whitespace is normalized, terminal control sequences are removed, and truncation never splits a code point. Empty and non-text prompts wait for later eligible input. @@ -13,6 +13,8 @@ Only text blocks from human `user/message` events are eligible. The first eligib - `rename(session, title)` accepts an explicit user title synchronously: it normalizes the text, supersedes in-flight automatic work, and appends a `session/title` event with the `user` source. A user-sourced latest title pins the session — later user messages schedule no automatic revision; an explicit `refresh` remains the deliberate unpin. - `register(provider)` installs the sole optional provider and returns its awaitable Cordis effect disposer. A second registration throws immediately; disposal aborts pending and active calls, waits for their settlement, and only then permits another provider to register. +The service also registers the host-only `titleInput` projection. It incrementally retains eligible human text and the latest request route, so provider scheduling reads typed state through `stateOf()` instead of rescanning the session log. + Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion appends a standalone log-only event directly through `Session` without opening a turn. Persistence observes that event eagerly and drains on ordinary lifecycle checkpoints; title publication itself does not force a flush. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. Concurrent explicit refreshes reserve their revision before provider work, while overlapping automatic and explicit fallback requests share one session-local in-flight append. The service and bundled model provider each append their own literal event type, so no generic title-write marker, cast, or settlement queue is needed. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes. Forks inherit title events in their seed unchanged. The first-prompt cadence does not automatically retitle a child; the all-messages cadence may append a new revision after the child receives a later human prompt. diff --git a/packages/session/session-title/README.zh.md b/packages/session/session-title/README.zh.md index 8e84ac27d9..2401448505 100644 --- a/packages/session/session-title/README.zh.md +++ b/packages/session/session-title/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -由日志支持的会话标题,提供即时确定性回退与一个可选异步提供方。每次已接受的修订都是仅写入日志的 `session/title` 事件;`foldSessionTitle()` 与 `ctx.sessionTitle.get()` 会选择最新事件,并返回其事件 seq 和时间戳。 +由日志支持的会话标题,提供即时确定性回退与一个可选异步提供方。每次已接受的修订都是仅写入日志的 `session/title` 事件。必需的 `title` 投影为 `ctx.sessionTitle.get()` 保留完整最新快照,而客户端视图仍是 `string | null`;`foldSessionTitle()` 继续用于直接折叠脱离服务的日志。 只有用户 `user/message` 事件中的文本块符合条件。第一条符合条件的提示词会安排回退,从其开头若干词生成标题,并受所配置 UTF-8 字节上限约束。系统会规范化空白、移除终端控制序列,且截断绝不会切断码点。空提示词和非文本提示词会等待后续符合条件的输入。 @@ -13,6 +13,8 @@ - `rename(session, title)` 同步接受用户显式标题:规范化文本、取代在途自动工作,并追加一条 `user` 来源的 `session/title` 事件。用户来源的最新标题会钉住该会话——后续用户消息不再安排自动修订;显式 `refresh` 仍是有意的解钉手段。 - `register(provider)` 安装唯一可选提供方,并返回可等待的 Cordis effect disposer。第二次注册会立即抛出;对提供方执行 dispose(资源释放)会中止待处理和活跃调用,等待其结算,之后才允许注册另一个提供方。 +服务还会注册仅供 host 使用的 `titleInput` 投影。该投影增量保留符合条件的人类文本和最近一次请求 route,使提供方调度可以通过 `stateOf()` 读取类型化状态,无需重新扫描会话日志。 + 自动工作绝不会延迟主 agent(智能体)响应。只有当带标记、由循环构建的请求,其确切路由与当前已记录的 `request/header` 匹配时,提供方才会启动;即使请求头未变而无需新快照,也适用此规则。延迟完成会直接通过 `Session` 追加一个独立的纯日志事件,而不打开轮次。持久化会立即观察到该事件,并在常规生命周期检查点完成刷写;标题发布本身不会强制刷写。自动失败会发出警告并保留最新标题。新的全消息修订、提供方 dispose、会话 dispose 和显式刷新都会中止旧工作,陈旧的完成结果无法追加。并发显式刷新会在提供方工作之前预留修订号;重叠的自动/显式回退请求共享一个会话本地正在进行的追加操作。服务与内置模型提供方各自追加自己的字面事件类型,因此不需要通用标题写入标记、类型断言或结算队列。服务拆卸会取消排队工作,并在卸载完成前等待不响应取消的调用结算完成。 fork 出的会话会原样继承种子中的标题事件。首消息节奏不会自动为子会话重新生成标题;全消息节奏可以在子会话收到后续用户提示词后追加新修订。 diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index 3395816244..352f977661 100644 --- a/packages/session/session-title/package.json +++ b/packages/session/session-title/package.json @@ -46,7 +46,8 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^", @@ -60,6 +61,8 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^" } } diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index 5ed0f1ca4d..7f467e6d79 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -6,26 +6,38 @@ import { Context, FiberState, Service, type Fiber } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' -import type { Branded } from '@deepseek-ai/dsh-brand' +import type { ZodType } from 'zod' import { assertNever, deepFreeze, isAgentLoopRequest } from '@deepseek-ai/dsh-llm' import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, } from '@deepseek-ai/dsh-session' -// Type-only: resolves ctx.sessionProjections for the optional unit child. import type {} from '@deepseek-ai/dsh-session-projection' -// The `title` projection-key declaration lives in src/types.ts (its one home); -// this re-export projects the type face onto the package root AND keeps the -// module edge in the emitted index.d.ts, so aggregate programs consuming the -// declarations still receive the SessionProjectionMap merge. -export type * from './types.ts' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type {} from '@deepseek-ai/dsh-agent' +export type { + SessionTitleEventData, + SessionTitleModelProvenance, + SessionTitleSnapshot, + SessionTitleSource, + SessionTitleUserMessage, + TitleProjection, +} from './types.ts' import { fallbackSessionTitle, normalizeSessionTitle } from './normalize.ts' +import type { + SessionTitleEventData, + SessionTitleModelProvenance, + SessionTitleSnapshot, + SessionTitleSource, + SessionTitleUserMessage, + TitleInputChunk, + TitleInputState, + TitleProjection, +} from './types.ts' -export { fallbackSessionTitle, normalizeSessionTitle, truncateTitleUtf8 } from './normalize.ts' - -/** Identifies one session-title provider registration. */ -export type SessionTitleProviderId = Branded<'SessionTitleProviderId'> +/** Branded session-title provider identity. */ +export type SessionTitleProviderId = import('./types.ts').SessionTitleProviderId /** * Brand a raw provider id. @@ -36,44 +48,7 @@ export function SessionTitleProviderId(id: string): SessionTitleProviderId { return id as SessionTitleProviderId } -/** Exact auxiliary model route that produced a title. */ -export interface SessionTitleModelProvenance { - /** Registered LLM provider route. */ - readonly provider: string - /** Provider model id. */ - readonly model: string -} - -/** Durable ownership record for an accepted session title. */ -export type SessionTitleSource = - | { readonly kind: 'fallback' } - | { - readonly kind: 'provider' - readonly provider: SessionTitleProviderId - readonly model?: SessionTitleModelProvenance - } - | { - /** Explicit user rename: pins the title — automatic generation stops scheduling. */ - readonly kind: 'user' - } - -/** Payload of the log-only `session/title` event. */ -export interface SessionTitleEventData { - /** Normalized non-empty title text. */ - readonly title: string - /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */ - readonly messageSeqs: number[] - /** Whether the built-in fallback, a registered provider, or the user supplied the title. */ - readonly source: SessionTitleSource -} - -/** Latest folded title plus the title event's durable envelope facts. */ -export interface SessionTitleSnapshot extends SessionTitleEventData { - /** Seq of the latest `session/title` event. */ - readonly eventSeq: number - /** Timestamp of the latest `session/title` event. */ - readonly updatedAt: number -} +export { fallbackSessionTitle, normalizeSessionTitle, truncateTitleUtf8 } from './normalize.ts' /** Required deterministic fallback and accepted-title limits. */ export interface Config { @@ -111,14 +86,6 @@ export class SessionTitleInvalidError extends Error { override readonly name = 'SessionTitleInvalidError' } -/** One eligible human text message exposed to title providers. */ -export interface SessionTitleUserMessage { - /** Source `user/message` event seq. */ - readonly seq: number - /** Exact concatenated text-block content. */ - readonly text: string -} - /** Automatic generation cadence owned by a registered provider. */ export type SessionTitleAutomaticMode = 'first-prompt' | 'all-prompts' @@ -158,48 +125,17 @@ export interface SessionTitleProvider { generate(request: SessionTitleProviderRequest): Promise } -/** - * Collect human text-bearing user messages in log order. - * @param events - session log or persisted replay. - * @param throughSeq - optional inclusive event boundary. - * @returns eligible messages with exact source seqs. - */ -export function collectSessionTitleMessages( - events: readonly SessionEvent[], - throughSeq?: number, -): SessionTitleUserMessage[] { - const messages: SessionTitleUserMessage[] = [] - for (const event of events) { - if (throughSeq !== undefined && event.seq > throughSeq) break - if (event.type !== 'user/message' || event.data.source.kind !== 'user') continue - const content = event.data.content - const text = content - .filter((block): block is Extract<(typeof content)[number], { type: 'text' }> => block.type === 'text') - .map(block => block.text) - .join('\n') - if (normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER).length === 0) continue - messages.push({ seq: event.seq, text }) - } - return messages +/** Extract one eligible human text message from a session event. */ +function sessionTitleUserMessageOf(event: SessionEvent): SessionTitleUserMessage | undefined { + if (event.type !== 'user/message' || event.data.source.kind !== 'user') return undefined + const content = event.data.content + const text = content + .filter((block): block is Extract<(typeof content)[number], { type: 'text' }> => block.type === 'text') + .map(block => block.text) + .join('\n') + if (normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER).length === 0) return undefined + return { seq: event.seq, text } } - -/** - * Fold the latest logged title without consulting mutable metadata. - * @param events - live or persisted session log. - * @returns the latest immutable title snapshot, or `undefined`. - */ -export function foldSessionTitle(events: readonly SessionEvent[]): SessionTitleSnapshot | undefined { - const event = events.findLast(item => item.type === 'session/title') - if (event === undefined) return undefined - return deepFreeze({ - title: event.data.title, - messageSeqs: [...event.data.messageSeqs], - source: copySessionTitleSource(event.data.source), - eventSeq: event.seq, - updatedAt: event.time, - }) -} - /** Defensive copy of a logged title source (the snapshot must not alias log-owned objects). */ function copySessionTitleSource(source: SessionTitleSource): SessionTitleSource { switch (source.kind) { @@ -257,9 +193,94 @@ function assertPositiveInteger(name: keyof Config, value: number): void { } } +/** The title unit's folded state — the projection's full public face. */ +export type TitleUnitState = TitleProjection + +/** + * Convert title projection state into an immutable snapshot. + * @param state - the title unit's folded state. + * @returns the immutable snapshot. + */ +export function titleSnapshotFromState(state: TitleUnitState): SessionTitleSnapshot { + return deepFreeze({ + title: state.title, + messageSeqs: [...state.messageSeqs], + source: copySessionTitleSource(state.source), + eventSeq: state.eventSeq, + updatedAt: state.updatedAt, + }) +} + +const TITLE_INPUT_CHUNK_SIZE = 64 + +const EMPTY_TITLE_INPUT: TitleInputState = { first: null, last: null, count: 0, tail: null } + +function titleInputPrefix(state: TitleInputState, throughSeq: number): SessionTitleUserMessage[] { + const chunks: TitleInputChunk[] = [] + for (let chunk = state.tail; chunk !== null; chunk = chunk.previous) chunks.push(chunk) + const prefix: SessionTitleUserMessage[] = [] + for (const chunk of chunks.reverse()) { + for (const message of chunk.messages) { + if (message.seq <= throughSeq) prefix.push(message) + } + } + return prefix +} + +// Zod cannot express the branded provider id without a runtime transform. +const titleProjectionSchema = zod.object({ + title: zod.string().min(1), + messageSeqs: zod.array(zod.number().int().nonnegative()), + source: zod.discriminatedUnion('kind', [ + zod.object({ kind: zod.literal('fallback') }), + zod.object({ + kind: zod.literal('provider'), + provider: zod.string(), + model: zod.object({ provider: zod.string(), model: zod.string() }).optional(), + }), + zod.object({ kind: zod.literal('user') }), + ]), + eventSeq: zod.number().int().nonnegative(), + updatedAt: zod.number(), +}).nullable() as unknown as ZodType + +const titleViewSchema: ZodType = zod.string().min(1).nullable() + +/** Latest logged title and its client view. */ +export const titleProjectionDefinition = { + key: 'title', + stateVersion: 2, + stateSchema: titleProjectionSchema, + init: () => null, + apply: (state, event) => (event.type === 'session/title' + ? { + title: event.data.title, + messageSeqs: event.data.messageSeqs, + source: event.data.source, + eventSeq: event.seq, + updatedAt: event.time, + } + : state), + wire: { + viewSchema: titleViewSchema, + view: state => state?.title ?? null, + }, +} satisfies ProjectionDefinition<'title', TitleUnitState | null> + +/** + * Fold the latest title from a session log. + * @param events - live or persisted session log. + * @returns the immutable latest title snapshot, or `undefined`. + */ +export function foldSessionTitle(events: readonly SessionEvent[]): SessionTitleSnapshot | undefined { + let state: TitleUnitState | null = titleProjectionDefinition.init() + for (const event of events) state = titleProjectionDefinition.apply(state, event) + return state === null ? undefined : titleSnapshotFromState(state) +} + /** Log-backed title fold plus asynchronous fallback generation. */ export class SessionTitleService extends Service { - static inject = ['sessions'] + static inject = ['sessions', 'sessionProjections'] static Config: z = z.object({ fallbackMaxWords: z.number().step(1).min(1).required(), fallbackMaxBytes: z.number().step(1).min(1).required(), @@ -301,20 +322,26 @@ export class SessionTitleService extends Service { this.work.clear() }, 'sessionTitle lifecycle') - // The title projection unit: pure last-wins fold of session/title events - // (the same events foldSessionTitle consumes), serving the plain title - // string clients list rows read. The unit child activates only when a - // projection registry is composed (headless assemblies stay unaffected). - ctx.inject(['sessionProjections'], (projectionCtx) => { - const titleSchema = zod.union([zod.string().min(1), zod.null()]) - projectionCtx.sessionProjections.register<'title', string | null>({ - key: 'title', - stateSchema: titleSchema, - init: () => null, - apply: (state, event) => (event.type === 'session/title' ? event.data.title : state), - wire: { viewSchema: titleSchema, view: state => state }, - stateVersion: 1, - }) + ctx.sessionProjections.register(titleProjectionDefinition) + + ctx.sessionProjections.register<'titleInput', TitleInputState>({ + key: 'titleInput', + stateVersion: 1, + stateSchema: zod.custom(), + init: () => EMPTY_TITLE_INPUT, + apply: (state, event) => { + const message = sessionTitleUserMessageOf(event) + if (message === undefined) return state + const tail = state.tail === null || state.tail.messages.length >= TITLE_INPUT_CHUNK_SIZE + ? { messages: [message], previous: state.tail } + : { messages: [...state.tail.messages, message], previous: state.tail.previous } + return { + first: state.first ?? message, + last: message, + count: state.count + 1, + tail, + } + }, }) ctx.on('session/event', (session, event) => { @@ -347,7 +374,8 @@ export class SessionTitleService extends Service { * @returns latest title snapshot, or `undefined` before eligible input. */ get(session: Session): SessionTitleSnapshot | undefined { - return foldSessionTitle(session.events) + const state = this.ctx.sessionProjections.stateOf(session, 'title') + return state === null || state === undefined ? undefined : titleSnapshotFromState(state) } /** @@ -397,15 +425,15 @@ export class SessionTitleService extends Service { throw new Error(`session "${session.id}" is not live in this store`) } const registration = this.registration - const messages = collectSessionTitleMessages(session.events) - const latest = messages.at(-1) - if (registration === undefined || registration.closing || latest === undefined) { + const input = this.titleInputOf(session) + const latest = input.last + if (registration === undefined || registration.closing || latest === null) { // Explicit refresh is the unpin even without a provider: a standing // user title must not short-circuit ensureFallback into a no-op, so // re-derive and append the fallback over it when one is derivable. const current = this.get(session) - const [first] = messages - if (current?.source.kind === 'user' && first !== undefined) { + const first = input.first + if (current?.source.kind === 'user' && first !== null) { this.appendFallback(session, first) signal?.throwIfAborted() return this.get(session) @@ -462,14 +490,14 @@ export class SessionTitleService extends Service { /** Schedule fallback creation and any provider cadence for one eligible event. */ private onUserMessage(session: Session, event: Extract): void { if (!this.serviceActive()) return - if (event.data.source.kind !== 'user' || collectSessionTitleMessages([event]).length === 0) return + if (event.data.source.kind !== 'user' || sessionTitleUserMessageOf(event) === undefined) return // A user rename pins the title: no automatic revision may override it. if (this.get(session)?.source.kind === 'user') return const registration = this.registration if (registration !== undefined && !registration.closing) { - const messages = collectSessionTitleMessages(session.events, event.seq) + const count = this.titleInputOf(session).count const shouldSchedule = registration.provider.automatic === 'all-prompts' - || (session.header.parentSession === undefined && messages.length === 1 && this.get(session) === undefined) + || (session.header.parentSession === undefined && count === 1 && this.get(session) === undefined) if (shouldSchedule) { const state = this.stateFor(session) const revision = this.supersede(state, 'newer user message superseded title generation') @@ -506,9 +534,9 @@ export class SessionTitleService extends Service { const state = session === undefined ? undefined : this.work.get(session) const pending = state?.pending if (session === undefined || state === undefined || pending === undefined) return - const boundary = session.events.findLast(event => event.type === 'step/start' || event.type === 'step/end') + const boundary = this.ctx.sessionProjections.stateOf(session, 'turnBoundary')?.lastStepBoundary const route = session.requestHeader()?.config - if (boundary?.type !== 'step/start' + if (boundary?.kind !== 'start' || boundary.seq <= pending.throughSeq || route?.provider !== options.provider || route.model !== options.model) return @@ -558,7 +586,7 @@ export class SessionTitleService extends Service { this.assertCurrent(session, work) await this.ensureFallback(session) this.assertCurrent(session, work) - const messages = collectSessionTitleMessages(session.events, work.throughSeq) + const messages = titleInputPrefix(this.titleInputOf(session), work.throughSeq) const result = await work.registration.provider.generate({ session, messages, @@ -680,6 +708,11 @@ export class SessionTitleService extends Service { return state } + private titleInputOf(session: Session): TitleInputState { + /* v8 ignore next -- session-title registers its own titleInput unit, so the key is always present */ + return this.ctx.sessionProjections.stateOf(session, 'titleInput') ?? EMPTY_TITLE_INPUT + } + /** Queue detached service work and retain it through service disposal. */ private defer(task: () => Promise): void { const run = Promise.resolve().then(async () => { @@ -757,8 +790,8 @@ export class SessionTitleService extends Service { this.assertServiceActive() const current = this.get(session) if (current !== undefined) return current - const [first] = collectSessionTitleMessages(session.events) - if (first === undefined) return undefined + const first = this.titleInputOf(session).first + if (first === null) return undefined const title = fallbackSessionTitle( first.text, this.config.fallbackMaxWords, diff --git a/packages/session/session-title/src/types.ts b/packages/session/session-title/src/types.ts index ad93926202..77c6d3e78e 100644 --- a/packages/session/session-title/src/types.ts +++ b/packages/session/session-title/src/types.ts @@ -2,19 +2,93 @@ * Pure types of the title domain: the ONE home of the `title` projection-key * declaration, free of this package's host-side value imports (cordis * service, schemastery, the llm seam). Two namespace projections serve it — - * `./types` for host consumers, `./client/types` (the browser half-entry's - * re-export) for client aggregates — with zero content duplication. + * `./types` for host consumers and `./client/types` for client aggregates — + * with zero content duplication. * * @module @deepseek-ai/dsh-session-title/types */ -// Marks this file a module so the declaration below AUGMENTS the projection -// table instead of declaring an ambient module. export {} +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Identifies one session-title provider registration. */ +export type SessionTitleProviderId = Branded<'SessionTitleProviderId'> + +/** Exact auxiliary model route that produced a title. */ +export interface SessionTitleModelProvenance { + /** Registered LLM provider route. */ + readonly provider: string + /** Provider model id. */ + readonly model: string +} + +/** Durable ownership record for an accepted session title. */ +export type SessionTitleSource = + | { readonly kind: 'fallback' } + | { + readonly kind: 'provider' + readonly provider: SessionTitleProviderId + readonly model?: SessionTitleModelProvenance + } + | { + /** Explicit user rename: pins the title — automatic generation stops scheduling. */ + readonly kind: 'user' + } + +/** Payload of the log-only `session/title` event. */ +export interface SessionTitleEventData { + /** Normalized non-empty title text. */ + readonly title: string + /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */ + readonly messageSeqs: number[] + /** Whether the built-in fallback, a registered provider, or the user supplied the title. */ + readonly source: SessionTitleSource +} + +/** Latest folded title plus the title event's durable envelope facts. */ +export interface SessionTitleSnapshot extends SessionTitleEventData { + /** Seq of the latest `session/title` event. */ + readonly eventSeq: number + /** Timestamp of the latest `session/title` event. */ + readonly updatedAt: number +} + +/** Host title projection value. */ +export type TitleProjection = SessionTitleSnapshot + +/** One eligible human text message exposed to title providers. */ +export interface SessionTitleUserMessage { + /** Source `user/message` event seq. */ + readonly seq: number + /** Exact concatenated text-block content. */ + readonly text: string +} + +/** One bounded reverse-linked group of eligible title-input messages. */ +export interface TitleInputChunk { + readonly messages: readonly SessionTitleUserMessage[] + readonly previous: TitleInputChunk | null +} + +/** Eligible title input stored in bounded reverse-linked chunks. */ +export interface TitleInputState { + /** The oldest eligible message, or null before any. */ + readonly first: SessionTitleUserMessage | null + /** Total eligible messages folded so far. */ + readonly count: number + /** Newest eligible message, or null before any. */ + readonly last: SessionTitleUserMessage | null + /** Newest chunk in the reverse-linked list. */ + readonly tail: TitleInputChunk | null +} + declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { - title: string | null + /** Latest logged title, or null. */ + title: TitleProjection | null + /** Eligible human title input. */ + titleInput: TitleInputState } interface SessionProjectionMap { /** diff --git a/packages/session/session-title/tests/persistence.spec.ts b/packages/session/session-title/tests/persistence.spec.ts index a886e62db3..18b5f57aca 100644 --- a/packages/session/session-title/tests/persistence.spec.ts +++ b/packages/session/session-title/tests/persistence.spec.ts @@ -5,6 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SqliteSessionPersistence from '@deepseek-ai/dsh-session-persistence-sqlite' import SessionTitleService, { foldSessionTitle } from '@deepseek-ai/dsh-session-title' @@ -57,6 +58,7 @@ describe('session title persistence round trips', () => { const id = SessionId('title-jsonl') const writer = new Context() await writer.plugin(SessionStore) + await writer.plugin(SessionProjectionRegistry) await writer.plugin(JsonlSessionPersistence, { root, compression: 'none' }) await writer.plugin(SessionTitleService, CONFIG) await appendPersistedTitle(writer, id) @@ -64,6 +66,7 @@ describe('session title persistence round trips', () => { const reader = new Context() await reader.plugin(SessionStore) + await reader.plugin(SessionProjectionRegistry) await reader.plugin(JsonlSessionPersistence, { root, compression: 'none' }) await expectPersistedTitle(reader, id) await reader.fiber.dispose() @@ -76,6 +79,7 @@ describe('session title persistence round trips', () => { const id = SessionId('title-sqlite') const writer = new Context() await writer.plugin(SessionStore) + await writer.plugin(SessionProjectionRegistry) await writer.plugin(SqliteSessionPersistence, { path }) await writer.plugin(SessionTitleService, CONFIG) await appendPersistedTitle(writer, id) @@ -83,6 +87,7 @@ describe('session title persistence round trips', () => { const reader = new Context() await reader.plugin(SessionStore) + await reader.plugin(SessionProjectionRegistry) await reader.plugin(SqliteSessionPersistence, { path }) await expectPersistedTitle(reader, id) await reader.fiber.dispose() diff --git a/packages/session/session-title/tests/projection.spec.ts b/packages/session/session-title/tests/projection.spec.ts index 1198e78a23..d518f9f756 100644 --- a/packages/session/session-title/tests/projection.spec.ts +++ b/packages/session/session-title/tests/projection.spec.ts @@ -1,16 +1,6 @@ -/** - * The `title` projection unit: mounting the title service beside the - * projection registry serves the current normalized title (last-wins over - * session/title events, the same events foldSessionTitle consumes) — null - * before the first title — through the registry snapshot and the change - * feed; compositions without the registry are unaffected; unmounting the - * service removes the key (HMR safety). The bespoke session/title mux frame - * is untouched by this unit (its retirement is the client value-store - * migration's concern). - */ - import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -26,7 +16,6 @@ async function harness(withTitleService: boolean): Promise<{ ctx: Context; sessi return { ctx, session: ctx.sessions.create(SessionId('titled')) } } -/** Append one session/title event directly (the replay-plane shape the unit folds). */ function appendTitle(session: Session, title: string): number { return session.append('session/title', { title, messageSeqs: [1], source: { kind: 'fallback' } }).seq } @@ -46,7 +35,6 @@ describe('title projection unit', () => { }) const firstSeq = appendTitle(session, 'First title') const secondSeq = appendTitle(session, 'Second title') - // Unrelated event: same-reference apply, no notification. session.append('turn/start', { turn: 1 }) expect(changes).toEqual([ { key: 'title', value: 'First title', seq: firstSeq }, @@ -73,4 +61,23 @@ describe('title projection unit', () => { await fiber.dispose() expect('title' in ctx.sessionProjections.snapshot(session).values).toBe(false) }) + + it('keeps thousands of title inputs in bounded reverse-linked chunks without persisting them', async () => { + const { ctx, session } = await harness(false) + session.append('turn/start', { turn: 1 }) + for (let index = 0; index < 5_000; index++) { + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: `message ${String(index)}` }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + } + await ctx.plugin(SessionTitleService, CONFIG) + + const state = ctx.sessionProjections.stateOf(session, 'titleInput') + expect(state?.count).toBe(5_000) + let chunks = 0 + for (let chunk = state?.tail ?? null; chunk !== null; chunk = chunk.previous) chunks += 1 + expect(chunks).toBe(Math.ceil(5_000 / 64)) + expect(ctx.sessionProjections.checkpoint(session).titleInput).toBeUndefined() + }) }) diff --git a/packages/session/session-title/tests/provider.spec.ts b/packages/session/session-title/tests/provider.spec.ts index 7843ff2c22..53fc323d38 100644 --- a/packages/session/session-title/tests/provider.spec.ts +++ b/packages/session/session-title/tests/provider.spec.ts @@ -2,6 +2,8 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import LlmRuntime, { createUserMessage, deepFreeze, markAgentLoopRequest } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SessionTitleService, { SessionTitleProviderId, type SessionTitleProvider, @@ -51,6 +53,7 @@ describe('SessionTitleService Provider lifecycle', () => { it('inherits title events across forks, skips first-prompt retitling, and lets all-messages update later', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const parent = ctx.sessions.create(SessionId('title-parent')) parent.append('turn/start', { @@ -115,6 +118,7 @@ describe('SessionTitleService Provider lifecycle', () => { it('runs a first-prompt provider once after the routed request and retries only through refresh', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const requests: SessionTitleProviderRequest[] = [] const provider: SessionTitleProvider = { @@ -167,9 +171,75 @@ describe('SessionTitleService Provider lifecycle', () => { expect(requests[1]?.messages.map(message => message.seq)).toEqual([first.seq, second.seq]) }) + it('preserves provider input order across bounded title-input chunks', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SessionTitleService, CONFIG) + const session = ctx.sessions.create(SessionId('chunked-provider-input')) + session.append('turn/start', { turn: 1 }) + const messages = Array.from({ length: 70 }, (_, index) => + appendHumanPrompt(session, `Prompt ${String(index)}`)) + await settle() + const generate = vi.fn(async (request: SessionTitleProviderRequest): Promise => ({ + title: 'Chunked history', + messageSeqs: request.messages.map(message => message.seq), + })) + ctx.sessionTitle.register({ + id: SessionTitleProviderId('chunked-provider'), + automatic: 'all-prompts', + generate, + }) + + await ctx.sessionTitle.refresh(session) + + expect(generate).toHaveBeenCalledOnce() + expect(generate.mock.calls[0]?.[0].messages).toEqual(messages.map((message, index) => ({ + seq: message.seq, + text: `Prompt ${String(index)}`, + }))) + }) + + it('cuts a first-message provider request at its scheduled watermark when a newer prompt lands first', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SessionTitleService, CONFIG) + const requests: SessionTitleProviderRequest[] = [] + ctx.sessionTitle.register({ + id: SessionTitleProviderId('watermark-cut'), + automatic: 'first-prompt', + async generate(request) { + requests.push(request) + return { + title: 'Watermarked title', + messageSeqs: request.messages.map(message => message.seq), + } + }, + }) + const session = ctx.sessions.create(SessionId('watermark-cut')) + session.append('turn/start', { + turn: 1, + }) + const first = appendHumanPrompt(session, 'First prompt') + await settle() + appendHumanPrompt(session, 'A newer prompt before the route') + appendRoute(session) + await settle() + + expect(requests).toHaveLength(1) + expect(requests[0]?.messages).toEqual([{ seq: first.seq, text: 'First prompt' }]) + expect(ctx.sessionTitle.get(session)).toMatchObject({ + title: 'Watermarked title', + messageSeqs: [first.seq], + source: { kind: 'provider', provider: SessionTitleProviderId('watermark-cut') }, + }) + }) + it('rejects a second provider and drains stale work when the winner is disposed', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const pending = deferred() let observedSignal: AbortSignal | undefined @@ -221,6 +291,7 @@ describe('SessionTitleService Provider lifecycle', () => { it('supersedes an older all-messages revision and cannot commit an ignored abort', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const firstResult = deferred() const requests: SessionTitleProviderRequest[] = [] @@ -264,6 +335,8 @@ describe('SessionTitleService Provider lifecycle', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SessionTitleService, CONFIG) const requests: SessionTitleProviderRequest[] = [] ctx.sessionTitle.register({ @@ -318,6 +391,8 @@ describe('SessionTitleService Provider lifecycle', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SessionTitleService, CONFIG) const generate = vi.fn(async (request: SessionTitleProviderRequest): Promise => ({ title: 'Unexpected title', @@ -349,6 +424,7 @@ describe('SessionTitleService Provider lifecycle', () => { it('contains automatic failures but lets explicit refresh reject', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const provider: SessionTitleProvider = { diff --git a/packages/session/session-title/tests/rename.spec.ts b/packages/session/session-title/tests/rename.spec.ts index 9452ee8dc4..7701d953b7 100644 --- a/packages/session/session-title/tests/rename.spec.ts +++ b/packages/session/session-title/tests/rename.spec.ts @@ -5,6 +5,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionTitleService, { SessionTitleProviderId, foldSessionTitle, @@ -32,6 +33,7 @@ describe('SessionTitleService.rename', () => { it('appends a normalized user-source title', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('rename-accept')) session.append('turn/start', { turn: 1 }) @@ -57,6 +59,7 @@ describe('SessionTitleService.rename', () => { it('rejects titles that normalize to empty and dead sessions', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('rename-reject')) expect(() => ctx.sessionTitle.rename(session, '  ')).toThrow(/visible characters/) @@ -68,6 +71,7 @@ describe('SessionTitleService.rename', () => { it('pins the title: later user messages schedule no automatic revision; refresh unpins', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const generate = vi.fn(async (request: SessionTitleProviderRequest) => ({ title: 'Provider title', @@ -105,6 +109,7 @@ describe('SessionTitleService.rename', () => { it('fallback-only refresh also unpins: the user title yields to a re-derived fallback', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('rename-unpin-fallback')) session.append('turn/start', { turn: 1 }) @@ -126,6 +131,7 @@ describe('SessionTitleService.rename', () => { it('supersedes in-flight automatic generation: a late provider result cannot override the user title', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) // The provider parks on a test-held deferred so rename lands while its // generation is ACTIVE (not merely scheduled). @@ -165,6 +171,7 @@ describe('SessionTitleService.rename', () => { it('fallback-only refresh keeps the user title when no fallback is derivable', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) // A 3-byte fallback cap cannot hold the 4-byte emoji prompt: the // re-derived fallback is empty, so the pinned title survives the refresh. await ctx.plugin(SessionTitleService, { ...CONFIG, fallbackMaxBytes: 3 }) diff --git a/packages/session/session-title/tests/service-contracts.spec.ts b/packages/session/session-title/tests/service-contracts.spec.ts index 521562c4ed..6ac579bd35 100644 --- a/packages/session/session-title/tests/service-contracts.spec.ts +++ b/packages/session/session-title/tests/service-contracts.spec.ts @@ -2,6 +2,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { Context, type Fiber } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionTitleService, { SessionTitleProviderId, type Config, @@ -29,6 +30,7 @@ async function settle(): Promise { async function setup(config: Config = CONFIG): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, config) return ctx } @@ -241,6 +243,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { it('cancels a queued fallback when the session-title service unloads', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const lifecycle: { fiber?: Fiber; session?: Session; inactiveRefresh?: Promise } = {} ctx.on('internal/plugin', (subject) => { if (subject !== lifecycle.fiber || subject.uid !== null || lifecycle.session === undefined) return @@ -273,6 +276,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { it('suppresses a queued fallback failure after service unload begins', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(SessionTitleService, CONFIG) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const session = startSession(ctx, 'service-unload-started-fallback') @@ -288,6 +292,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { it('aborts pending and active provider work and drains ignored cancellation during service unload', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(SessionTitleService, CONFIG) const result = deferred() const requests: SessionTitleProviderRequest[] = [] diff --git a/packages/session/session-title/tests/session-title.spec.ts b/packages/session/session-title/tests/session-title.spec.ts index bb8ce4625d..905e24aa9b 100644 --- a/packages/session/session-title/tests/session-title.spec.ts +++ b/packages/session/session-title/tests/session-title.spec.ts @@ -2,6 +2,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionTitleService, { SessionTitleProviderId, fallbackSessionTitle, @@ -39,6 +40,7 @@ describe('SessionTitleService', () => { it('logs and folds an immediate fallback after the first eligible human text message', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('fresh')) session.append('turn/start', { @@ -75,6 +77,7 @@ describe('SessionTitleService', () => { it('derives a fallback title from the direct prompt instead of baked prefix context', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('prefixed-title')) session.append('turn/start', { @@ -93,6 +96,7 @@ describe('SessionTitleService', () => { it('waits through synthetic, empty, and non-text messages, then keeps the first fallback', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('eligibility')) session.append('turn/start', { @@ -159,4 +163,10 @@ describe('SessionTitleService', () => { updatedAt: seed.events[1]?.time, }) }) + + it('folds an empty or title-less log to undefined', () => { + expect(foldSessionTitle([])).toBeUndefined() + const empty = Session.create(SessionId('no-title')) + expect(foldSessionTitle(empty.events)).toBeUndefined() + }) }) diff --git a/packages/session/session-title/tsconfig.json b/packages/session/session-title/tsconfig.json index 40937098cb..d4eef17de4 100644 --- a/packages/session/session-title/tsconfig.json +++ b/packages/session/session-title/tsconfig.json @@ -31,6 +31,10 @@ }, { "path": "../session-projection" + }, + { + "path": "../../core/agent" + } ] } diff --git a/packages/shell/bash-sandbox/package.json b/packages/shell/bash-sandbox/package.json index e0614889ac..f7aa203673 100644 --- a/packages/shell/bash-sandbox/package.json +++ b/packages/shell/bash-sandbox/package.json @@ -48,6 +48,7 @@ "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/node-addon-landlock-run": "workspace:^" + "@deepseek-ai/node-addon-landlock-run": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/shell/bash-sandbox/tests/bwrap.e2e.ts b/packages/shell/bash-sandbox/tests/bwrap.e2e.ts index 11f8a17272..5b00eb14ac 100644 --- a/packages/shell/bash-sandbox/tests/bwrap.e2e.ts +++ b/packages/shell/bash-sandbox/tests/bwrap.e2e.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' @@ -42,6 +43,7 @@ async function tempDir(base: string): Promise { async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise { ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) diff --git a/packages/shell/bash-sandbox/tests/landlock.e2e.ts b/packages/shell/bash-sandbox/tests/landlock.e2e.ts index a1f2b3ada7..0160caaa56 100644 --- a/packages/shell/bash-sandbox/tests/landlock.e2e.ts +++ b/packages/shell/bash-sandbox/tests/landlock.e2e.ts @@ -8,6 +8,7 @@ import { Context } from '@deepseek-ai/cordis' import { launcherPath } from '@deepseek-ai/node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' @@ -47,6 +48,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) ;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false } + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) diff --git a/packages/shell/bash-sandbox/tests/partial-landlock.spec.ts b/packages/shell/bash-sandbox/tests/partial-landlock.spec.ts index dea6a41ec1..d665bb4eb5 100644 --- a/packages/shell/bash-sandbox/tests/partial-landlock.spec.ts +++ b/packages/shell/bash-sandbox/tests/partial-landlock.spec.ts @@ -9,6 +9,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run' import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' @@ -51,6 +52,7 @@ ${fatalBranch}exec "$@" async function setup(fatalExit?: number): Promise { const ctx = new Context() contexts.push(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(LocalSandboxProvider, {}) const sandbox = ctx.sandbox as LocalSandboxProvider sandbox.internals = { @@ -68,6 +70,7 @@ async function setup(fatalExit?: number): Promise { async function setupConfiguredRunner(runner: string): Promise { const ctx = new Context() contexts.push(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(LocalSandboxProvider, { runnerCommand: [runner], runnerFailureSignatures: ['configured-runner: fatal'], diff --git a/packages/shell/bash-sandbox/tests/sandbox.spec.ts b/packages/shell/bash-sandbox/tests/sandbox.spec.ts index 750fb870c3..4029cc4f40 100644 --- a/packages/shell/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/shell/bash-sandbox/tests/sandbox.spec.ts @@ -11,6 +11,7 @@ import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { ShellRunResult, CollectedOutput } from '@deepseek-ai/dsh-shell' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' @@ -62,6 +63,7 @@ async function setup( } } const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(FakeSandboxProvider) await ctx.plugin(SandboxPolicyService, { ...mode !== undefined ? { mode } : {}, diff --git a/packages/shell/bash-sandbox/tests/seatbelt.e2e.ts b/packages/shell/bash-sandbox/tests/seatbelt.e2e.ts index 136259468c..7f2a1be03f 100644 --- a/packages/shell/bash-sandbox/tests/seatbelt.e2e.ts +++ b/packages/shell/bash-sandbox/tests/seatbelt.e2e.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' @@ -41,6 +42,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) ;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' } + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) diff --git a/packages/shell/pwsh-sandbox/package.json b/packages/shell/pwsh-sandbox/package.json index 65a3863bec..dc40592bff 100644 --- a/packages/shell/pwsh-sandbox/package.json +++ b/packages/shell/pwsh-sandbox/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/shell/pwsh-sandbox/tests/sandbox.spec.ts b/packages/shell/pwsh-sandbox/tests/sandbox.spec.ts index 22d65b60a9..258e3d950f 100644 --- a/packages/shell/pwsh-sandbox/tests/sandbox.spec.ts +++ b/packages/shell/pwsh-sandbox/tests/sandbox.spec.ts @@ -15,6 +15,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, RunnerFailureRule, SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import { SandboxPwshExecutor } from '../src/index.ts' @@ -64,6 +65,7 @@ async function setup( } } const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(FakeSandboxProvider) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: spillDir }) await ctx.plugin(subprocess) diff --git a/packages/shell/shell/README.i18n.yaml b/packages/shell/shell/README.i18n.yaml index a4e67f3760..013a9e13cb 100644 --- a/packages/shell/shell/README.i18n.yaml +++ b/packages/shell/shell/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/shell/README.md -README.md: a15980ead797a73c18dd6d8825a4b38cf845df60 -README.zh.md: 87a7dffdfb28fcf8a58e6254bf450e513dd458f4 +README.md: 6a7c88bb0ad2612b239d68f79c80351a4316c340 +README.zh.md: 476bd793828b068905aec65d1072a9d78c0fa725 diff --git a/packages/shell/shell/README.md b/packages/shell/shell/README.md index a15980ead7..6a7c88bb0a 100644 --- a/packages/shell/shell/README.md +++ b/packages/shell/shell/README.md @@ -33,7 +33,7 @@ Implementations subclass `ShellExecutor` and implement the abstract methods. Dis `ShellExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxPolicy?) resolves to `ShellExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxPolicy) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxPolicy` is optional on the request and required-but-nullable on the resolved spec: it carries the complete per-call mode and workspace root. The sandbox tool path resolves it from the calling session through `ctx.sandboxPolicy`; a direct sandbox-executor caller falls back to deployment policy, while a non-sandboxing executor carries the field and confines nothing. -The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `ShellRunResult`; `start()` returns `ShellProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `ShellSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [subsystems/shell.md](../../../docs/subsystems/shell.md). +The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `sandboxMode` projection, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `ShellRunResult`; `start()` returns `ShellProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `ShellSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [subsystems/shell.md](../../../docs/subsystems/shell.md). `stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.shellEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). diff --git a/packages/shell/shell/README.zh.md b/packages/shell/shell/README.zh.md index 87a7dffdfb..476bd79382 100644 --- a/packages/shell/shell/README.zh.md +++ b/packages/shell/shell/README.zh.md @@ -33,7 +33,7 @@ `ShellExecRequest`(command、workdir?、timeoutMs?、stdoutMaxBytes?、signal?、stdin?、env?、dshEnv?、sandboxPolicy?)在执行前解析为 `ShellExecSpec`(command、workdir、timeoutMs、stdoutMaxBytes、signal?、stdin?、env?、dshEnv?、sandboxPolicy)。`stdoutMaxBytes` 是受信任前台运行的捕获预算,用于必须解析完整有界 stdout 的消费方;面向模型的 bash 工具不公开该字段。`sandboxPolicy` 在请求上可选,在已解析 spec 上必填但可为 null:它携带完整的每次调用模式与工作区根目录。沙箱工具路径通过 `ctx.sandboxPolicy` 从调用会话解析它;沙箱执行器的直接调用方回退到部署策略,非沙箱执行器则携带该字段但不作限制。 -每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `ShellRunResult`;`start()` 返回 `ShellProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `ShellSandboxInfo`。详见 `src/types.ts` 与 [subsystems/shell.md](../../../docs/subsystems/shell.md)。 +每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`sandboxMode` 投影以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `ShellRunResult`;`start()` 返回 `ShellProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `ShellSandboxInfo`。详见 `src/types.ts` 与 [subsystems/shell.md](../../../docs/subsystems/shell.md)。 `stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.shellEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json index 2232d909e4..c6d671d40f 100644 --- a/packages/shell/tool-bash-persistent/package.json +++ b/packages/shell/tool-bash-persistent/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts index 6d6affdb44..878930426c 100644 --- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts @@ -15,6 +15,7 @@ import * as TerminalLocal from '@deepseek-ai/dsh-terminal-bash' import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' @@ -75,6 +76,7 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => { "- name: '@deepseek-ai/dsh-tools'", "- name: '@deepseek-ai/dsh-terminal'", "- name: '@deepseek-ai/dsh-test-sandbox'", + "- name: '@deepseek-ai/dsh-session-projection'", "- name: '@deepseek-ai/dsh-sandbox-policy'", ' config:', ' mode: danger-full-access', @@ -108,6 +110,7 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => { ['@deepseek-ai/dsh-tools', ToolRuntime], ['@deepseek-ai/dsh-terminal', TerminalSessionService], ['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox], + ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService], ['@deepseek-ai/dsh-subprocess-local', LocalSubprocessRuntime], ['@deepseek-ai/dsh-terminal-bash', TerminalLocal], diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 0de6feef49..78244ea09b 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -68,6 +68,7 @@ "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/shell/tool-bash/tests/integration.spec.ts b/packages/shell/tool-bash/tests/integration.spec.ts index 78a40d6962..c645230ac2 100644 --- a/packages/shell/tool-bash/tests/integration.spec.ts +++ b/packages/shell/tool-bash/tests/integration.spec.ts @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' 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' @@ -26,6 +27,8 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: string) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + // AgentLoop declares the registry as a required injection. + await ctx.plugin(SessionProjectionRegistry) if (sessionRoot !== undefined) { await ctx.plugin(JsonlSessionPersistence, { root: sessionRoot, compression: 'none' }) } diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index 0f2e4d5a20..acc07a9fcd 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -10,6 +10,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' @@ -19,6 +20,7 @@ import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' import { processOutcome } from '../src/background.ts' @@ -188,6 +190,8 @@ async function setupSandboxed(withApproval = false) { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolTasks) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(RecordingSandboxExecutor) if (withApproval) await ctx.plugin(ApprovalService) @@ -201,7 +205,7 @@ function sandboxAgent( ctx?: Context, onAppend?: (type: string) => void, ): Agent { - const events: Array<{ type: string; data?: Record }> = [{ type: 'turn/start' }] + const events: Array<{ type: string; data?: Record }> = [{ type: 'turn/start', data: { turn: 1 } }] if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } }) const id = SessionId('sandbox-session') return { diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index e87a546b62..afc3e4a412 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -50,6 +50,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-shell": "workspace:^", "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -58,6 +59,7 @@ "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index ecd9f5bde5..9d007dd53f 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -27,6 +27,8 @@ import ApprovalService from '@deepseek-ai/dsh-user-approval' import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import { ShellExecutor } from '@deepseek-ai/dsh-shell' import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' @@ -210,6 +212,11 @@ async function setupSandboxed(withApproval = false) { await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolTasks) await ctx.plugin(BashEnvPlugin) + await ctx.plugin(SessionProjectionRegistry) + // The loop's turnBoundary unit (the open-turn fold) is not mounted in this + // bench — the loop itself is not composed. Register its open-turn fold so + // the approval service's turn-enclosure gate reads the seeded log shape. + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(ConfiningFakeBash) if (withApproval) await ctx.plugin(ApprovalService) @@ -229,8 +236,10 @@ function sandboxAgent( ctx?: Context, onAppend?: (type: string) => void, ): Agent { - const events: Array<{ type: string; data?: Record }> = [{ type: 'turn/start' }] - if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } }) + const events: Array<{ type: string; data?: Record; seq?: number }> = [ + { type: 'turn/start', seq: 0, data: { turn: 1 } }, + ] + if (mode !== undefined) events.push({ type: 'sandbox/mode', seq: 1, data: { mode } }) const id = SessionId('sandbox-session') return { id, diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index cdd1bbfa04..a7cb45ceb3 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -37,10 +37,12 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -51,6 +53,7 @@ "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index e2a0cc2dca..ac1bbec5fa 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -7,10 +7,12 @@ import { createHash } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { z as zod } from 'zod' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-projection' import { escapeText, isModelInvocable, @@ -22,7 +24,7 @@ import { } from '@deepseek-ai/dsh-skill' export const name = 'tool-skill' -export const inject = ['agents', 'tools', 'skills'] +export const inject = ['agents', 'tools', 'skills', 'sessionProjections'] const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500 /** @@ -68,12 +70,30 @@ export const Config: z = z.object({ catalogDescriptionMaxLength: z.number().default(DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH), }) +/** + * The skill-catalog projection's state schema — the one definition of the + * state shape; the type is inferred from it (state equals the public shape). + */ +const skillCatalogStateSchema = zod.object({ + digest: zod.string().min(1), + seq: zod.number().int().nonnegative(), +}).nullable() + +type SkillCatalogState = zod.infer + /** * Register the model-facing skill loader and its visibility-matched * durable session catalog. The catalog is emitted only when the calling agent * resolves this plugin's exact tool registration; a restriction or scoped * same-name shadow therefore removes both the schema and its call guidance. */ +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** The latest published skill-catalog message (digest + seq); null before the first publication. */ + skillCatalog: SkillCatalogState + } +} + export function apply(ctx: Context, config: Config = {}): void { const catalogDescriptionMaxLength = config.catalogDescriptionMaxLength ?? DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH assertPositiveInteger('catalogDescriptionMaxLength', catalogDescriptionMaxLength, 3) @@ -203,6 +223,19 @@ export function apply(ctx: Context, config: Config = {}): void { return { kind: 'enter', messages: [...decision.messages, ...injections] } }) + ctx.sessionProjections.register({ + key: 'skillCatalog', + stateVersion: 1, + stateSchema: skillCatalogStateSchema, + init: () => null, + apply: (state, event) => { + if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') return state + const entries = readCatalogEntries(event.data.source) + if (entries === undefined) return state + return { digest: digestCatalogEntries(entries), seq: event.seq } + }, + }) + // Register after the tool so reverse teardown removes guidance first. Exact definition // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. // @@ -226,7 +259,7 @@ export function apply(ctx: Context, config: Config = {}): void { const skills = snapshot.skills.filter(isModelInvocable) const entries = catalogSourceEntries(skills, catalogDescriptionMaxLength) const digest = digestCatalogEntries(entries) - const history = catalogHistory(agent) + const history = catalogHistory(ctx, agent) const existing = catalogMessage(decision.messages) if (history.visibleDigest === digest) { return existing === undefined @@ -358,22 +391,13 @@ function readCatalogEntries(source: unknown): SkillCatalogSource['entries'] | un return readable } -function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } { +function catalogHistory(ctx: Context, agent: Agent): { visibleDigest?: string; published: boolean } { const visible = new Set(agent.session.surface.nodes) - const events = agent.session.events - let published = false - for (let index = events.length - 1; index >= 0; index -= 1) { - // The loop bounds prove the read-only event view contains this index. - // oxlint-disable-next-line typescript/no-non-null-assertion - const event = events[index]! - if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') continue - const entries = readCatalogEntries(event.data.source) - if (entries === undefined) continue - const digest = digestCatalogEntries(entries) - published = true - if (visible.has(event.seq)) return { visibleDigest: digest, published } - } - return { published } + const state = ctx.sessionProjections.stateOf(agent.session, 'skillCatalog') ?? null + if (state === null) return { published: false } + return visible.has(state.seq) + ? { visibleDigest: state.digest, published: true } + : { published: true } } function catalogMessage( diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index d8dd9df2f4..4b63ba8207 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -9,6 +9,7 @@ import { Session, SessionId, type SessionEvent, type UserMessage } from '@deepse import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { agentEvents, Inbox, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SkillRegistry from '@deepseek-ai/dsh-skill' import * as SkillFileSystem from '@deepseek-ai/dsh-skill-filesystem' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' @@ -30,6 +31,7 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) const home = await tempDir('tool-schema') await ctx.plugin(SkillRegistry) await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) @@ -751,6 +754,7 @@ describe('dsh-tool-skill', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SkillRegistry) await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 8a6fba06e7..f2654b3c9e 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts index 9654df5feb..607350a29c 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' import * as acp from '../src/index.ts' @@ -52,6 +53,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive it('drives the real acp-agent example process to answer a prompt', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-acp-e2e-')) ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(acp, { @@ -82,6 +84,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive it('drives the child to do real file work via its own bash tool', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-acp-e2e-')) ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(acp, { diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index c534b8e949..fd66d9368f 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' @@ -43,6 +44,7 @@ interface SetupEnv { */ async function setup(mockEnv: SetupEnv = {}, permission: 'allow' | 'reject' = 'reject') { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(acp, { @@ -222,6 +224,7 @@ describe('cwd resolution', () => { const sentinel = join(tmp, 'spawned') try { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) // A command that would create the sentinel if the child were ever spawned. @@ -241,6 +244,7 @@ describe('cwd resolution', () => { const parentDir = realpathSync(mkdtempSync(join(tmpdir(), 'acp-parent-cwd-'))) try { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(acp, { @@ -269,6 +273,7 @@ describe('cwd resolution', () => { const relative = 'packages/subagent/subagent-acp' const absolute = resolve(relative) const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(acp, { @@ -289,6 +294,7 @@ describe('cwd resolution', () => { // `path.resolve('')` is the process cwd, so an empty string would silently // reintroduce the launch-directory fallback this resolution removed. const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await expect(ctx.plugin(acp, { @@ -310,6 +316,7 @@ describe('cwd resolution', () => { chmodSync(tmp, 0o600) try { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await expect(ctx.plugin(acp, { @@ -329,6 +336,7 @@ describe('cwd resolution', () => { it('rejects a config cwd that is not an accessible directory at load', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await expect(ctx.plugin(acp, { @@ -371,6 +379,7 @@ describe('cwd resolution', () => { const sentinel = join(tmp, 'spawned') try { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} }) @@ -694,6 +703,7 @@ describe('dsh-subagent-acp', () => { const ready = join(tmp, 'trap-armed') try { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(acp, { @@ -727,6 +737,7 @@ describe('dsh-subagent-acp', () => { { disposeGraceMs: MAX_TIMER_DELAY_MS + 1 }, ]) { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', args: [], permission: 'reject', env: {}, ...bad })) @@ -737,6 +748,7 @@ describe('dsh-subagent-acp', () => { it('rejects a startup failure via the provider load path', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(acp, { @@ -868,6 +880,7 @@ describe('dsh-subagent-acp', () => { it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const fiber = await ctx.plugin(acp, { providerName: 'acp', command: 'x', args: [], permission: 'reject', env: {} }) diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index fe974b0344..5bffd3de08 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -63,6 +63,7 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts index 1881e14d33..d9a5c5bc1b 100644 --- a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts +++ b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts @@ -14,6 +14,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as claudeCode from '../src/index.ts' @@ -112,6 +113,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)( } const ctx = new Context() contexts.push(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const handles: SubprocessHandle[] = [] diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index a8e38885ef..ec227e6f4d 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -21,6 +21,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as claudeCode from '../src/index.ts' @@ -180,6 +181,7 @@ interface RealRuntime { async function realRuntime(): Promise { const ctx = new Context() contexts.push(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const handles: SubprocessHandle[] = [] diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 8f1ad34d8a..5eedbc0051 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -26,6 +26,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SubprocessHandle, SubprocessOutcome, @@ -390,6 +391,7 @@ describe('task admission and package contracts', () => { it('registers the default descriptor, validates config, and unregisters on HMR', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const fiber = await ctx.plugin(claudeCode, {}) @@ -421,6 +423,7 @@ describe('task admission and package contracts', () => { it('keeps named instances, runs, and HMR ownership isolated', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const safeChild = fakeChild() @@ -518,6 +521,7 @@ describe('task admission and package contracts', () => { it('rejects duplicate provider names without replacing the first instance', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const firstFiber = await ctx.plugin(claudeCode, { @@ -552,6 +556,7 @@ describe('task admission and package contracts', () => { it('resolves the safe permission default when apply is called directly', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) claudeCode.apply(ctx, { env: {}, disposeGraceMs: 3_000 }) @@ -561,6 +566,7 @@ describe('task admission and package contracts', () => { it('starts through the registered provider with its resolved config and diagnostics', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const child = fakeChild() diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index dbe30e18e1..2ded5fdc95 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -62,6 +62,7 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts index c12e96a306..788ea54cde 100644 --- a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts +++ b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts @@ -15,6 +15,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' @@ -96,6 +97,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)( } const ctx = new Context() contexts.push(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const handles: SubprocessHandle[] = [] diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 3b95de4f79..459c09d845 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -17,6 +17,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' @@ -120,6 +121,7 @@ interface RealRuntime { async function realRuntime(): Promise { const ctx = new Context() contexts.push(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const handles: SubprocessHandle[] = [] diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 6ba6f65e87..236db16e60 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { SubprocessHandle, @@ -399,6 +400,7 @@ describe('task admission and package contracts', () => { it('registers the default descriptor, validates config, and unregisters on HMR', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const fiber = await ctx.plugin(codex, {}) @@ -428,6 +430,7 @@ describe('task admission and package contracts', () => { it('keeps named instances, runs, and HMR ownership isolated', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const safeChild = fakeChild() @@ -524,6 +527,7 @@ describe('task admission and package contracts', () => { it('rejects duplicate provider names without replacing the first instance', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const firstFiber = await ctx.plugin(codex, { @@ -556,6 +560,7 @@ describe('task admission and package contracts', () => { it('resolves the safe permission default when apply is called directly', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) codex.apply(ctx, { env: {}, disposeGraceMs: 3_000 }) @@ -601,6 +606,7 @@ describe('task admission and package contracts', () => { it('requires a parent session cwd without suggesting unsupported config', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const spawn = vi.spyOn(ctx.subprocess, 'spawn') @@ -1796,6 +1802,7 @@ describe('run lifecycle and quiescence', () => { it('uses the registered provider config and logs flattened errors', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const child = fakeChild() diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index 8db10499cd..ad728161d6 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -55,6 +55,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index f4b5bd4267..5314e5da4c 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts @@ -13,6 +13,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import * as sdk from '../src/index.ts' import { @@ -36,6 +37,7 @@ function request(text = 'p', signal = new AbortController().signal) { /** Mount the SDK backend pointed at the fake runtime, scripted by `fakeEnv`. */ async function setup(fakeEnv: Record = {}, config: Partial = {}) { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) // The Config type models the post-validation shape, so the default registry // name is stated here; the Loader-composition fixture omits providerName and @@ -361,6 +363,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('registers under the configured provider name and unregisters on fiber dispose (HMR safety)', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const fiber = await ctx.plugin(sdk, { providerName: 'sdk-hmr', @@ -385,6 +388,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('rejects non-positive timing bounds at load', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const base = { providerName: 'sdk', command: 'true', args: [], provider: 'p', model: 'm', env: {} } await expect(ctx.plugin(sdk, { ...base, shutdownTimeoutMs: 0 })).rejects.toThrow('shutdownTimeoutMs must be a positive finite number') @@ -397,6 +401,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { 'rejects invalid maxTokens %s at load', async (maxTokens) => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await expect(ctx.plugin(sdk, { providerName: 'sdk', @@ -415,6 +420,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { 'defensively rejects invalid maxTokens %s when apply is called directly', async (maxTokens) => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) expect(() => { sdk.apply(ctx, { providerName: 'sdk', @@ -434,6 +440,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('rejects an empty config cwd at load', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await expect(ctx.plugin(sdk, { providerName: 'sdk', diff --git a/packages/subagent/subagent-fork-in-process/package.json b/packages/subagent/subagent-fork-in-process/package.json index f5f0a8b1e6..1e383410a3 100644 --- a/packages/subagent/subagent-fork-in-process/package.json +++ b/packages/subagent/subagent-fork-in-process/package.json @@ -53,6 +53,7 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-in-process-driver": "workspace:^", "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts index 42e5216e47..f14c5d001b 100644 --- a/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts @@ -9,6 +9,7 @@ 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 SubagentRuntime, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as Spawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as fork from '../src/index.ts' @@ -37,6 +38,7 @@ async function setup(script: Script) { await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(Spawn, { providerName: 'spawn' }) await ctx.plugin(fork, { providerName: 'fork' }) diff --git a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts index 6d763b7c0a..46c60498a3 100644 --- a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts +++ b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts @@ -11,6 +11,7 @@ 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 SubagentRuntime, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import * as fork from '../src/index.ts' @@ -44,6 +45,7 @@ async function setup(script: Script) { await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(fork, { providerName: 'fork' }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) @@ -201,6 +203,7 @@ describe('dsh-subagent-fork-in-process', () => { it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(fork, { providerName: 'fork' }) diff --git a/packages/subagent/subagent-in-process-driver/package.json b/packages/subagent/subagent-in-process-driver/package.json index 613eea85ca..593451bf05 100644 --- a/packages/subagent/subagent-in-process-driver/package.json +++ b/packages/subagent/subagent-in-process-driver/package.json @@ -58,6 +58,7 @@ "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts index aa7437be81..4ff72cba21 100644 --- a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts @@ -18,6 +18,7 @@ import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import ApprovalService from '@deepseek-ai/dsh-user-approval' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' @@ -40,6 +41,7 @@ async function setupWalled(script: Script): Promise<{ ctx: Context; parent: Agen const ctx = new Context() contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: workspace }) await ctx.plugin(SandboxedFileSystem, { cwd: workspace }) await ctx.plugin(ToolFs) diff --git a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts index b4c5d5736e..93a141375d 100644 --- a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts @@ -18,6 +18,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import AgentPresets from '@deepseek-ai/dsh-agent-presets' import { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' @@ -39,6 +40,7 @@ async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter; await ctx.plugin(Loader) ctx.loader.builtins.include = Include await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS, includeUserRoot: false }) const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')]) diff --git a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts index 8840e8b101..edc4e38846 100644 --- a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts @@ -12,6 +12,7 @@ import SubagentRuntime, { type ResolvedSubagentStartRequest, type SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Config as ToolConfig, ObjectJsonSchema } from '@deepseek-ai/dsh-tools' import { defineContentToolFixture, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -67,6 +68,7 @@ async function setup(script: Script, options: SetupOptions = {}) { } await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const disposeProvider = ctx.subagents.registerProvider({ name: 'spawn', diff --git a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts index 823890d934..da123ca552 100644 --- a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts @@ -10,6 +10,7 @@ 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 SubagentRuntime, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' @@ -28,6 +29,7 @@ async function setup(script: Script, parentOptions: Partial = {}) await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const adapter = new MockAdapter(script) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/subagent/subagent-spawn-in-process/package.json b/packages/subagent/subagent-spawn-in-process/package.json index 2f317dde8a..a3843f8ff3 100644 --- a/packages/subagent/subagent-spawn-in-process/package.json +++ b/packages/subagent/subagent-spawn-in-process/package.json @@ -55,6 +55,7 @@ "@deepseek-ai/dsh-subagent-in-process-driver": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/subagent/subagent-spawn-in-process/tests/harness.ts b/packages/subagent/subagent-spawn-in-process/tests/harness.ts index d60d8057d3..16e6680432 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/harness.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/harness.ts @@ -1,6 +1,7 @@ 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 { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' @@ -24,6 +25,7 @@ export async function spawnHarness(workdir: string): Promise { // spawned children render it. It stays neutral for both roles; the // delegation nudge lives in the e2e's user prompt and the subagent tool's // own description. + await ctx.plugin(SessionProjectionRegistry) await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona: 'You are a coding agent. Report only when the requested work is done.' }, }) diff --git a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts index 74aefb4ebd..3427a7bf80 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts @@ -11,6 +11,7 @@ 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 SubagentRuntime, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as spawn from '../src/index.ts' import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-in-process-driver' @@ -38,6 +39,7 @@ async function setup(script: Script) { await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(spawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) @@ -290,6 +292,7 @@ describe('dsh-subagent-spawn-in-process', () => { it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) @@ -322,6 +325,7 @@ describe('dsh-subagent-spawn-in-process', () => { await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) @@ -350,6 +354,7 @@ describe('dsh-subagent-spawn-in-process', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index d140507b44..e5024850ef 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md -README.md: 99b28fb7fc62473fa02fef4f3977f29e9935edaa -README.zh.md: 6970c8f21424cb73d170602c52fb98641d5d712d +README.md: 7fb8bc9fe7ffa9732b4ae793c43010066e81325f +README.zh.md: f6c4aef102b0771d981d383d238f4bf4733446eb diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 99b28fb7fc..7fb8bc9fe7 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -97,13 +97,13 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen Continuable children do not create `SubagentRun` or Jobs. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` records the sender without granting authority. Interrupt authority is deliberately wider than delivery authority: a human presents the durable direct-parent address so a live child stays stoppable while its parent Agent is offline, and any exact live ancestor recorded in the Activation's materialization lineage may stop its descendant, because stopping a turn is idempotent and delivers no content. -When `ctx.sessionProjections` is available, the service registers two projection units. `subagentTiming` resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn; while that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata. `subagent` folds the durable identity — mode plus creation label — from `subagent/descriptor` events with the same last-wins reset discipline, so a fork seed's ancestor descriptor stands only until the child's own overrides it; a malformed or unrecognized-version payload folds to the serializable `null` sentinel — indistinguishable from a log with no descriptor, and surviving every JSON push frame so a consumer replaces a stale identity instead of keeping it — and never throws. +The service requires `ctx.sessionProjections` and registers two projection units. `subagentTiming` resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn; while that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata. `subagent` folds the durable identity — mode plus creation label — from `subagent/descriptor` events with the same last-wins reset discipline, so a fork seed's ancestor descriptor stands only until the child's own overrides it; a malformed or unrecognized-version payload folds to the serializable `null` sentinel — indistinguishable from a log with no descriptor, and surviving every JSON push frame so a consumer replaces a stale identity instead of keeping it — and never throws. `registerContinuableSetup()` lets optional packages add child-scoped capabilities without teaching the continuation manager their names. Contributions install synchronously before Activation publication, roll back with failed setup, and are released with the child scope. New grants wait for the next Activation, while contribution removal revokes every resident installation immediately. ## Collection model -The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task and no result promise — a caller sends later work with the `send_message` follow-up tool, and `interrupt()` stops only the current turn without disposing the child, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child; for a cold one, a durable projection-cache row when it serves an own-suffix identity — its `seq` gate proves the value postdates the fork seed, where a child's own descriptor is immutable once appended — else one bounded-concurrency persistence inspection folded through the registry, whose result must still name the enumerated lifecycle (a re-published id degrades to a `corrupt` diagnostic). A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and refines status through the live Agent registry and maps storage-only to its resumable-not-terminal `ready` (`running`/`idle`/`ready`) and walks `listDescendants()` for its `descendants` scope. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, and a missing session store with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. +The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task and no result promise — a caller sends later work with the `send_message` follow-up tool, and `interrupt()` stops only the current turn without disposing the child, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: `stateOf()` for a live child; for a cold one, a durable projection-cache row when it serves an own-suffix identity — its `seq` gate proves the value postdates the fork seed, where a child's own descriptor is immutable once appended — else one bounded-concurrency persistence inspection folded through the registry, whose result must still name the enumerated lifecycle (a re-published id degrades to a `corrupt` diagnostic). A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and refines status through the live Agent registry and maps storage-only to its resumable-not-terminal `ready` (`running`/`idle`/`ready`) and walks `listDescendants()` for its `descendants` scope. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; a missing session store fails with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. Continuable Activations await a best-effort final session flush without treating listener participation as durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 6970c8f214..f6c4aef102 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -97,13 +97,13 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 可继续子级不会创建 `SubagentRun` 或 Task。继续执行管理器为每个驻留子会话直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由确切在线的直接父级身份授权。上报则由确切在线的子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 记录发送方,但不授予权限。中断权限被刻意设计得比投递权限更宽:人类出示持久化直接 parent 地址,因此即使 parent Agent 离线,在线 child 仍可被停止;Activation 物化时记录的任何确切在线 ancestor 也可以停止其后代,因为停止一个轮次是幂等的,且不投递任何内容。 -当 `ctx.sessionProjections` 可用时,服务会注册两个投影单元。`subagentTiming` 会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界;在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。`subagent` 以同样的 last-wins 重置纪律从 `subagent/descriptor` 事件折叠持久化身份——模式与创建标签——因此 fork 种子中的祖先描述符只在 child 自身的描述符覆盖之前有效;畸形或版本无法识别的载荷折叠为可序列化的 `null` 哨兵——与没有描述符的日志不可区分,且能完好通过每个 JSON 推送帧,让消费方以之替换掉手中的陈旧身份而非继续保留该身份——绝不抛错。 +服务要求存在 `ctx.sessionProjections`,并注册两个投影单元。`subagentTiming` 会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界;在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。`subagent` 以同样的 last-wins 重置纪律从 `subagent/descriptor` 事件折叠持久化身份——模式与创建标签——因此 fork 种子中的祖先描述符只在 child 自身的描述符覆盖之前有效;畸形或版本无法识别的载荷折叠为可序列化的 `null` 哨兵——与没有描述符的日志不可区分,且能完好通过每个 JSON 推送帧,让消费方以之替换掉手中的陈旧身份而非继续保留该身份——绝不抛错。 `registerContinuableSetup()` 允许可选包添加子级作用域能力,而无需让继续执行管理器知道这些能力的名称。贡献会在 Activation 发布前同步安装,在设置失败时一并回滚,并随子级作用域释放。新授权须等到下一个 Activation,移除贡献则会立即撤销每个驻留安装项。 ## 收集模型 -面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、也没有结果 promise——调用方通过 `send_message` 后续操作工具发送后续工作,`interrupt()` 只停止当前轮次而不 dispose 子 agent,而持久化子 agent 会话仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀(fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic)。缓存读取抛出异常时,不会据此作出分类判断,因为缓存只是派生数据;静默落到该权威重折。分类结果完全以投影折叠为准;列表操作本身不解析描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,通过在线 Agent 注册表细化状态,并把仅存于存储的状态映射为可恢复而非终态的 `ready`(`running`/`idle`/`ready`),并在 `descendants` scope 下遍历 `listDescendants()`。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整约定见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 +面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、也没有结果 promise——调用方通过 `send_message` 后续操作工具发送后续工作,`interrupt()` 只停止当前轮次而不 dispose 子 agent,而持久化子 agent 会话仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 通过 `stateOf()` 读取;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀(fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic)。缓存读取抛出异常时,不会据此作出分类判断,因为缓存只是派生数据;静默落到该权威重折。分类结果完全以投影折叠为准;列表操作本身不解析描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,通过在线 Agent 注册表细化状态,并把仅存于存储的状态映射为可恢复而非终态的 `ready`(`running`/`idle`/`ready`),并在 `descendants` scope 下遍历 `listDescendants()`。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整约定见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 可继续 Activation 会等待 best-effort 的最终会话 flush,但不会把 listener 参与视为持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。 diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 45b96a2311..7e6d7840d1 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -169,6 +169,8 @@ declare module '@deepseek-ai/cordis' { /** Named provider registry with one-shot runs, durable discovery, and continuable-child operations. */ export class SubagentRuntime extends Service { + static inject = ['sessionProjections'] + private providers = new Map() private continuations: SubagentContinuationManager | undefined /** Deployment contributions composed into unpublished continuable children. */ @@ -194,10 +196,8 @@ export class SubagentRuntime extends Service { if (this.continuations === manager) this.continuations = undefined }, 'subagents.continuationBinding()') }) - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register(subagentTimingProjectionDefinition) - projectionCtx.sessionProjections.register(subagentIdentityProjectionDefinition) - }) + ctx.sessionProjections.register(subagentTimingProjectionDefinition) + ctx.sessionProjections.register(subagentIdentityProjectionDefinition) } /** @@ -334,7 +334,7 @@ export class SubagentRuntime extends Service { * row when the optional cache serves an own-suffix identity (its `seq` * gate proves the value postdates the fork seed, where a child's own * descriptor is immutable once appended), else one persistence inspection - * folded through the registry. The + * folded through the same unit. The * projection fold is the single classification authority; per-child * diagnostics relay a fold that served no identity or a failed inspection, * never a list-time descriptor parse. Absent persistence, enumeration is @@ -349,8 +349,8 @@ export class SubagentRuntime extends Service { * @param signal - caller-owned cancellation forwarded to persistence reads * and observed around every read await. * @returns children and per-child diagnostics ordered by `createdAt`, then id. - * @throws {@link SubagentError} when the projection registry or the session - * store is not mounted, or the caller cancels the listing. + * @throws {@link SubagentError} when the session store is not mounted, or + * the caller cancels the listing. */ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise { return listSubagentChildren(this.ctx, parentSessionId, signal) diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index 95c3be8520..7b45b405bb 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -5,7 +5,7 @@ * mode/label is the registered `subagent` projection unit's value, resolved * down a three-rung ladder: the registry's watermark cache for a live child, * a durable projection-cache row when it serves an own-suffix identity (the - * seq gate), and one persistence inspection folded through the registry + * seq gate), and one persistence inspection folded through the same unit * otherwise, validated against the enumerated lifecycle. The projection fold * is the single classification authority — this module parses no descriptor * itself. Absent persistence, enumeration is live-only: a cold child is @@ -22,6 +22,7 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection' import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache' import { SubagentError } from './error.ts' +import { subagentIdentityProjectionDefinition } from './projection.ts' import type { SubagentIdentityProjection } from './projection-types.ts' /** @@ -77,11 +78,9 @@ export type SubagentListEntry = * Why the candidate has no `child` row: `corrupt` for a settled candidate * whose projection fold served no identity (a missing, malformed, or * unrecognized-version descriptor — deliberately undistinguished), and - * for any candidate whose log makes a registered unit's fold or schema - * throw (deterministic data damage, contained per child); `unavailable` - * when the candidate's persistence inspection failed (retried on the - * next listing). `unsupported` is never produced; it remains in the - * union for consumers that route on it. + * `unavailable` when the candidate's persistence inspection failed + * (retried on the next listing). `unsupported` is never produced; it + * remains in the union for consumers that route on it. */ readonly reason: 'corrupt' | 'unsupported' | 'unavailable' } @@ -117,19 +116,19 @@ interface PositionedCandidate { /** * Enumerate one parent's origin-classified direct children from the * live-preferred merge of `ctx.sessions` and optional session persistence, - * serving each identity from the `subagent` projection unit: the registry's - * watermark snapshot for a live child; for a cold one, a durable + * serving each identity from the `subagent` projection unit: `stateOf()` for + * a live child; for a cold one, a durable * projection-cache row when it serves an own-suffix identity (the seq gate), * else one bounded-concurrency persistence inspection folded through the - * registry. + * same unit. * @see SubagentRuntime.listChildren for the public cancellation and failure contract. * @param ctx - context carrying the session store, the projection registry, * optional persistence, and the optional projection cache. * @param parentSessionId - parent session whose direct children are listed. * @param signal - caller-owned cancellation observed around every persistence read. * @returns children and per-child diagnostics ordered by `createdAt`, then id. - * @throws {@link SubagentError} when the projection registry or the session - * store is not mounted, or the caller cancels the listing. + * @throws {@link SubagentError} when the session store is not mounted or the + * caller cancels the listing. */ export async function listChildren( ctx: Context, @@ -185,16 +184,7 @@ async function prepareListing( ctx: Context, signal: AbortSignal | undefined, ): Promise { - const projections = ctx.get('sessionProjections') - // Checked before any read, even with zero candidates: mode/label are the - // row's strong contract, so a missing fold capability is a deterministic - // deployment configuration error, never an empty success. - if (projections === undefined) { - throw new SubagentError( - 'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', - 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE', - ) - } + const projections = ctx.sessionProjections // Strict global read, never the `ctx.sessions` property proxy: the proxy is // caller-scope bound, so a consumer plugin without its own `sessions` // injection (the model-facing tool, the API proxy) would throw on access. @@ -257,17 +247,7 @@ async function resolveCandidateRows( // The registry's watermark cache serves the live value with zero log // reads; a live child without an identity yet is the creation window // before the establishing provider appends its descriptor. - let identity: SubagentIdentityProjection | null | undefined - try { - identity = projections.snapshot(candidate.live).values.subagent - } catch { - // The snapshot folds EVERY registered unit over this child's log, so - // any unit's fold or schema can reject damaged payloads. That is - // deterministic data damage in this one child; it degrades to one - // corrupt diagnostic instead of failing the whole listing. - rows[index] = { kind: 'diagnostic', id: childId, reason: 'corrupt' } - return - } + const identity = projections.stateOf(candidate.live, 'subagent') // The unit's serializable no-value sentinel is `null`; `undefined` can // only mean the key was dropped at a JSON boundary. Both are no value. if (identity === undefined || identity === null) return @@ -283,7 +263,7 @@ async function resolveCandidateRows( async () => { for (let job = queue.shift(); job !== undefined; job = queue.shift()) { rows[job.index] = await resolveColdIdentity( - persistence, projections, cache, job.header, + persistence, cache, job.header, subagentParents.has(job.header.id), signal, ) } @@ -338,16 +318,13 @@ function compareCorpusRecords(a: CorpusRecord, b: CorpusRecord): number { /** * Resolve one cold candidate down the remaining ladder: a durable * projection-cache row when it serves an own-suffix identity (the seq gate), - * otherwise one persistence inspection folded through the projection - * registry (the same detached recipe the API proxy uses for detached session - * projections). A failed inspection is one transient `unavailable` row - * retried on the next listing; an inspection naming another lifecycle, and a - * settled log the fold cannot identify — or that makes any registered unit - * throw — are final, so they report `corrupt`. + * otherwise one persistence inspection folded through the same subagent unit. + * A failed inspection is one transient `unavailable` row retried on the next + * listing; an inspection naming another lifecycle or a settled log the fold + * cannot identify reports `corrupt`. */ async function resolveColdIdentity( persistence: SessionPersistence, - projections: SessionProjectionRegistry, cache: SessionProjectionCache | undefined, header: SessionHeader, hasChildren: boolean, @@ -393,16 +370,11 @@ async function resolveColdIdentity( if (!sameLifecycle(inspected.meta, header)) { return { kind: 'diagnostic', id: childId, reason: 'corrupt' } } - let identity: SubagentIdentityProjection | null | undefined - try { - identity = projections.restore({}, inspected.events, 0).snapshot.values.subagent - } catch { - // The restore folds EVERY registered unit over this child's log, so any - // unit's fold or schema can reject damaged payloads — deterministic data - // damage in this one child, contained as its own corrupt diagnostic. - return { kind: 'diagnostic', id: childId, reason: 'corrupt' } + let identity: SubagentIdentityProjection | null = subagentIdentityProjectionDefinition.init() + for (const event of inspected.events) { + identity = subagentIdentityProjectionDefinition.apply(identity, event) } - if (identity === undefined || identity === null) { + if (identity === null) { return { kind: 'diagnostic', id: childId, reason: 'corrupt' } } return childRow(childId, identity, 'inactive', hasChildren) diff --git a/packages/subagent/subagent/src/projection-types.ts b/packages/subagent/subagent/src/projection-types.ts index 046c32e91d..b07ec8e8a2 100644 --- a/packages/subagent/subagent/src/projection-types.ts +++ b/packages/subagent/subagent/src/projection-types.ts @@ -47,9 +47,7 @@ export type SubagentIdentityProjection = } declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionMap { - /** Active-turn duration for a descriptor-backed subagent session. */ - subagentTiming: SubagentTimingProjection + interface SessionProjectionStateMap { /** * Identity of a descriptor-backed subagent session. `null` ⟺ no valid * descriptor (missing, malformed, or unrecognized-version — deliberately @@ -61,4 +59,8 @@ declare module '@deepseek-ai/dsh-session-projection/types' { */ subagent: SubagentIdentityProjection | null } + interface SessionProjectionMap { + /** Active-turn duration for a descriptor-backed subagent session. */ + subagentTiming: SubagentTimingProjection + } } diff --git a/packages/subagent/subagent/src/projection.ts b/packages/subagent/subagent/src/projection.ts index 288949ac26..d0185144d3 100644 --- a/packages/subagent/subagent/src/projection.ts +++ b/packages/subagent/subagent/src/projection.ts @@ -12,42 +12,35 @@ import { foldSubagentDescriptor } from './descriptor.ts' import type { SubagentDescriptorData } from './descriptor.ts' import type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts' -/** Fold state for a subagent's latest timing snapshot. */ -export interface TimingState { - /** Milliseconds accumulated across completed post-descriptor turns. */ - settledMs: number - /** Current open interval kept paired inside the fold. */ - active?: { since: number; through: number } | undefined - /** Latest pre-descriptor turn start, promoted when the child's own descriptor arrives. */ - pendingTurnStart?: number | undefined - /** Whether the fold has crossed a descriptor in this logical log. */ - descriptorSeen: boolean -} +const settledMsField = z.number().int().nonnegative() + +const activeField = z.object({ + since: z.number().int().nonnegative(), + through: z.number().int().nonnegative(), +}).strict().optional() // Zod's optional output includes explicit `undefined`; with // exactOptionalPropertyTypes the public interface permits omission only. const projectionSchema = z.object({ - settledMs: z.number().int().nonnegative(), - active: z.object({ - since: z.number().int().nonnegative(), - through: z.number().int().nonnegative(), - }).strict().optional(), + settledMs: settledMsField, + active: activeField, }).strict() as unknown as z.ZodType -const timingStateSchema: z.ZodType = z.object({ - settledMs: z.number().int().nonnegative(), - active: z.object({ - since: z.number().int().nonnegative(), - through: z.number().int().nonnegative(), - }).strict().optional(), +const timingStateSchema = z.object({ + settledMs: settledMsField, + active: activeField, + /** Latest pre-descriptor turn start, promoted when the child's own descriptor arrives. */ pendingTurnStart: z.number().int().nonnegative().optional(), + /** Whether the fold has crossed a descriptor in this logical log. */ descriptorSeen: z.boolean(), }).strict() +/** Fold state for descriptor-relative active-turn duration. */ +export type TimingState = z.infer + declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { subagentTiming: TimingState - subagent: IdentityState } } @@ -61,6 +54,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { */ export const subagentTimingProjectionDefinition = { key: 'subagentTiming', + stateVersion: 2, stateSchema: timingStateSchema, init: () => ({ descriptorSeen: false, settledMs: 0 }), apply: (state, event) => { @@ -102,14 +96,8 @@ export const subagentTimingProjectionDefinition = { ...(state.active === undefined ? {} : { active: state.active }), }), }, - stateVersion: 2, } satisfies ProjectionDefinition<'subagentTiming', TimingState> -interface IdentityState { - /** Identity from the last valid descriptor; absent before one, and after an invalid one. */ - identity?: SubagentIdentityProjection | undefined -} - // The cast bridges only the optional-label arm: Zod's optional output // includes explicit `undefined`, which exactOptionalPropertyTypes excludes // from the public interface. The no-value state itself is the serializable @@ -130,10 +118,6 @@ const identityValueSchema = z.discriminatedUnion('mode', [ const identitySchema = identityValueSchema.nullable() -const identityStateSchema: z.ZodType = z.object({ - identity: identityValueSchema.optional(), -}).strict() - /** Interpret one `subagent/descriptor` event's identity; no value when the payload cannot be trusted. */ function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | undefined { let descriptor: SubagentDescriptorData | undefined @@ -167,15 +151,13 @@ function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | u */ export const subagentIdentityProjectionDefinition = { key: 'subagent', - stateSchema: identityStateSchema, - init: () => ({}), + stateVersion: 3, + stateSchema: identitySchema, + persist: true, + init: () => null, apply: (state, event) => { if (event.type !== 'subagent/descriptor') return state const identity = descriptorIdentity(event) - return identity === undefined ? {} : { identity } + return identity === undefined ? null : identity }, - wire: { viewSchema: identitySchema, view: state => state.identity ?? null }, - // Bumped when the identity gained its `seq` field: an older checkpoint row - // would replay into a value the schema rejects, so it must refold instead. - stateVersion: 2, -} satisfies ProjectionDefinition<'subagent', IdentityState> +} satisfies ProjectionDefinition<'subagent', SubagentIdentityProjection | null> diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts index 3f2c0b1e31..eb69b47119 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -15,13 +15,14 @@ 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 SandboxPolicyService, { effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' -import { SessionId } from '@deepseek-ai/dsh-session' +import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' -import ApprovalService, { effectiveApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import ApprovalService from '@deepseek-ai/dsh-user-approval' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import SubagentRuntime from '../src/index.ts' @@ -39,6 +40,7 @@ async function setup(script: Script) { const ctx = new Context() contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) const root = mkdtempSync(join(tmpdir(), 'dsh-continuation-inherit-')) roots.push(root) await ctx.plugin(JsonlSessionPersistence, { root }) @@ -73,6 +75,14 @@ function policyEvents(events: readonly SessionEvent[]) { return events.filter(event => event.type === 'sandbox/mode' || event.type === 'approval/policy') } +function foldedSandboxMode(ctx: Context, id: SessionId, events: readonly SessionEvent[]): unknown { + return ctx.sessionProjections.snapshot(Session.create(id, events)).values.sandboxMode +} + +function foldedApprovalPolicy(ctx: Context, id: SessionId, events: readonly SessionEvent[]): unknown { + return ctx.sessionProjections.snapshot(Session.create(id, events)).values.approvalPolicy +} + describe('continuable policy inheritance', () => { it('seeds the parent sandbox override and pins approval to never', { timeout: 20_000 }, async () => { const { ctx, parent } = await setup([textResponse('child done')]) @@ -98,8 +108,8 @@ describe('continuable policy inheritance', () => { { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, ]) // Durable: a reload folds the same effective policy. - expect(effectiveSandboxMode(loaded.events)).toBe('danger-full-access') - expect(effectiveApprovalPolicy(loaded.events)).toBe('never') + expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBe('danger-full-access') + expect(foldedApprovalPolicy(ctx, started.childId, loaded.events)).toBe('never') expect(ctx.approval.overrideOf(parent.session)).toBeUndefined() const runtimeContext = loaded.events.find( (event): event is SessionEvent<'user/message'> => event.type === 'user/message' @@ -125,7 +135,7 @@ describe('continuable policy inheritance', () => { await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) expect(ctx.sandboxPolicy.overrideOf(parent.session)).toBe('danger-full-access') - expect(effectiveSandboxMode(loaded.events)).toBe('read-only') + expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBe('read-only') }) it('leaves an unswitched sandbox on the deployment default while still pinning approval', { timeout: 20_000 }, async () => { @@ -138,7 +148,7 @@ describe('continuable policy inheritance', () => { expect(policyEvents(loaded.events)).toMatchObject([ { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, ]) - expect(effectiveSandboxMode(loaded.events)).toBeUndefined() + expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBeNull() }) it('pins approval after the fork prefix of an unswitched fork child', { timeout: 20_000 }, async () => { @@ -157,7 +167,7 @@ describe('continuable policy inheritance', () => { expect(policyEvents(loaded.events)).toMatchObject([ { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, ]) - expect(effectiveSandboxMode(loaded.events)).toBeUndefined() + expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBeNull() }) it('lets a later child-side switch win over the delegation snapshot', { timeout: 20_000 }, async () => { @@ -177,7 +187,7 @@ describe('continuable policy inheritance', () => { await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) - expect(effectiveSandboxMode(loaded.events)).toBe('read-only') + expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBe('read-only') }) it('cold-resumes on the persisted snapshot without re-capturing the parent', { timeout: 20_000 }, async () => { @@ -199,7 +209,7 @@ describe('continuable policy inheritance', () => { expect(loaded.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([ { data: { mode: 'read-only', source: 'delegation' } }, ]) - expect(effectiveSandboxMode(loaded.events)).toBe('read-only') + expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBe('read-only') // The approval pin is seeded once at creation, never re-appended on resume. expect(loaded.events.filter(event => event.type === 'approval/policy')).toMatchObject([ { data: { policy: 'never', source: 'delegation' } }, @@ -226,6 +236,6 @@ describe('continuable policy inheritance', () => { { data: { mode: 'workspace-write' } }, { data: { mode: 'read-only', source: 'delegation' } }, ]) - expect(effectiveSandboxMode(loaded.events)).toBe('read-only') + expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBe('read-only') }) }) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index d7ef69a593..7d85b5a595 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -9,6 +9,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm' @@ -60,6 +61,9 @@ afterEach(() => { async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } = {}) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + // The registry is a required injection of AgentLoop and SubagentRuntime + // (both register projection units on activation). + await ctx.plugin(SessionProjectionRegistry) let disposePersistence: (() => Promise) | undefined let root: string | undefined if (options.persistence !== false) { @@ -404,6 +408,7 @@ describe('SubagentRuntime.startContinuable', () => { const fresh = new Context() await mountAgentLoopTestDependencies(fresh) + await fresh.plugin(SessionProjectionRegistry) await fresh.plugin(JsonlSessionPersistence, { root: root! }) await fresh.plugin(AgentLoop, { agents: [] }) await fresh.plugin(SubagentRuntime) @@ -2431,6 +2436,7 @@ describe('continuable errors', () => { const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }]) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-')) roots.push(root) await ctx.plugin(JsonlSessionPersistence, { root }) diff --git a/packages/subagent/subagent/tests/invariant.spec.ts b/packages/subagent/subagent/tests/invariant.spec.ts index 91200abf8d..597c22223c 100644 --- a/packages/subagent/subagent/tests/invariant.spec.ts +++ b/packages/subagent/subagent/tests/invariant.spec.ts @@ -10,9 +10,13 @@ import type { } from '@deepseek-ai/dsh-subagent' import * as SubagentInvariant from '@deepseek-ai/dsh-subagent/invariant' import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' async function setup(): Promise { const ctx = new Context() + // The registry is a required injection of SubagentRuntime (its projection + // units register in the constructor). + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(InvariantRegistry) await ctx.plugin(SubagentInvariant) diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index e9a5633189..018b11f16e 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -2,16 +2,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { z } from 'zod' import { Context } from '@deepseek-ai/cordis' import { createUserMessage } from '@deepseek-ai/dsh-llm' +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 SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import SessionProjectionCache from '@deepseek-ai/dsh-session-projection-cache' import Storage from '@deepseek-ai/dsh-storage' import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' @@ -55,8 +54,10 @@ async function setup( await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(SubagentFork, { providerName: 'fork' }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) - return { ctx, parent } + const parent = ctx.get('agentLoop') === undefined + ? undefined + : ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + return { ctx, parent: parent as Agent } } const testSignal = new AbortController().signal @@ -117,40 +118,6 @@ function descriptorPayload(label: string, version = SUBAGENT_DESCRIPTOR_VERSION) return { version, mode: 'continuable' as const, provider: 'spawn', label } } -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - subagentListHostileProbe: { poisoned?: boolean | undefined } - } - interface SessionProjectionMap { - /** Test-only hostile probe proving per-child isolation of foreign unit failures. */ - subagentListHostileProbe: null - } -} - -/** - * A foreign registered unit that rejects one specific child's log at view - * time: `apply` never throws (the eager drive passes every committed event - * through it), while the poisoned state detonates only when a listing read - * folds or serves this child through the registry. - */ -const hostileProjectionDefinition = { - key: 'subagentListHostileProbe', - stateSchema: z.object({ poisoned: z.boolean().optional() }), - init: () => ({}), - apply: (state, event) => - event.type === 'subagent/descriptor' && (event.data as { label?: string }).label === 'poison me' - ? { poisoned: true } - : state, - wire: { - viewSchema: z.null(), - view: (state) => { - if (state.poisoned === true) throw new Error('hostile unit rejects the poisoned log') - return null - }, - }, - stateVersion: 1, -} satisfies ProjectionDefinition<'subagentListHostileProbe', { poisoned?: boolean | undefined }> - describe('SubagentRuntime.listChildren', () => { it('lists live children without persistence, query services, or the continuation runtime', async () => { const ctx = new Context() @@ -180,11 +147,10 @@ describe('SubagentRuntime.listChildren', () => { ]) }) - it('fails loud when the projection registry is not mounted, even with no children', async () => { - const { ctx, parent } = await setup([], { sessionProjections: false }) - await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow( - expect.objectContaining({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' }) as Error, - ) + it('never activates without the projection registry (mandatory seam)', async () => { + const { ctx } = await setup([], { sessionProjections: false }) + expect(ctx.get('subagents')).toBeUndefined() + expect(ctx.get('agentLoop')).toBeUndefined() }) it('fails loud when the session store is not mounted', async () => { @@ -595,47 +561,6 @@ describe('SubagentRuntime.listChildren', () => { ]) }) - it('contains a foreign unit failure during a cold fold to that child as corrupt', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - ctx.sessionProjections.register(hostileProjectionDefinition) - const healthy = await startChild(ctx, parent, 'healthy sibling') - const poisoned = await authorChild(ctx, '00000000-0000-4000-8000-00000000d00d', { - parentSession: parent.id, - origin: 'subagent', - }, childEvents(descriptorPayload('poison me'))) - // The subagent unit itself folds this child cleanly; the FOREIGN unit's - // view throws, and that damage stays contained to the one child. - const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toContainEqual({ kind: 'diagnostic', id: poisoned, reason: 'corrupt' }) - expect(entries).toContainEqual({ - kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable', - activity: 'inactive', hasChildren: false, - }) - }) - - it('contains a foreign unit failure during a live snapshot to that child as corrupt', async () => { - const { ctx, parent } = await setup([]) - ctx.sessionProjections.register(hostileProjectionDefinition) - const poisonedId = SessionId('live-poisoned-child') - const poisoned = ctx.sessions.create(poisonedId, { - meta: { parentSession: parent.id, origin: 'subagent' }, - }) - poisoned.append('turn/start', { turn: 1 }) - poisoned.append('subagent/descriptor', descriptorPayload('poison me')) - const healthyId = SessionId('live-healthy-child') - const healthy = ctx.sessions.create(healthyId, { - meta: { parentSession: parent.id, origin: 'subagent' }, - }) - healthy.append('turn/start', { turn: 1 }) - healthy.append('subagent/descriptor', descriptorPayload('live healthy')) - const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toContainEqual({ kind: 'diagnostic', id: poisonedId, reason: 'corrupt' }) - expect(entries).toContainEqual({ - kind: 'child', id: healthyId, label: 'live healthy', mode: 'continuable', - activity: 'running', hasChildren: false, - }) - }) - it('fails the whole enumeration when the persisted listing itself fails', async () => { const { ctx, parent } = await setup([textResponse('done')]) await startChild(ctx, parent, 'never listed') @@ -775,8 +700,9 @@ describe('SubagentRuntime.listChildren', () => { // The child's turn/end and disposal are the cache's mandatory checkpoint // points; both writes are fail-soft asynchronous, so wait for the row. const header = (await ctx.sessionPersistence.list()).find(meta => meta.id === childId) - await vi.waitFor(() => { - expect(ctx.sessionProjectionCache.cachedSnapshot(header!)?.values.subagent).toBeDefined() + await vi.waitFor(async () => { + const cut = ctx.sessionProjectionCache.cachedSnapshot(header!) + expect(cut?.values.subagent).toBeDefined() }, { timeout: 5_000 }) const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect') await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ @@ -972,10 +898,14 @@ describe('SubagentRuntime.listChildren', () => { }) it('SubagentError from listChildren is typed with its stable code', async () => { - const { ctx, parent } = await setup([], { sessionProjections: false }) - const caught: unknown = await ctx.subagents.listChildren(parent.id).catch((error: unknown) => error) + const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SubagentRuntime) + const caught: unknown = await ctx.subagents + .listChildren(SessionId('no-store-parent')) + .catch((error: unknown) => error) expect(caught).toBeInstanceOf(SubagentError) - expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') + expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE') }) }) @@ -1211,10 +1141,8 @@ describe('SubagentRuntime.listDescendants', () => { expect(list).not.toHaveBeenCalled() }) - it('fails loud when the projection registry is not mounted', async () => { - const { ctx, parent } = await setup([], { sessionProjections: false }) - await expect(ctx.subagents.listDescendants(parent.id)).rejects.toThrow( - expect.objectContaining({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' }) as Error, - ) + it('does not activate when the projection registry is not mounted', async () => { + const { ctx } = await setup([], { sessionProjections: false }) + expect(ctx.subagents).toBeUndefined() }) }) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 05e9785611..c998ca2c02 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -19,6 +19,7 @@ import SubagentRuntime, { type SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' function fakeParent(id = 'parent-1'): Agent { return { id: SessionId(id) } as unknown as Agent @@ -64,6 +65,9 @@ class StubProvider implements SubagentProvider { async function service(): Promise<{ ctx: Context; subagents: SubagentRuntime }> { const ctx = new Context() + // The registry is a required injection of SubagentRuntime (its projection + // units register in the constructor). + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) return { ctx, subagents: ctx.subagents } } diff --git a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts index 4eb8008c3b..4c3ba93d13 100644 --- a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts @@ -247,6 +247,7 @@ describe('dsh-tool-subagent-control/list-agents', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const fiber = await ctx.plugin(tool) expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(true) diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index 0636254841..9abe4a5a88 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -207,6 +207,7 @@ describe('dsh-tool-subagent-control', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const fiber = await ctx.plugin(tool) expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true) diff --git a/packages/subagent/tool-subagent-report/package.json b/packages/subagent/tool-subagent-report/package.json index dbadc60e88..9eb9252af1 100644 --- a/packages/subagent/tool-subagent-report/package.json +++ b/packages/subagent/tool-subagent-report/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 28d6068757..90fec316e0 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -13,6 +13,7 @@ import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import * as control from '@deepseek-ai/dsh-tool-subagent-control' import { textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -68,6 +69,7 @@ async function setup(options: { load?: boolean; config?: tool.Config } = {}) { const root = mkdtempSync(join(tmpdir(), 'dsh-tool-subagent-report-')) await ctx.plugin(JsonlSessionPersistence, { root }) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) const fiber = options.load === false diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 8de6a04739..2549e46501 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -59,6 +59,7 @@ "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts b/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts index 67ebfb1388..d54fa53c3b 100644 --- a/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts +++ b/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { type Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { SessionId } from '@deepseek-ai/dsh-session' import * as scripted from './scripted-provider.ts' @@ -21,6 +22,7 @@ function baseRequest(over: Partial = {}): SubagentStartReq async function mount(config: Partial = {}): Promise { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await scripted.mountScriptedProvider(ctx, { name: 'mock', ...config }) return ctx @@ -89,6 +91,7 @@ describe('scripted subagent provider fixture', () => { it('unregisters with its owning fixture fiber', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const fiber = await scripted.mountScriptedProvider(ctx, { name: 'mock' }) expect(ctx.subagents.list()).toEqual(['mock']) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 1ee5e40228..f731622d2b 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -13,6 +13,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' @@ -41,6 +42,7 @@ async function setup(toolConfig: tool.Config, mockConfig: Partial = const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await mock.mountScriptedProvider(ctx, { name: 'mock', ...mockConfig }) await ctx.plugin(tool, toolConfig) @@ -208,6 +210,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await mock.mountScriptedProvider(ctx, { name: 'spawn', reply: 'from spawn' }) await mock.mountScriptedProvider(ctx, { name: 'acp', reply: 'from acp' }) @@ -229,6 +232,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'weird', @@ -255,6 +259,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'capture', @@ -285,6 +290,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'bare', @@ -320,6 +326,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) // Tool first: no provider yet — the tool must be absent, not broken. // Direct apply (schema bypass): also covers the waiting-note's default @@ -337,6 +344,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) tool.apply(ctx, { provider: 'later-continuable', @@ -353,6 +361,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const backend = await mock.mountScriptedProvider(ctx, { name: 'mock' }) // fresh conversation (descriptor: false) await ctx.plugin(tool, { provider: 'mock' }) @@ -372,6 +381,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) // Arm 1: a mounted tool and its prompt section die with the plugin fiber; @@ -408,6 +418,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await mock.mountScriptedProvider(ctx, { name: 'mock' }) await ctx.plugin(tool, { provider: 'mock' }) @@ -444,6 +455,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', @@ -467,6 +479,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', @@ -491,6 +504,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', @@ -519,6 +533,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', @@ -546,6 +561,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', @@ -585,6 +601,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', @@ -649,6 +666,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'capture2', @@ -706,6 +724,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'capture3', @@ -736,6 +755,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'capture4', @@ -761,6 +781,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'p', @@ -1088,6 +1109,7 @@ describe('dsh-tool-subagent continuable background mode', () => { roots.push(root) await ctx.plugin(JsonlSessionPersistence, { root }) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(LocalJobRegistry) @@ -1292,6 +1314,7 @@ describe('depth budget configuration', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'capture', @@ -1330,6 +1353,7 @@ describe('depth budget configuration', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'no-depth', @@ -1346,6 +1370,7 @@ describe('depth budget configuration', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'external', diff --git a/packages/terminal/terminal-bash/package.json b/packages/terminal/terminal-bash/package.json index b1b8ae6a59..b6042e122b 100644 --- a/packages/terminal/terminal-bash/package.json +++ b/packages/terminal/terminal-bash/package.json @@ -39,7 +39,8 @@ "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" diff --git a/packages/terminal/terminal-bash/src/index.ts b/packages/terminal/terminal-bash/src/index.ts index d0b2d6a564..a9a17950b4 100644 --- a/packages/terminal/terminal-bash/src/index.ts +++ b/packages/terminal/terminal-bash/src/index.ts @@ -11,7 +11,8 @@ import { TerminalBackendCleanupError } from '@deepseek-ai/dsh-terminal' import type { TerminalBackend, TerminalBackendSpawnSpec } from '@deepseek-ai/dsh-terminal' import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' -import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import type {} from '@deepseek-ai/dsh-session-projection' import { type Config, type ResolvedConfig, validateConfig } from './config.ts' import { LocalPtySession } from './session.ts' import { CONTROLLED_PROMPT } from './sanitize.ts' @@ -21,12 +22,13 @@ export type { Config as TerminalLocalConfig } from './config.ts' /** Cordis plugin name. */ export const name = 'terminal-bash' -/** Required services: PTY registry, shared confinement policy, and process substrate. */ -export const inject = ['terminals', 'sandboxPolicy', 'subprocess'] +/** Required services: terminal registry, shared confinement policy, projection registry, and process substrate. */ +export const inject = ['terminals', 'sandboxPolicy', 'sessionProjections', 'subprocess'] interface SandboxModeFenceState { pty: Context['terminals'] sandboxPolicy: Context['sandboxPolicy'] + sessionProjections: Context['sessionProjections'] } const sandboxModeFences = new WeakMap() @@ -36,15 +38,21 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void { if (existing !== undefined) { existing.pty = ctx.terminals existing.sandboxPolicy = ctx.sandboxPolicy + existing.sessionProjections = ctx.sessionProjections return } - const state: SandboxModeFenceState = { pty: ctx.terminals, sandboxPolicy: ctx.sandboxPolicy } + const state: SandboxModeFenceState = { + pty: ctx.terminals, + sandboxPolicy: ctx.sandboxPolicy, + sessionProjections: ctx.sessionProjections, + } sandboxModeFences.set(owner, state) owner.ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] if (session !== owner.session || event.type !== 'sandbox/mode') return - const currentMode = effectiveSandboxMode(session.events) ?? state.sandboxPolicy.defaultMode + const folded = state.sessionProjections.stateOf(session, 'sandboxMode') ?? null + const currentMode = folded ?? state.sandboxPolicy.defaultMode if (event.data.mode === currentMode || !state.pty.hasOwnerActivity(owner)) return throw new Error( `cannot change sandbox mode from "${currentMode}" to "${event.data.mode}" while persistent terminal sessions are open or being created; wait for creation to settle and close them first`, diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 2a4d7848df..015b6562ed 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -7,6 +7,7 @@ import AgentRegistry, { Inbox, type Agent } from '@deepseek-ai/dsh-agent' import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TerminalSessionService, { TerminalBackendCleanupError, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import { BashTerminalBackend } from '@deepseek-ai/dsh-terminal-bash' import * as ptyLocal from '@deepseek-ai/dsh-terminal-bash' @@ -99,7 +100,7 @@ function stubLocalSession(initialize: () => Promise = () => Promise.resolv } function registerStubLocalBackend(ctx: Context, createSession: () => LocalPtySession) { - return ctx.inject(['terminals', 'sandbox', 'sandboxPolicy', 'subprocess'], (providerCtx) => { + return ctx.inject(['terminals', 'sandbox', 'sandboxPolicy', 'sessionProjections', 'subprocess'], (providerCtx) => { providerCtx.terminals.registerBackend(new BashTerminalBackend( providerCtx, { ...config(), backendType: 'stub' }, @@ -113,6 +114,7 @@ describe('BashTerminalBackend startup rollback', () => { it('rejects pre-aborted setup and empty sandbox argv', async () => { const ctx = new Context() await ctx.plugin(EmptySandbox) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: '/tmp' }) const backend = new BashTerminalBackend(ctx, config(), async () => terminalHandle()) const controller = new AbortController() @@ -124,6 +126,7 @@ describe('BashTerminalBackend startup rollback', () => { it('closes failed startup and aggregates cleanup failure', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) const spawnTerminal = async (): Promise => terminalHandle() @@ -149,6 +152,7 @@ describe('BashTerminalBackend startup rollback', () => { it('starts startup rollback when cancellation wins a stalled initialization', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) const initialization = Promise.withResolvers() const initializationStarted = Promise.withResolvers() @@ -176,6 +180,7 @@ describe('BashTerminalBackend startup rollback', () => { it('wraps confined argv, scrubs the environment, and returns initialized sessions', async () => { const ctx = new Context() await ctx.plugin(RecordingSandbox) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/workspace' }) const terminal = terminalHandle() let spawned: SubprocessTerminalSpawnSpec | undefined @@ -223,6 +228,7 @@ describe('BashTerminalBackend startup rollback', () => { it('resolves session mode and root together before wrapping the shell', async () => { const ctx = new Context() await ctx.plugin(RecordingSandbox) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: '/deployment-fallback' }) const terminal = terminalHandle() let spawned: SubprocessTerminalSpawnSpec | undefined @@ -254,6 +260,7 @@ describe('BashTerminalBackend startup rollback', () => { it('rejects a confined spawn without a sandbox provider', async () => { const confinedCtx = new Context() + await confinedCtx.plugin(SessionProjectionRegistry) await confinedCtx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/workspace' }) const confined = new BashTerminalBackend( confinedCtx, @@ -269,6 +276,7 @@ describe('BashTerminalBackend startup rollback', () => { it('forwards terminal allocation cancellation directly', async () => { const ctx = new Context() await ctx.plugin(EmptySandbox) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) const publishedController = new AbortController() @@ -313,6 +321,7 @@ describe('BashTerminalBackend startup rollback', () => { it('composes the default local session around a spawned terminal', async () => { const ctx = new Context() await ctx.plugin(EmptySandbox) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' }) const output = new PassThrough() const outcome = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>() @@ -346,7 +355,7 @@ describe('terminal-bash plugin shape', () => { const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(ptyLocal) as Record expect(unwrapped.name).toBe('terminal-bash') - expect(unwrapped.inject).toEqual(['terminals', 'sandboxPolicy', 'subprocess']) + expect(unwrapped.inject).toEqual(['terminals', 'sandboxPolicy', 'sessionProjections', 'subprocess']) expect(unwrapped.Config).toBeDefined() }) @@ -354,6 +363,7 @@ describe('terminal-bash plugin shape', () => { const ctx = new Context() await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) await ctx.plugin(StubSubprocessRuntime) const fiber = await ctx.plugin(ptyLocal, config()) @@ -368,6 +378,7 @@ describe('terminal-bash plugin shape', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) await ctx.plugin(EmptySandbox) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) await ctx.plugin(StubSubprocessRuntime) await ctx.plugin(ptyLocal, config()) @@ -385,6 +396,7 @@ describe('terminal-bash plugin shape', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) await ctx.plugin(RecordingSandbox) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) await ctx.plugin(StubSubprocessRuntime) @@ -434,6 +446,7 @@ describe('terminal-bash plugin shape', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) await ctx.plugin(RecordingSandbox) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) await ctx.plugin(StubSubprocessRuntime) diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index c7af8668a3..e4f5712656 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -11,6 +11,7 @@ import type { TerminalSendOperation } from '@deepseek-ai/dsh-terminal' import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as ptyLocal from '@deepseek-ai/dsh-terminal-bash' @@ -57,6 +58,7 @@ async function harness( await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) await ctx.plugin(PassthroughSandbox) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: root }) await ctx.plugin(LocalSubprocessRuntime) const fiber = await ctx.plugin(ptyLocal, { diff --git a/packages/terminal/tool-terminal/package.json b/packages/terminal/tool-terminal/package.json index 9fe3475e74..d43bf9737a 100644 --- a/packages/terminal/tool-terminal/package.json +++ b/packages/terminal/tool-terminal/package.json @@ -63,6 +63,7 @@ "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts index ada46ef78f..279198a8a1 100644 --- a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts +++ b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts @@ -16,6 +16,7 @@ import TerminalSessionService from '@deepseek-ai/dsh-terminal' import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as TerminalLocal from '@deepseek-ai/dsh-terminal-bash' import * as ToolPty from '@deepseek-ai/dsh-tool-terminal' @@ -69,6 +70,7 @@ suite('terminal real Loader composition through cordis.yml', () => { "- name: '@deepseek-ai/dsh-tools'", "- name: '@deepseek-ai/dsh-terminal'", "- name: '@deepseek-ai/dsh-test-sandbox'", + "- name: '@deepseek-ai/dsh-session-projection'", "- name: '@deepseek-ai/dsh-sandbox-policy'", ' config:', ' mode: danger-full-access', @@ -96,6 +98,7 @@ suite('terminal real Loader composition through cordis.yml', () => { ['@deepseek-ai/dsh-tools', ToolRuntime], ['@deepseek-ai/dsh-terminal', TerminalSessionService], ['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox], + ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService], ['@deepseek-ai/dsh-subprocess-local', LocalSubprocessRuntime], ['@deepseek-ai/dsh-terminal-bash', TerminalLocal], diff --git a/packages/todo/tool-todo/README.i18n.yaml b/packages/todo/tool-todo/README.i18n.yaml index 469cb71023..e386d0cfcb 100644 --- a/packages/todo/tool-todo/README.i18n.yaml +++ b/packages/todo/tool-todo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/todo/tool-todo/README.md -README.md: 4848758dd8f2049221901744e5d09cef2dd31591 -README.zh.md: f3e9e50e1c548dc8c0d01b0bae5c91dd813b5899 +README.md: dfd9b59c19a65f128068514ddef2a4b078ff99cb +README.zh.md: 055ac3f20a599a75d302435459e5c8040dade30b diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index 4848758dd8..dfd9b59c19 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -30,7 +30,7 @@ The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; ## Session projection -When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)), this package registers the `todos` projection unit under an injected child: `init` = `null` (no write yet), `apply` = take the whole list from each `todo/write` and clear to `null` on each `turn/start` (standing plan; `turn/end` keeps the finished checklist; every other event returns the same state reference), `view` = identity, `stateVersion` = 2. The key merges into `SessionProjectionMap` here (via the Service Definition package's `/types` outlet); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected. Lifetime rationale: [todo plan clears on next turn](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md). +This package requires `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)) and registers the `todos` projection unit: `init` = `null` (no write yet), `apply` = take the whole list from each `todo/write` and clear to `null` on each `turn/start` (standing plan; `turn/end` keeps the finished checklist; every other event returns the same state reference), client view = identity, `stateVersion` = 2. The key merges into both `SessionProjectionStateMap` and `SessionProjectionMap`; carriers serve the client value on the history tail page and the `session/projection` push frame. Lifetime rationale: [todo plan clears on next turn](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md). ## Export shape diff --git a/packages/todo/tool-todo/README.zh.md b/packages/todo/tool-todo/README.zh.md index f3e9e50e1c..055ac3f20a 100644 --- a/packages/todo/tool-todo/README.zh.md +++ b/packages/todo/tool-todo/README.zh.md @@ -30,7 +30,7 @@ ## 会话投影 -当组合挂载了 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包在一个注入的子插件中注册 `todos` 投影单元:`init` = `null`(尚无写入)、`apply` = 从每个 `todo/write` 取整表,并在每个 `turn/start` 清为 `null`(当前有效计划;`turn/end` 保留刚完成的清单;其余事件都返回同一个状态引用)、`view` = 恒等、`stateVersion` = 2。该键在本包中合并进 `SessionProjectionMap`(经 Service Definition 包的 `/types` 出口);框架驱动该单元,载体通过历史尾页与 `session/projection` 推送帧提供该值。未挂载注册表的组合不受影响。生命周期理由见 [在下一轮次清空 todo 计划](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)。 +本包要求组合提供 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)),并注册 `todos` 投影单元:`init` = `null`(尚无写入)、`apply` = 从每个 `todo/write` 取整表,并在每个 `turn/start` 清为 `null`(当前有效计划;`turn/end` 保留刚完成的清单;其余事件都返回同一个状态引用)、客户端视图 = 恒等、`stateVersion` = 2。该 key 同时合并进 `SessionProjectionStateMap` 与 `SessionProjectionMap`;载体通过历史尾页与 `session/projection` 推送帧提供客户端值。生命周期理由见 [在下一轮次清空 todo 计划](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)。 ## 导出形状 diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index 9c0a8e45a7..ef82b2f19d 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -11,16 +11,15 @@ import { z as zod } from 'zod' import type { ZodType } from 'zod' import { defineTool } from '@deepseek-ai/dsh-tools' import type { TodoItem } from '@deepseek-ai/dsh-session' -// Type-only: resolves ctx.sessionProjections for the optional unit child. import type {} from '@deepseek-ai/dsh-session-projection' // The `todos` projection-key declaration lives in src/types.ts (its one home); // this re-export projects the type face onto the package root AND keeps the // module edge in the emitted index.d.ts, so aggregate programs consuming the -// declarations still receive the SessionProjectionMap merge. +// declarations still receive the SessionProjectionStateMap merge. export type * from './types.ts' export const name = 'tool-todo' -export const inject = ['tools'] +export const inject = ['tools', 'sessionProjections'] /** The valid {@link TodoItem} statuses, as a runtime set for input narrowing. */ const STATUSES = ['pending', 'in_progress', 'completed'] as const @@ -120,31 +119,24 @@ const todosProjectionSchema: ZodType = zod.union([ ]) /** - * Register the `todo_write` tool on `ctx.tools` and, when the session-projection seam is composed, - * the `todos` unit. - * @param ctx - registrant context carrying the tool registry. + * Register the `todo_write` tool and `todos` projection. + * @param ctx - registrant context carrying the tool and projection registries. * @param config - deployment's explicit todo policy. */ export function apply(ctx: Context, config: Config): void { const allowParallel = config.allowParallelInProgress - // The unit child activates only when a projection registry is composed - // (headless assemblies without the seam stay unaffected). Standing-plan fold: - // latest whole todo/write list, cleared by the next turn/start (turn/end keeps - // the finished checklist visible); null before the first write or after a - // later turn begins; every other event returns the same state reference. - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register<'todos', TodoItem[] | null>({ - key: 'todos', - stateSchema: todosProjectionSchema, - init: () => null, - apply: (state, event) => { - if (event.type === 'todo/write') return event.data.todos - if (event.type === 'turn/start') return null - return state - }, - wire: { viewSchema: todosProjectionSchema, view: state => state }, - stateVersion: 2, - }) + ctx.sessionProjections.register({ + key: 'todos', + stateVersion: 2, + stateSchema: todosProjectionSchema, + init: () => null, + apply: (state, event) => { + if (event.type === 'todo/write') return event.data.todos + if (event.type === 'turn/start') return null + return state + }, + wire: { viewSchema: todosProjectionSchema, view: state => state }, + }) ctx.tools.register(defineTool({ name: 'todo_write', diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index 264731bdbc..0692772ea9 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -5,6 +5,7 @@ import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' 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 ToolTodo from '@deepseek-ai/dsh-tool-todo' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -17,6 +18,7 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent async function harness(adapter: MockAdapter): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index 945b2e84e5..e68ee90b3a 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -15,6 +15,7 @@ import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' let root: string | undefined @@ -58,6 +59,7 @@ async function boot(configLines: readonly string[]): Promise { "- name: '@deepseek-ai/dsh-agent'", "- name: '@deepseek-ai/dsh-system-prompt'", "- name: '@deepseek-ai/dsh-tools'", + "- name: '@deepseek-ai/dsh-session-projection'", "- name: '@deepseek-ai/dsh-tool-todo'", ...configLines.length > 0 ? [' config:', ...configLines] : [], '', @@ -72,6 +74,7 @@ async function boot(configLines: readonly string[]): Promise { ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], + ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-tool-todo', ToolTodo], ]) ctx.loader.internal = { diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index dd959f2f9f..f6babf0157 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -4,6 +4,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { TodoItem } from '@deepseek-ai/dsh-session' import { type Agent } from '@deepseek-ai/dsh-agent' @@ -30,6 +31,7 @@ async function setup(allowParallelInProgress: boolean): Promise { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(tool, { allowParallelInProgress }) return ctx } @@ -213,6 +215,7 @@ describe('dsh-tool-todo', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(tool, { allowParallelInProgress: true }) expect(ctx.tools.schemas().some(s => s.name === 'todo_write')).toBe(true) await fiber.dispose() @@ -223,13 +226,13 @@ describe('dsh-tool-todo', () => { // A default export would make Loader unwrap only apply and drop `inject`. expect('default' in tool).toBe(false) expect(tool.name).toBe('tool-todo') - expect(tool.inject).toEqual(['tools']) + expect(tool.inject).toEqual(['tools', 'sessionProjections']) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(tool) as Record expect(unwrapped).toBe(tool) expect(unwrapped.name).toBe('tool-todo') - expect(unwrapped.inject).toEqual(['tools']) + expect(unwrapped.inject).toEqual(['tools', 'sessionProjections']) expect(typeof unwrapped.apply).toBe('function') }) }) diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index a795d22ca8..b87c901ef6 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/package.json @@ -59,6 +59,7 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/workflow/tool-ralph/tests/integration.spec.ts b/packages/workflow/tool-ralph/tests/integration.spec.ts index e6de6c38ef..771a57afb0 100644 --- a/packages/workflow/tool-ralph/tests/integration.spec.ts +++ b/packages/workflow/tool-ralph/tests/integration.spec.ts @@ -5,6 +5,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-in-process-driver' import * as spawn from '@deepseek-ai/dsh-subagent-spawn-in-process' @@ -20,6 +21,7 @@ async function mountRalph(script: MockScript, config: toolRalph.Config) { const ctx = new Context() const adapter = new MockAdapter(script) await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) await ctx.plugin(spawn, { providerName: 'spawn' }) @@ -57,6 +59,7 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { toolCallResponse('round-2', STRUCTURED_OUTPUT_TOOL, finalReport), ]) await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) await ctx.plugin(spawn, { providerName: 'spawn' }) diff --git a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts index 7c6f02e36c..69a699a245 100644 --- a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts +++ b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts @@ -4,6 +4,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import type { Agent } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubagentCapabilities, SubagentProvider, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -78,6 +79,7 @@ async function setup(options?: SetupOptions) { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const provider = options?.provider === false ? undefined : options?.provider ?? new StubProvider() if (provider !== undefined) ctx.subagents.registerProvider(provider) diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 2bd0cd13a9..595459bc2e 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -59,6 +59,7 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 938f9a5502..26847b7098 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -15,6 +15,7 @@ import SubagentRuntime from '@deepseek-ai/dsh-subagent' import WorkerThreadWorkflowEngine from '@deepseek-ai/dsh-workflow-worker-thread' import * as toolWorkflow from '../src/index.ts' import { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' const testToolSignal = new AbortController().signal @@ -420,6 +421,7 @@ describe('dsh-tool-workflow', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spawn', diff --git a/packages/workflow/workflow-worker-thread/package.json b/packages/workflow/workflow-worker-thread/package.json index 8e2c094009..24fd17ae98 100644 --- a/packages/workflow/workflow-worker-thread/package.json +++ b/packages/workflow/workflow-worker-thread/package.json @@ -64,6 +64,7 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/cordis": "workspace:^", - "tsx": "^4.19.2" + "tsx": "^4.19.2", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/workflow/workflow-worker-thread/tests/built-worker.e2e.ts b/packages/workflow/workflow-worker-thread/tests/built-worker.e2e.ts index 31998a22cf..f8714a5ac5 100644 --- a/packages/workflow/workflow-worker-thread/tests/built-worker.e2e.ts +++ b/packages/workflow/workflow-worker-thread/tests/built-worker.e2e.ts @@ -22,10 +22,12 @@ describe.skipIf(!existsSync(builtIndex) || !existsSync(builtWorker))('built work try { await writeFile(driver, ` import { Context } from '@deepseek-ai/cordis' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import WorkerThreadWorkflowEngine from '@deepseek-ai/dsh-workflow-worker-thread' const ctx = new Context() +await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) let selectedStarts = 0 ctx.subagents.registerProvider({ diff --git a/packages/workflow/workflow-worker-thread/tests/integration.spec.ts b/packages/workflow/workflow-worker-thread/tests/integration.spec.ts index 33f5575693..686c342ad5 100644 --- a/packages/workflow/workflow-worker-thread/tests/integration.spec.ts +++ b/packages/workflow/workflow-worker-thread/tests/integration.spec.ts @@ -7,6 +7,7 @@ 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' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import * as spawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-in-process-driver' @@ -35,6 +36,7 @@ async function setup(script: Script) { const adapter = new MockAdapter(script) await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) await ctx.plugin(spawn, { providerName: 'spawn' }) diff --git a/packages/workflow/workflow-worker-thread/tests/source-worker.compat.spec.ts b/packages/workflow/workflow-worker-thread/tests/source-worker.compat.spec.ts index 8d33e373c7..19c7e79f69 100644 --- a/packages/workflow/workflow-worker-thread/tests/source-worker.compat.spec.ts +++ b/packages/workflow/workflow-worker-thread/tests/source-worker.compat.spec.ts @@ -9,6 +9,7 @@ import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubagentProvider } from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import WorkerThreadWorkflowEngine from '../src/index.ts' import { SessionId } from '@deepseek-ai/dsh-session' @@ -18,6 +19,7 @@ vi.setConfig({ testTimeout: 30_000 }) it('runs the default config through the source worker', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) const subagents = await ctx.plugin(SubagentRuntime) const provider: SubagentProvider = { name: 'spawn', diff --git a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts index b87d0eb62a..daf7516ca2 100644 --- a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts +++ b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts @@ -7,6 +7,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import * as Spawn from '@deepseek-ai/dsh-subagent-spawn-in-process' @@ -32,6 +33,7 @@ async function harness(): Promise { const built = new Context() await built.plugin(LlmRuntime) await built.plugin(SessionStore) + await built.plugin(SessionProjectionRegistry) await built.plugin(SystemPrompt) await built.plugin(ToolRuntime) await built.plugin(AgentRegistry) diff --git a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts index 5130071362..9e90c400b4 100644 --- a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts +++ b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts @@ -13,6 +13,7 @@ import WorkerThreadWorkflowEngine, { type Config } from '../src/index.ts' import { workerSpawnEnv } from '../src/host.ts' import { HostToWorkerType, WorkerToHostType } from '../src/protocol.ts' import { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' /** A minimal parent stand-in: the engine only threads it through to the provider. */ function fakeParent(): Agent { @@ -143,6 +144,7 @@ interface SetupOptions { async function setup(options?: SetupOptions) { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const provider = new StubProvider( 'stub', @@ -456,6 +458,7 @@ describe('dsh-workflow-worker-thread', () => { it('a child result REJECTION crosses back as a fatal AGENT_RESULT error (a broken provider is not a failed child)', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const provider: SubagentProvider = { name: 'rejecting', @@ -515,6 +518,7 @@ describe('dsh-workflow-worker-thread', () => { it('a child whose dispose() throws synchronously cannot wedge the script (the host acks anyway)', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const provider: SubagentProvider = { name: 'bad-dispose', @@ -537,6 +541,7 @@ describe('dsh-workflow-worker-thread', () => { it('a child dispose() rejecting an UNRENDERABLE value still acks — the containment warn is total', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const provider: SubagentProvider = { name: 'coercion-trap-dispose', @@ -887,6 +892,7 @@ describe('dsh-workflow-worker-thread', () => { it('the settle-reap fires the request signal too: a provider honoring ONLY the signal winds its stray down promptly', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const aborted: string[] = [] const provider: SubagentProvider = { @@ -1182,6 +1188,7 @@ describe('dsh-workflow-worker-thread', () => { it('refuses and disposes a provider run that becomes ready after its real worker dies', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const requested = Promise.withResolvers() const ready = Promise.withResolvers() @@ -1244,6 +1251,7 @@ describe('dsh-workflow-worker-thread', () => { it('a worker that exits before settling reports an error result and reaps its children', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) // The child's dispose() REJECTS on top of the worker death: the reap // must contain it (warn, not crash) while still emptying the registry. @@ -1428,6 +1436,7 @@ describe('dsh-workflow-worker-thread', () => { it('unregisters ctx.workflowEngine when the engine fiber is disposed (HMR safety)', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const fiber = await ctx.plugin(WorkerThreadWorkflowEngine, {}) expect(ctx.get('workflowEngine')).toBeDefined() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36b32a5873..47cf941286 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -804,6 +804,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -3252,6 +3255,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../llm/token-meter @@ -3286,6 +3292,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../llm/token-meter @@ -3295,6 +3304,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3329,6 +3341,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -3366,15 +3381,24 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../session-query/session-query + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../../session/session-title packages/context/time-context: dependencies: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3400,6 +3424,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -3412,6 +3439,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3428,6 +3458,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-shell': specifier: workspace:^ version: link:../../shell/shell @@ -3452,6 +3485,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../system-prompt @@ -3489,6 +3525,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3514,6 +3553,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings @@ -3634,6 +3676,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../system-prompt @@ -3782,6 +3827,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../session-query/session-query @@ -3867,6 +3915,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../session/session-title @@ -3962,6 +4013,9 @@ importers: '@deepseek-ai/dsh-session-persistence-sqlite': specifier: workspace:^ version: link:../../session/session-persistence-sqlite + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent @@ -4002,6 +4056,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent @@ -4332,6 +4389,9 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection packages/fs/tool-fs: dependencies: @@ -4384,6 +4444,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -4479,6 +4542,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -4512,6 +4578,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection packages/goal/goal: dependencies: @@ -4579,6 +4648,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -4601,6 +4673,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../goal @@ -4613,6 +4688,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -4647,6 +4725,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -4723,6 +4804,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-shell': specifier: workspace:^ version: link:../../shell/shell @@ -4775,6 +4859,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-shell': specifier: workspace:^ version: link:../../shell/shell @@ -4875,6 +4962,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets @@ -5149,6 +5239,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -5156,6 +5249,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -5171,6 +5267,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -5380,6 +5479,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -5423,6 +5525,9 @@ importers: '@deepseek-ai/dsh-session-persistence-sqlite': specifier: workspace:^ version: link:../../session/session-persistence-sqlite + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -5693,6 +5798,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings @@ -5783,6 +5891,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -5799,6 +5910,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -5939,6 +6053,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent @@ -6036,6 +6153,9 @@ importers: '@deepseek-ai/dsh-session-persistence-sqlite': specifier: workspace:^ version: link:../../session/session-persistence-sqlite + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../session-query @@ -6052,6 +6172,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -6067,6 +6190,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../session-query @@ -6121,6 +6247,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -6341,6 +6470,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -6372,6 +6507,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -6381,6 +6519,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../session-projection '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../session-title @@ -6403,6 +6544,9 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -6415,6 +6559,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../session-projection '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../session-title @@ -6539,6 +6686,9 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-shell': specifier: workspace:^ version: link:../shell @@ -6597,6 +6747,9 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-shell': specifier: workspace:^ version: link:../shell @@ -6698,6 +6851,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-shell': specifier: workspace:^ version: link:../shell @@ -6753,6 +6909,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local @@ -6784,6 +6943,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -6808,6 +6970,9 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-shell': specifier: workspace:^ version: link:../shell @@ -6894,6 +7059,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -6913,6 +7081,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../skill @@ -7151,6 +7322,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -7200,6 +7374,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -7246,6 +7423,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -7292,6 +7472,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -7329,6 +7512,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -7377,6 +7563,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -7429,6 +7618,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -7481,6 +7673,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -7572,6 +7767,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -7639,6 +7837,9 @@ importers: packages/terminal/terminal-bash: dependencies: + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -7713,6 +7914,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local @@ -8255,6 +8459,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent @@ -8298,6 +8505,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent @@ -8365,6 +8575,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 2f1abb810d..2b8b66160d 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -468,7 +468,6 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(LocalJobRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(SessionStore) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(ToolSubagentControl) await ctx.plugin(ToolSubagentListAgents) }, @@ -669,6 +668,7 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES // plugins mounted still tears the context down (no leaked executor/provider // fiber) — the repo's "dispose must reach quiescence" rule. try { + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime, entry.toolsConfig ?? {}) await entry.mount(ctx) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 3c7038937c..f8fe47c8bb 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -628,27 +628,27 @@ { "doc": "docs/subsystems/session-title.md", "symbol": "SessionTitleProviderId", - "source": "packages/session/session-title/src/index.ts" + "source": "packages/session/session-title/src/types.ts" }, { "doc": "docs/subsystems/session-title.md", "symbol": "SessionTitleModelProvenance", - "source": "packages/session/session-title/src/index.ts" + "source": "packages/session/session-title/src/types.ts" }, { "doc": "docs/subsystems/session-title.md", "symbol": "SessionTitleSource", - "source": "packages/session/session-title/src/index.ts" + "source": "packages/session/session-title/src/types.ts" }, { "doc": "docs/subsystems/session-title.md", "symbol": "SessionTitleEventData", - "source": "packages/session/session-title/src/index.ts" + "source": "packages/session/session-title/src/types.ts" }, { "doc": "docs/subsystems/session-title.md", "symbol": "SessionTitleSnapshot", - "source": "packages/session/session-title/src/index.ts" + "source": "packages/session/session-title/src/types.ts" }, { "doc": "docs/subsystems/session-title.md", @@ -658,7 +658,7 @@ { "doc": "docs/subsystems/session-title.md", "symbol": "SessionTitleUserMessage", - "source": "packages/session/session-title/src/index.ts" + "source": "packages/session/session-title/src/types.ts" }, { "doc": "docs/subsystems/session-title.md",