refactor(agent): persist model selection in session state

This commit is contained in:
_Kerman committed 2026-10-15 19:05:09 +08:00
1 parent 755c52fd77
commit e39b5f58c2
152 files changed
+1438 -1340

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ try {
}
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
{ messages: [], turn: 1, step: 1, signal: new AbortController().signal },
{ callConfig: { provider: 'mock', model: 'mock' }, messages: [], turn: 1, step: 1, signal: new AbortController().signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
const catalog = decision.kind === 'enter'
@@ -12,7 +12,7 @@ import SessionStore, {
SessionLogOffset,
SessionPreparation,
} from '@deepseek-ai/dsh-session'
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type AgentHandle } from '@deepseek-ai/dsh-agent'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
@@ -30,7 +30,6 @@ import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline
import TokenMeter from '@deepseek-ai/dsh-token-meter'
// These Host-only adapters have no public Node export and are compiled into the benchmark worker.
import { SessionHistoryController } from '../../packages/api/session-controller/src/history.ts'
import { installModelSelectionProjection } from '../../packages/api/session-controller/src/model-selection-projection.ts'
import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts'
import { SYNTHETIC_SESSION_ID } from './session-open.constants.ts'
@@ -132,7 +131,7 @@ async function installProjectionSet(ctx: Context, agentLoopOwnsBoundary: boolean
// Mirrors projection owners mounted by the base and web-app bundles without timing profile boot.
if (!agentLoopOwnsBoundary) ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
ctx.sessionProjections.register(agentPresetProjectionDefinition)
installModelSelectionProjection(ctx)
if (!agentLoopOwnsBoundary) await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionTitleService, {
fallbackMaxWords: 5,
fallbackMaxBytes: 40,
+4 -4
View File
@@ -24,11 +24,11 @@
en: e89802f066e4955d
zh: ea2fb4bef94e086d
/deepseek-harness-architecture/turn-flow:
en: e6402b28c0ed15fa
zh: 6c07be9047b42056
en: 20efe97dd5a9c7c1
zh: ab9c17b18b1d4182
/deepseek-harness-architecture/session-log:
en: a2e35c43438fec6d
zh: e27bdac8e9feff32
en: ece5a91f0c437ff8
zh: baa13dfdd23d48f8
/deepseek-harness-architecture/capability-seams:
en: a5bc20c3f2a1b370
zh: 91773a281410b2f4
+3 -3
View File
@@ -83,7 +83,7 @@ A **step** is one model request plus the tools it calls. A **turn** is zero or m
```text
turn/start
claim next-step input plus one queued message
assemble prompt sections + tool schemas
snapshot Session model selection; assemble prompt sections + tool schemas
-> agent/pre-step; project runtime context at fallback
reject | enter(messages, startsRequestSeries?)
reject, or a first enter rewritten empty -> close the turn with no step
@@ -107,7 +107,7 @@ turn/end
One inbox feeds the driver; injected context waits for a waking message. AgentLoop’s durable `inbox` projection exposes pending input without live Agents.
`agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. Prepared call capabilities govern prompt admission, not preceding `request/context`. Each attempt refreshes registered runtime facts, reconciles the bound prompt, admits users once, and restores removed runtime context, logs header/context as needed, and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes append non-empty updates after cached history, including supported tool updates; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)).
The loop snapshots Session model selection before assembly and shares its route with `agent/pre-step`; retries retain that selection. `agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` adjusts controls without changing the route, and `prepareCall()` binds the adapter before the system prompt and accepted users are committed; cancellation during either async phase commits neither. Prepared call capabilities govern prompt admission, not preceding `request/context`. Each attempt refreshes registered runtime facts, reconciles the bound prompt, admits users once, and restores removed runtime context, logs header/context as needed, and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The system prompt travels as `system/message` history: empty text clears it, capable routes append updates, and other routes consolidate it at the first system node. [Agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) owns prompt admission and replacement details.
The loop sends immutable requests with live cancellation and reuses freeze evidence for frozen identities; [agent-loop](../packages/core/agent-loop/README.md) owns construction and cancellation causes.
@@ -123,7 +123,7 @@ Session consumers know only the current logical format. Header-only `stat` and `
**Model-visible means logged.** Every model request must be reconstructable from the log. New model-visible inputs require session events. Plugins changing existing message content register [pure message projections](subsystems/session.md#plugin-owned-message-projections); detached readers supply the same definitions explicitly. Tool changes are capability-independent; [Session tool history](../packages/core/session/README.md) supplies provider declarations.
**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()`. A host reader either requires this service during activation or fails explicitly when the registry or required key is absent. Contributors may retain `ctx.inject(['sessionProjections'], ...)` registration without silently defaulting a missing host value. The agent loop registers shared `turnBoundary` state for its readers ([decision](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md)).
**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()`. A host reader either requires this service during activation or fails explicitly when the registry or required key is absent. Contributors may retain `ctx.inject(['sessionProjections'], ...)` registration without silently defaulting a missing host value. The Agent registry owns `modelSelection`; the agent loop registers shared `turnBoundary` state for its readers ([decision](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md)).
[Working directories](subsystems/working-directory.md) supply user context and execution paths without changing original project identity, sandbox write roots, or existing process directories.
+3 -3
View File
@@ -87,7 +87,7 @@ AgentLoop 在启动已排队工作前等待串行 `agent/created`,初始化失
```text
turn/start
claim next-step input plus one queued message
assemble prompt sections + tool schemas
snapshot Session model selection; assemble prompt sections + tool schemas
-> agent/pre-step; project runtime context at fallback
reject | enter(messages, startsRequestSeries?)
reject, or a first enter rewritten empty -> close the turn with no step
@@ -111,7 +111,7 @@ turn/end
输入通过同一个 inbox 到达驱动器;注入的上下文等待一条唤醒消息。AgentLoop 的持久 `inbox` 投影使待处理输入在没有活跃 Agent 时仍可读取。
`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试刷新已注册运行时事实、协调绑定的提示词、仅接纳一次用户消息并恢复被移除的运行时上下文、按需记录 header/context,再冻结请求并通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新,包括同时发生的受支持工具更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。
循环在组装前快照 Session 模型选择,并将其路由传给 `agent/pre-step`;重试保留该选择。`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 在不改变路由的前提下调整控制参数,`prepareCall()` 绑定适配器,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试刷新已注册运行时事实、协调绑定的提示词、仅接纳一次用户消息并恢复被移除的运行时上下文、按需记录 header/context,再冻结请求并通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。系统提示词通过 `system/message` 历史传递:空文本清除提示词,具备能力的路由追加更新,其他路由将其归并到首个系统节点。[Agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation) 说明提示词准入与替换的细节。
循环发送不可变请求,同时保持取消有效,仅对完全冻结的对象复用冻结证据;[agent-loop](../packages/core/agent-loop/README.zh.md) 负责请求构建与取消原因。
@@ -127,7 +127,7 @@ Session 消费方只了解当前逻辑格式。仅 header 的 `stat` 与 `list`
**模型可见即已记录。** 每个模型请求都必须能从日志重建。新增模型可见输入需要会话事件。修改现有消息内容的插件注册[纯消息投影](subsystems/session.zh.md#plugin-owned-message-projections),独立读取器显式传入相同的处理器。 工具变更不依赖能力;[Session 工具历史](../packages/core/session/README.zh.md)提供提供方声明。
**投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,host 消费方通过 `stateOf()` 读取单个类型化状态,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。host 读取方要么在激活时要求该服务,要么在注册表或必需 key 缺席时明确失败。贡献方可以保留 `ctx.inject(['sessionProjections'], ...)` 注册,但不能为缺失的 host 值静默提供默认值。agent loop 为读取方注册共享的 `turnBoundary` 状态([决策](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md))。
**投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,host 消费方通过 `stateOf()` 读取单个类型化状态,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。host 读取方要么在激活时要求该服务,要么在注册表或必需 key 缺席时明确失败。贡献方可以保留 `ctx.inject(['sessionProjections'], ...)` 注册,但不能为缺失的 host 值静默提供默认值。Agent 注册表拥有 `modelSelection`;agent loop 为读取方注册共享的 `turnBoundary` 状态([决策](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md))。
[工作目录](subsystems/working-directory.zh.md) 提供用户上下文和执行路径,不改变原始项目标识、沙箱写入根目录或已有进程目录。
+8 -8
View File
@@ -14,9 +14,9 @@ Each package entry labels its data with three identifiers: `inject` lists the se
## `@deepseek-ai/dsh-acp`
- `inject`: `agents` · `llm` · `sessionPersistence` · `sessions`
- `inject`: `agents` · `llm` · `sessionPersistence` · `sessionProjections` · `sessions`
- `refs`: `Stream` (`@agentclientprotocol/sdk`)
- `source`: [`packages/acp/acp/src/index.ts:75`](../packages/acp/acp/src/index.ts)
- `source`: [`packages/acp/acp/src/index.ts:74`](../packages/acp/acp/src/index.ts)
```ts config-catalog
/** Plugin config: the provider/model selection used for each ACP-created agent. */
@@ -92,7 +92,7 @@ export interface Config {
- `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections`
- `refs`: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md) · `Volatile` (`@deepseek-ai/cosmokit`)
- `source`: [`packages/core/agent-loop/src/index.ts:303`](../packages/core/agent-loop/src/index.ts)
- `source`: [`packages/core/agent-loop/src/index.ts:304`](../packages/core/agent-loop/src/index.ts)
```ts config-catalog
/** Agent-loop plugin configuration. */
@@ -102,7 +102,7 @@ export interface Config {
* omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
*/
maxParallelToolCalls: Volatile<number>
/** Agents created or resumed at plugin startup. */
/** Agents created or resumed at plugin startup; a resumed agent records its configured route as the Session's model selection. */
agents: (AgentOptions & {
/** Stable config label used in logs and as the fresh combined-id prefix. */
id: string
@@ -220,7 +220,7 @@ export interface Config {
## `@deepseek-ai/dsh-api-session-controller`
- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `source`: [`packages/api/session-controller/src/index.ts:94`](../packages/api/session-controller/src/index.ts)
- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts)
```ts config-catalog
/** Session Controller deployment policy. */
@@ -1434,7 +1434,7 @@ export interface Config {
## `@deepseek-ai/dsh-headless`
- `inject`: `agentDefaultModel` · `agents` · `sessions`
- `inject`: `agentDefaultModel` · `agents` · `sessionProjections` · `sessions`
- `source`: [`packages/bundle/headless/src/index.ts:43`](../packages/bundle/headless/src/index.ts)
```ts config-catalog
@@ -2744,7 +2744,7 @@ export interface Config {
## `@deepseek-ai/dsh-sdk-jsonrpc-server`
- `inject`: `agents` · `workingDirectory`
- `inject`: `agents` · `sessionProjections` · `workingDirectory`
- `refs`: `Readable` (`node:stream`) · `Writable` (`node:stream`)
- `source`: [`packages/sdk/server/src/index.ts:24`](../packages/sdk/server/src/index.ts)
@@ -4562,7 +4562,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
| `package` | `inject` | `source` |
| --- | --- | --- |
| `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) |
| `@deepseek-ai/dsh-agent` | — | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) |
| `@deepseek-ai/dsh-agent` | `sessionProjections` | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) |
| `@deepseek-ai/dsh-api-account-controller` | `deepseekAccount` · `agents` | [`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts) |
| `@deepseek-ai/dsh-api-remote-access` | — | [`packages/api/remote-access/src/index.ts`](../packages/api/remote-access/src/index.ts) |
| `@deepseek-ai/dsh-api-remotes` | `typertGateway` | [`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts) |
+8 -8
View File
@@ -16,9 +16,9 @@
## `@deepseek-ai/dsh-acp`
- `inject`: `agents` · `llm` · `sessionPersistence` · `sessions`
- `inject`: `agents` · `llm` · `sessionPersistence` · `sessionProjections` · `sessions`
- `refs`: `Stream` (`@agentclientprotocol/sdk`)
- `source`: [`packages/acp/acp/src/index.ts:75`](../packages/acp/acp/src/index.ts)
- `source`: [`packages/acp/acp/src/index.ts:74`](../packages/acp/acp/src/index.ts)
```ts config-catalog
/** Plugin config: the provider/model selection used for each ACP-created agent. */
@@ -94,7 +94,7 @@ export interface Config {
- `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections`
- `refs`: [`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md) · `Volatile` (`@deepseek-ai/cosmokit`)
- `source`: [`packages/core/agent-loop/src/index.ts:303`](../packages/core/agent-loop/src/index.ts)
- `source`: [`packages/core/agent-loop/src/index.ts:304`](../packages/core/agent-loop/src/index.ts)
```ts config-catalog
/** Agent-loop plugin configuration. */
@@ -104,7 +104,7 @@ export interface Config {
* omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
*/
maxParallelToolCalls: Volatile<number>
/** Agents created or resumed at plugin startup. */
/** Agents created or resumed at plugin startup; a resumed agent records its configured route as the Session's model selection. */
agents: (AgentOptions & {
/** Stable config label used in logs and as the fresh combined-id prefix. */
id: string
@@ -222,7 +222,7 @@ export interface Config {
## `@deepseek-ai/dsh-api-session-controller`
- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `source`: [`packages/api/session-controller/src/index.ts:94`](../packages/api/session-controller/src/index.ts)
- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts)
```ts config-catalog
/** Session Controller deployment policy. */
@@ -1436,7 +1436,7 @@ export interface Config {
## `@deepseek-ai/dsh-headless`
- `inject`: `agentDefaultModel` · `agents` · `sessions`
- `inject`: `agentDefaultModel` · `agents` · `sessionProjections` · `sessions`
- `source`: [`packages/bundle/headless/src/index.ts:43`](../packages/bundle/headless/src/index.ts)
```ts config-catalog
@@ -2746,7 +2746,7 @@ export interface Config {
## `@deepseek-ai/dsh-sdk-jsonrpc-server`
- `inject`: `agents` · `workingDirectory`
- `inject`: `agents` · `sessionProjections` · `workingDirectory`
- `refs`: `Readable` (`node:stream`) · `Writable` (`node:stream`)
- `source`: [`packages/sdk/server/src/index.ts:24`](../packages/sdk/server/src/index.ts)
@@ -4564,7 +4564,7 @@ export interface Config {
| `package` | `inject` | `source` |
| --- | --- | --- |
| `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) |
| `@deepseek-ai/dsh-agent` | — | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) |
| `@deepseek-ai/dsh-agent` | `sessionProjections` | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) |
| `@deepseek-ai/dsh-api-account-controller` | `deepseekAccount` · `agents` | [`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts) |
| `@deepseek-ai/dsh-api-remote-access` | — | [`packages/api/remote-access/src/index.ts`](../packages/api/remote-access/src/index.ts) |
| `@deepseek-ai/dsh-api-remotes` | `typertGateway` | [`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts) |
+19 -19
View File
@@ -8,25 +8,25 @@ This matrix shows which packages dispatch each harness-owned event and which pac
<!-- BEGIN GENERATED event-producer-consumer:events -->
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:249`](../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:250`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:69`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/experimental/webhook) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:629`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:636`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:615`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:622`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:367`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:263`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:272`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:397`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:301`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:323`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:341`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:357`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:282`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:385`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:602`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:582`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:588`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:595`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
@@ -72,7 +72,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`working-directory`](../packages/session/working-directory) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | `browser-use-runtime`, [`working-directory`](../packages/session/working-directory) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:209`](../packages/core/tools/src/index.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:165`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` |
+19 -19
View File
@@ -10,25 +10,25 @@
<!-- BEGIN GENERATED event-producer-consumer:events -->
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:249`](../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:250`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:69`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/experimental/webhook) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:629`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:636`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:615`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:622`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:367`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:263`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:272`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:397`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:301`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:323`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:341`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:357`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:282`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:385`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:602`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:582`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:588`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:595`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
@@ -74,7 +74,7 @@
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`working-directory`](../packages/session/working-directory) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | `browser-use-runtime`, [`working-directory`](../packages/session/working-directory) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:209`](../packages/core/tools/src/index.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:165`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` |
+16 -16
View File
@@ -21,8 +21,8 @@
en: 9188862058c6986b
zh: 9188862058c6986b
/session-persistence-event-catalog/events/agent/agent-inbox-spliced-log-only:
en: bc3be962bc8738b1
zh: e538aa8ff144468f
en: 5eec817110f76d99
zh: b8c36632d99ad776
/session-persistence-event-catalog/events/agent-preset:
en: 49891ca9be5ac123
zh: 49891ca9be5ac123
@@ -141,8 +141,8 @@
en: 9edf4ef31e60e880
zh: 9edf4ef31e60e880
/session-persistence-event-catalog/events/model/model-selection-log-only:
en: 0d9e20c2357ae7ac
zh: 23401d59e97d6011
en: cfa258833e29b683
zh: 5209de008e1a0ead
/session-persistence-event-catalog/events/permission:
en: 19917bc5438e60af
zh: 19917bc5438e60af
@@ -1071,8 +1071,8 @@
en: a3087ab7bebe9371
zh: abf6afc2f9f5b692
/session-persistence-event-catalog/resolved-persistence-types/inboxtarget:
en: 938113c1b01efad1
zh: d6829dbe979e6819
en: b3b7b8a3414bcb6b
zh: 06be5b8e56bacedf
/session-persistence-event-catalog/resolved-persistence-types/jobregistereddata:
en: f604eebb973fe2cf
zh: 0c0aba1e17ecf66e
@@ -1140,11 +1140,11 @@
en: 8bd12543fcdcda5f
zh: d4c6b17083948856
/session-persistence-event-catalog/resolved-persistence-types/modelmessagesource:
en: 3b677f021d7dd4d3
zh: 2a875742b22879a2
en: 5f9154ca34235ab9
zh: a833105910ebfa82
/session-persistence-event-catalog/resolved-persistence-types/modelselection:
en: 6d66ffc1b393f2f2
zh: ef6cb30b71f287e7
en: 571888ccc2c3f2ce
zh: 974c016a090d36fa
/session-persistence-event-catalog/resolved-persistence-types/oneshotscheduledispatchchange:
en: a3300babe57b6b67
zh: 4aaf06a149b399c3
@@ -1344,8 +1344,8 @@
en: 2786fef560851bb4
zh: 5d787475356b2606
/session-persistence-event-catalog/resolved-persistence-types/string:
en: 009c7ecb8c7d3bb5
zh: 7f56d672f376b22f
en: 1525c04d0ebfc56e
zh: 902d37c1de80bfe7
/session-persistence-event-catalog/resolved-persistence-types/string~2:
en: 0089620bf4638263
zh: 1922bef1713094bb
@@ -1521,8 +1521,8 @@
en: 8a9199b1784c70f5
zh: 76c98bc74686fda0
/session-persistence-event-catalog/resolved-persistence-types/inserted-outcome-removedcount-start:
en: e77d5d524dd8b4ea
zh: 4dac28dda78a510f
en: 74b8b426499a4203
zh: 5400c0fe94ef9cdf
/session-persistence-event-catalog/resolved-persistence-types/interrupted-message-step-stream:
en: ec10cd8e9c202a81
zh: e08f859bcb13f0d4
@@ -1830,8 +1830,8 @@
en: 809a23c25ded8497
zh: e9cd23e281f8d31f
/session-persistence-event-catalog/resolved-persistence-types/kind-user:
en: 9b6318ab4d7eba12
zh: f475439ef40b2681
en: 1dd9740285c43aff
zh: 093fab99146cac11
/session-persistence-event-catalog/resolved-persistence-types/kind-user-approval:
en: a52d2dfbaf844127
zh: 4c5306ca40f20873
+10 -10
View File
@@ -185,7 +185,7 @@ Sources: [`packages/core/session/src/types.ts:432`](../packages/core/session/src
}
```
Source: [`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:138`](../packages/core/agent/src/types.ts)
### `agent-preset/*`
@@ -694,13 +694,13 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src
```ts persistence-catalog
/**
* Complete validated model selection requested for subsequent prompt
* assembly. Log-only: it never enters derived model history.
* Complete model selection that applies from the next model step until a
* later selection. Log-only: it never enters derived model history.
*/
'model/selection': ModelSelection
```
Source: [`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts)
Source: [`packages/core/agent/src/types.ts:149`](../packages/core/agent/src/types.ts)
### `permission/*`
@@ -3780,7 +3780,7 @@ Array of [`ImageOffloadTarget`](#persistence-type-sha256-1cde1bedde79c5e8a56aee9
SHA-256: `96f4a9c81fc0f940ef71528a8cc66731ae4cf1f40d79680c5fe3c39f96d7723c`
Sources: [`packages/core/agent/src/types.ts:39`](../packages/core/agent/src/types.ts)
Sources: [`packages/core/agent/src/types.ts:40`](../packages/core/agent/src/types.ts)
One of:
@@ -4323,13 +4323,13 @@ Sources: [`packages/llm/llm/src/message.ts:24`](../packages/llm/llm/src/message.
<a id="persistence-type-modelselection"></a>
<a id="persistence-type-packagesapisession-controllersrctypestsmodelselection"></a>
<a id="persistence-type-packagescoreagentsrctypestsmodelselection"></a>
### `ModelSelection`
SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b`
Sources: [`packages/api/session-controller/src/types.ts:101`](../packages/api/session-controller/src/types.ts)
Sources: [`packages/core/agent/src/types.ts:91`](../packages/core/agent/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -5575,7 +5575,7 @@ Array of `number`.
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
Sources: [`packages/api/session-controller/src/types.ts:409`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
Sources: [`packages/api/session-controller/src/types.ts:382`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -6369,7 +6369,7 @@ Sources: [`packages/core/session/src/types.ts:428`](../packages/core/session/src
SHA-256: `fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42`
Sources: [`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts)
Sources: [`packages/core/agent/src/types.ts:138`](../packages/core/agent/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -7546,7 +7546,7 @@ Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/inde
SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def`
Sources: [`packages/api/session-controller/src/types.ts:418`](../packages/api/session-controller/src/types.ts)
Sources: [`packages/api/session-controller/src/types.ts:391`](../packages/api/session-controller/src/types.ts)
| Property | Presence | Type |
|---|---|---|
+10 -10
View File
@@ -187,7 +187,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
}
```
来源:[`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts)
来源:[`packages/core/agent/src/types.ts:138`](../packages/core/agent/src/types.ts)
### `agent-preset/*`
@@ -696,13 +696,13 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
```ts persistence-catalog
/**
* Complete validated model selection requested for subsequent prompt
* assembly. Log-only: it never enters derived model history.
* Complete model selection that applies from the next model step until a
* later selection. Log-only: it never enters derived model history.
*/
'model/selection': ModelSelection
```
来源:[`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts)
来源:[`packages/core/agent/src/types.ts:149`](../packages/core/agent/src/types.ts)
### `permission/*`
@@ -3782,7 +3782,7 @@ SHA-256: `9e2c06d8653585b33d72d5c1a80da79c8a24a88c284409bca15cc0f0ead5ebfb`
SHA-256: `96f4a9c81fc0f940ef71528a8cc66731ae4cf1f40d79680c5fe3c39f96d7723c`
来源:[`packages/core/agent/src/types.ts:39`](../packages/core/agent/src/types.ts)
来源:[`packages/core/agent/src/types.ts:40`](../packages/core/agent/src/types.ts)
以下类型之一:
@@ -4325,13 +4325,13 @@ SHA-256: `719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91`
<a id="persistence-type-modelselection"></a>
<a id="persistence-type-packagesapisession-controllersrctypestsmodelselection"></a>
<a id="persistence-type-packagescoreagentsrctypestsmodelselection"></a>
### `ModelSelection`
SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b`
来源:[`packages/api/session-controller/src/types.ts:101`](../packages/api/session-controller/src/types.ts)
来源:[`packages/core/agent/src/types.ts:91`](../packages/core/agent/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -5577,7 +5577,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78`
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
来源:[`packages/api/session-controller/src/types.ts:409`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
来源:[`packages/api/session-controller/src/types.ts:382`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -6371,7 +6371,7 @@ SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3`
SHA-256: `fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42`
来源:[`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts)
来源:[`packages/core/agent/src/types.ts:138`](../packages/core/agent/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -7548,7 +7548,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543`
SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def`
来源:[`packages/api/session-controller/src/types.ts:418`](../packages/api/session-controller/src/types.ts)
来源:[`packages/api/session-controller/src/types.ts:391`](../packages/api/session-controller/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
+6 -6
View File
@@ -23546,7 +23546,7 @@
"packages/workflow/workflow/src/types.ts#WorkflowRunId"
],
"sources": [
"packages/api/session-controller/src/types.ts:409",
"packages/api/session-controller/src/types.ts:382",
"packages/attachment/attachment/src/brand.ts:6",
"packages/compaction/compaction/src/brand.ts:4",
"packages/core/session/src/types.ts:21",
@@ -24068,7 +24068,7 @@
"digest": "6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def",
"names": [],
"sources": [
"packages/api/session-controller/src/types.ts:418"
"packages/api/session-controller/src/types.ts:391"
]
},
{
@@ -24660,7 +24660,7 @@
"packages/core/agent/src/types.ts#InboxTarget"
],
"sources": [
"packages/core/agent/src/types.ts:39"
"packages/core/agent/src/types.ts:40"
]
},
{
@@ -25881,10 +25881,10 @@
{
"digest": "f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b",
"names": [
"packages/api/session-controller/src/types.ts#ModelSelection"
"packages/core/agent/src/types.ts#ModelSelection"
],
"sources": [
"packages/api/session-controller/src/types.ts:101"
"packages/core/agent/src/types.ts:91"
]
},
{
@@ -26047,7 +26047,7 @@
"digest": "fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42",
"names": [],
"sources": [
"packages/core/agent/src/types.ts:96"
"packages/core/agent/src/types.ts:138"
]
},
{
+2 -2
View File
@@ -12,8 +12,8 @@
en: ab09a38b4e1f0883
zh: 81e2d14c4036ab6c
/core/the-agent-handle:
en: c21787347d8e2b14
zh: 25bc9cb60ee1520a
en: ae3f4d8ff054f736
zh: 5031b89c849a0a41
/core/initiating-agent:
en: 6c8c47ac78de2dde
zh: c9d527a25a85d1d9
+48 -8
View File
@@ -61,7 +61,7 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types
interface Agent {
/** Session-backed Agent identity. */
readonly id: SessionId
/** The provider route and model this agent's requests use. */
/** Initial route and output controls; the route applies only while the Session records no model selection or request. */
readonly options: AgentOptions
/** The live session this agent drives; its log is the durable source of truth. */
readonly session: Session
@@ -200,13 +200,51 @@ interface AgentOptions {
/** Model id interpreted by the selected provider adapter. */
model?: string
/** Adapter-owned reasoning effort for the selected provider/model route. */
reasoningEffort?: ReasoningEffortId
reasoningEffort?: ReasoningEffortId | undefined
/** Maximum output tokens for each conversation-model request. */
maxTokens?: number
}
```
Dispatch requires `provider` and `model` after `agent/request`. An explicit `reasoningEffort` seeds the first request on that route; exact-model resolution validates it, while omission allows the adapter default to materialize. When present, `maxTokens` must be a positive safe integer and caps every conversation-model request; omission allows the exact-model adapter default to materialize before the request header, or otherwise leaves provider behavior unchanged. An agent-scoped `deployment:persona-prefix` prompt section may shadow the global default persona.
Step preparation requires `provider` and `model` before prompt assembly. `agent/request` may change request controls but must preserve that route. An explicit `reasoningEffort` seeds the first request on that route; exact-model resolution validates it, while omission allows the adapter default to materialize. When present, `maxTokens` must be a positive safe integer and caps every conversation-model request; omission allows the exact-model adapter default to materialize before the request header, or otherwise leaves provider behavior unchanged. An agent-scoped `deployment:persona-prefix` prompt section may shadow the global default persona.
The Agent registry registers `modelSelection` on `ctx.sessionProjections`. `model/selection` records a complete choice without entering model history. A matching request header clears `pending`; the latest request remains `lastUsed`. Adapter-defaulted effort is omitted so later requests resolve the default again. `next` is null only before either a choice or a request is recorded; in that case `AgentOptions` supplies the route. The [Agent package](../../packages/core/agent/README.md) describes selection helpers and resume policy.
```ts type-equiv
/** Complete provider, model, and optional reasoning effort chosen for one Session's model steps. */
interface ModelSelection {
/** Registered provider route. */
provider: string
/** Provider-owned model id. */
model: string
/** Adapter-owned reasoning effort, or provider/default behavior when absent. */
reasoningEffort?: ReasoningEffortId
}
```
```ts type-equiv
/**
* Durable model-selection fold for one Session. An effort the adapter
* defaulted on a recorded request is omitted, so later requests resolve the
* default again instead of pinning it as a choice.
*/
interface ModelSelectionProjectionState {
/** Selection used by the latest recorded model request. */
readonly lastUsed: ModelSelection | null
/** Later recorded selection not yet used by a matching model request. */
readonly pending: ModelSelection | null
}
```
```ts type-equiv
/** Client view of the durable model-selection fold. */
interface ModelSelectionProjection {
/** Selection used by the latest recorded model request. */
readonly lastUsed: ModelSelection | null
/** Selection the next model step uses; null while the Agent's initial options supply the route. */
readonly next: ModelSelection | null
}
```
The inbox is the delivery vocabulary — two ordered pending-message lists the agent owns as a durable projection:
@@ -940,13 +978,14 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p
* @param payload.turn - the turn that will own the step.
* @param payload.step - the step proposed by the loop.
* @param payload.signal - the current turn's cancellation signal.
* @param payload.callConfig - provider and model fixed for this step and its retries.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/pre-step'(this: Scoped<Agent>, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>
'agent/pre-step'(this: Scoped<Agent>, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal; callConfig: Readonly<Pick<LlmCallConfig, 'provider' | 'model'>> }, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>
```
Types: [Scoped](scope.md) · [UserMessage](session.md)
Types: [LlmCallConfig](llm-streaming.md) · [Scoped](scope.md) · [UserMessage](session.md)
Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts)
@@ -954,13 +993,14 @@ Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/s
#### `agent/request` — waterfall
Replace the frozen call configuration. `await next()` yields the config the machine would use (agent options on the first request, the logged header afterwards); return a replacement to switch. On step admission, this runs after assembly and `step/start`, before the system prompt and accepted user batch are committed. Cancellation here or during subsequent `prepareCall()` resolution commits neither. The prepared call capability governs prompt admission. Model-visible content must use logged channels; this waterfall cannot mutate messages.
Replace the frozen call configuration. `await next()` yields the config the machine would use (the step's model selection with agent options on the first request, the logged header's controls afterwards); a replacement may adjust controls but must keep the provider and model. On step admission, this runs after assembly and `step/start`, before the system prompt and accepted user batch are committed. Cancellation here or during subsequent `prepareCall()` resolution commits neither. The prepared call capability governs prompt admission. Model-visible content must use logged channels; this waterfall cannot mutate messages.
```ts cordis-catalog
/**
* Replace the frozen call configuration. `await next()` yields the config
* the machine would use (agent options on the first request, the logged
* header afterwards); return a replacement to switch. On step admission,
* the machine would use (the step's model selection with agent options on
* the first request, the logged header's controls afterwards); a
* replacement may adjust controls but must keep the provider and model. On step admission,
* this runs after assembly and `step/start`, before the system prompt and
* accepted user batch are committed. Cancellation here or during subsequent
* `prepareCall()` resolution commits neither. The prepared call capability
+48 -8
View File
@@ -65,7 +65,7 @@ interface AgentHandle {
interface Agent {
/** Session-backed Agent identity. */
readonly id: SessionId
/** The provider route and model this agent's requests use. */
/** Initial route and output controls; the route applies only while the Session records no model selection or request. */
readonly options: AgentOptions
/** The live session this agent drives; its log is the durable source of truth. */
readonly session: Session
@@ -204,13 +204,51 @@ interface AgentOptions {
/** Model id interpreted by the selected provider adapter. */
model?: string
/** Adapter-owned reasoning effort for the selected provider/model route. */
reasoningEffort?: ReasoningEffortId
reasoningEffort?: ReasoningEffortId | undefined
/** Maximum output tokens for each conversation-model request. */
maxTokens?: number
}
```
在 `agent/request` 之后,分发要求 `provider` 与 `model` 都存在。显式 `reasoningEffort` 会为该路由的首次请求提供初始值;确切模型解析会校验该值,省略时则允许填入适配器默认值。提供 `maxTokens` 时,它必须是正安全整数,并限制每次对话模型请求的输出;省略时,系统会在写入请求 header 前填入确切模型的适配器默认值,否则提供方行为保持不变。agent 作用域的 `deployment:persona-prefix` 提示词段落可以遮蔽全局默认 persona。
步骤准备在提示词组装前要求 `provider` 与 `model` 都存在。`agent/request` 可以更改请求控制参数,但必须保留该路由。显式 `reasoningEffort` 会为该路由的首次请求提供初始值;确切模型解析会校验该值,省略时则允许填入适配器默认值。提供 `maxTokens` 时,它必须是正安全整数,并限制每次对话模型请求的输出;省略时,系统会在写入请求 header 前填入确切模型的适配器默认值,否则提供方行为保持不变。agent 作用域的 `deployment:persona-prefix` 提示词段落可以遮蔽全局默认 persona。
Agent 注册表在 `ctx.sessionProjections` 上注册 `modelSelection`。`model/selection` 记录完整选择,但不进入模型历史。匹配的请求头会清除 `pending`,最近请求仍保留为 `lastUsed`。适配器默认的推理强度会被省略,使后续请求重新解析默认值。只有尚未记录选择或请求时,`next` 才为 null,此时由 `AgentOptions` 提供路由。[Agent 包](../../packages/core/agent/README.zh.md)说明选择辅助函数与恢复策略。
```ts type-equiv
/** Complete provider, model, and optional reasoning effort chosen for one Session's model steps. */
interface ModelSelection {
/** Registered provider route. */
provider: string
/** Provider-owned model id. */
model: string
/** Adapter-owned reasoning effort, or provider/default behavior when absent. */
reasoningEffort?: ReasoningEffortId
}
```
```ts type-equiv
/**
* Durable model-selection fold for one Session. An effort the adapter
* defaulted on a recorded request is omitted, so later requests resolve the
* default again instead of pinning it as a choice.
*/
interface ModelSelectionProjectionState {
/** Selection used by the latest recorded model request. */
readonly lastUsed: ModelSelection | null
/** Later recorded selection not yet used by a matching model request. */
readonly pending: ModelSelection | null
}
```
```ts type-equiv
/** Client view of the durable model-selection fold. */
interface ModelSelectionProjection {
/** Selection used by the latest recorded model request. */
readonly lastUsed: ModelSelection | null
/** Selection the next model step uses; null while the Agent's initial options supply the route. */
readonly next: ModelSelection | null
}
```
inbox 即投递词汇——agent 以持久投影形式拥有的两条有序待处理消息列表:
@@ -952,13 +990,14 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p
* @param payload.turn - the turn that will own the step.
* @param payload.step - the step proposed by the loop.
* @param payload.signal - the current turn's cancellation signal.
* @param payload.callConfig - provider and model fixed for this step and its retries.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/pre-step'(this: Scoped<Agent>, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>
'agent/pre-step'(this: Scoped<Agent>, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal; callConfig: Readonly<Pick<LlmCallConfig, 'provider' | 'model'>> }, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>
```
Types: [Scoped](scope.zh.md) · [UserMessage](session.zh.md)
Types: [LlmCallConfig](llm-streaming.zh.md) · [Scoped](scope.zh.md) · [UserMessage](session.zh.md)
Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts)
@@ -966,13 +1005,14 @@ Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/s
#### `agent/request` — waterfall
Replace the frozen call configuration. `await next()` yields the config the machine would use (agent options on the first request, the logged header afterwards); return a replacement to switch. On step admission, this runs after assembly and `step/start`, before the system prompt and accepted user batch are committed. Cancellation here or during subsequent `prepareCall()` resolution commits neither. The prepared call capability governs prompt admission. Model-visible content must use logged channels; this waterfall cannot mutate messages.
Replace the frozen call configuration. `await next()` yields the config the machine would use (the step's model selection with agent options on the first request, the logged header's controls afterwards); a replacement may adjust controls but must keep the provider and model. On step admission, this runs after assembly and `step/start`, before the system prompt and accepted user batch are committed. Cancellation here or during subsequent `prepareCall()` resolution commits neither. The prepared call capability governs prompt admission. Model-visible content must use logged channels; this waterfall cannot mutate messages.
```ts cordis-catalog
/**
* Replace the frozen call configuration. `await next()` yields the config
* the machine would use (agent options on the first request, the logged
* header afterwards); return a replacement to switch. On step admission,
* the machine would use (the step's model selection with agent options on
* the first request, the logged header's controls afterwards); a
* replacement may adjust controls but must keep the provider and model. On step admission,
* this runs after assembly and `step/start`, before the system prompt and
* accepted user batch are committed. Cancellation here or during subsequent
* `prepareCall()` resolution commits neither. The prepared call capability
+2 -2
View File
@@ -39,8 +39,8 @@
en: 2b0831aaae8e1cdf
zh: f669d93d1b9d6025
/llm-streaming/the-model-request/the-request-envelope-llmcallconfig-and-the-logged-header:
en: f2e6932e211f179b
zh: 75112586a6f3f4aa
en: 27c3c133605c224d
zh: ca9584608cc04d2f
/llm-streaming/official-deepseek-request-extensions:
en: 42bc50394b070e96
zh: 007caa743ec3c790
+1 -1
View File
@@ -760,7 +760,7 @@ interface LlmDiscoveredModel {
The loop builds each request from logged state. `EpochHeader` records call config, marks the fields supplied by adapter defaults, and records the authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset) through full `request/header` snapshots. The rendered prompt is derived history — the `system/message` at surface node 0, plus any later system node an `in-history` route appended — so the header and the derived history together make the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. Before the waterfall, the loop removes values marked as adapter defaults so exact-model preparation materializes the selected route's current values; unmarked explicit settings remain in the proposal. After the waterfall, preparation rejects unsupported explicit effort ids without clamping and logs the effective config plus the fields supplied by adapter defaults under the turn signal. On step admission, this waterfall and preparation run after assembly and `step/start` but before the system prompt and accepted user batch are committed; cancellation during either commits neither. The prepared capability governs prompt reconciliation, and the call keeps one adapter registration through dispatch. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests.
`agent/request` receives a frozen call-config seed based on the step's captured model selection and may return a replacement for reasoning effort or sampling. A replacement must preserve provider and model; record a new Session selection before the next step to change the route. Before the waterfall, the loop removes values marked as adapter defaults so exact-model preparation materializes the selected route's current values; unmarked explicit settings remain in the proposal. After the waterfall, preparation rejects unsupported explicit effort ids without clamping and logs the effective config plus the fields supplied by adapter defaults under the turn signal. On step admission, this waterfall and preparation run after assembly and `step/start` but before the system prompt and accepted user batch are committed; cancellation during either commits neither. The prepared capability governs prompt reconciliation, and the call keeps one adapter registration through dispatch. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests.
On the wire, a loop-built request is the derived history alone: the rendered prompt travels as the leading `system`-role message (surface node 0, a `system/message` event) and, when the prepared call declares `systemPromptUpdate: 'in-history'`, a non-empty changed prompt may follow the cached history as a later `system`-role message that the model reads as the effective prompt; the request's `system` field is unset — `GenerateOptions.system` serves direct one-shot callers such as title providers. An empty rendering leaves no system messages in derived history, even when earlier requests retained several prompt versions. The logged request ends with the newest `user/message` on a turn's first step and the previous step's tool results on later steps.
+1 -1
View File
@@ -766,7 +766,7 @@ interface LlmDiscoveredModel {
循环从已记录状态构建每个请求。`EpochHeader` 记录调用配置,标记由适配器默认值提供的字段,并通过完整的 `request/header` 快照记录权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)。渲染后的提示词是派生历史——surface 第 0 号节点上的 `system/message`,加上 `in-history` 路由追加的任何后续系统节点——因此请求头与派生历史共同使请求可由会话日志重建。见 [session.md](session.zh.md#the-request-header-event-requestheader) 与[可重建性 Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)。
`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall(瀑布式事件)开始前,循环会移除标记为适配器默认值的值,使确切模型准备过程填入所选路由的当前值;未带标记的显式设置仍保留在提议中。waterfall 结束后,准备过程会在轮次信号控制下拒绝显式指定但不受支持的推理强度 ID(不自动调整),并记录生效配置以及由适配器默认值提供的字段。步骤准入时,该 waterfall 与准备过程在组装和 `step/start` 之后、系统提示词与已接纳用户批次提交之前运行;在任一阶段取消都不会提交这两者。已准备调用的能力决定提示词协调,调用直至分派完成始终持有同一项适配器注册。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。
`agent/request` 收到基于步骤已捕获模型选择的冻结调用配置初值,可以返回替换值来调整推理强度或采样。替换值必须保留提供方与模型;要改变路由,需在下一个步骤之前记录新的 Session 选择。waterfall(瀑布式事件)开始前,循环会移除标记为适配器默认值的值,使确切模型准备过程填入所选路由的当前值;未带标记的显式设置仍保留在提议中。waterfall 结束后,准备过程会在轮次信号控制下拒绝显式指定但不受支持的推理强度 ID(不自动调整),并记录生效配置以及由适配器默认值提供的字段。步骤准入时,该 waterfall 与准备过程在组装和 `step/start` 之后、系统提示词与已接纳用户批次提交之前运行;在任一阶段取消都不会提交这两者。已准备调用的能力决定提示词协调,调用直至分派完成始终持有同一项适配器注册。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。
在协议中,循环构建的请求只有派生历史:渲染后的提示词作为开头的 `system` 角色消息(surface 第 0 号节点,即一个 `system/message` 事件)传输,并且当已准备调用声明 `systemPromptUpdate: 'in-history'` 时,变化后的非空提示词可以作为后续的 `system` 角色消息跟在已缓存历史之后,由模型读作有效提示词;请求的 `system` 字段不设置——`GenerateOptions.system` 服务于标题提供方等直接单次调用方。空渲染文本使派生历史不包含任何系统消息,即使先前请求保留了多个提示词版本。已记录的请求会以最新的 `user/message`(轮次首步)或上一步的工具结果(后续步骤)结尾。
+4 -3
View File
@@ -205,20 +205,21 @@ async listCandidates( agent: Agent, query: string = '', limit: number = this.con
/**
* Snapshot all references for one accepted direct message and return one aggregated durable context.
* Automatic budgets use the last assembled route, or agent options before any assembly.
* Automatic budgets use the context capacity of `route`.
* Missing model capacity or adapter uses 64 KiB; other metadata lookup failures and cancellation reject preparation.
* Truncated previews include omission facts and a full-snapshot spill locator, or an explicit unavailable notice.
* Cancellation prevents context publication, including when storage completes after cancellation.
* @param agent - target agent; references to it are rejected.
* @param content - already host-normalized readable message content.
* @param references - structured source sessions in mention order.
* @param route - provider and model whose context capacity sizes the previews.
* @param signal - optional cancellation boundary for the active turn.
* @returns detached content and optional referenced-session context.
*/
async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise<PreparedReferencedMessage>
async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], route: Readonly<Pick<LlmCallConfig, 'provider' | 'model'>>, signal?: AbortSignal, ): Promise<PreparedReferencedMessage>
```
Types: [Agent](core.md) · [ContentBlock](llm-streaming.md)
Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [LlmCallConfig](llm-streaming.md)
Source: [`packages/context/session-reference/src/index.ts`](../../packages/context/session-reference/src/index.ts)
<!-- END GENERATED cordis-surface -->
+4 -3
View File
@@ -205,20 +205,21 @@ async listCandidates( agent: Agent, query: string = '', limit: number = this.con
/**
* Snapshot all references for one accepted direct message and return one aggregated durable context.
* Automatic budgets use the last assembled route, or agent options before any assembly.
* Automatic budgets use the context capacity of `route`.
* Missing model capacity or adapter uses 64 KiB; other metadata lookup failures and cancellation reject preparation.
* Truncated previews include omission facts and a full-snapshot spill locator, or an explicit unavailable notice.
* Cancellation prevents context publication, including when storage completes after cancellation.
* @param agent - target agent; references to it are rejected.
* @param content - already host-normalized readable message content.
* @param references - structured source sessions in mention order.
* @param route - provider and model whose context capacity sizes the previews.
* @param signal - optional cancellation boundary for the active turn.
* @returns detached content and optional referenced-session context.
*/
async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise<PreparedReferencedMessage>
async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], route: Readonly<Pick<LlmCallConfig, 'provider' | 'model'>>, signal?: AbortSignal, ): Promise<PreparedReferencedMessage>
```
Types: [Agent](core.zh.md) · [ContentBlock](llm-streaming.zh.md)
Types: [Agent](core.zh.md) · [ContentBlock](llm-streaming.zh.md) · [LlmCallConfig](llm-streaming.zh.md)
Source: [`packages/context/session-reference/src/index.ts`](../../packages/context/session-reference/src/index.ts)
<!-- END GENERATED cordis-surface -->
@@ -0,0 +1,16 @@
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.3/session-model-selection/guide.md
/:
en: b587cfd9808e132f
zh: f3a5ddb2b458a751
/session-model-selection-applies-at-the-next-step:
en: e8582a68349890cd
zh: a385414164b09b8a
/session-model-selection-applies-at-the-next-step/change:
en: efb4715eb920347f
zh: d3eaa17df3a64ae1
/session-model-selection-applies-at-the-next-step/migration:
en: 97f8be78786e4d5e
zh: 00a6b59dca477611
@@ -0,0 +1,24 @@
---
kind: upgrade-guide
description: "Session model choices now apply at step preparation, and request listeners must preserve the selected route."
---
# Session model selection applies at the next step
English | [中文](guide.zh.md)
## Change
ACP model and reasoning changes take effect at the next model step, including a tool continuation in the current turn. Previously, ACP pinned the selection for the complete prompt turn. A prepared step and its retries retain their captured selection. Image admission checks the route selected when admission begins; a later option change can select a different route before the step starts.
The Agent registry now requires `sessionProjections` and owns the durable `modelSelection` projection. Programmatic resume restores recorded choices, including a selection not yet used by a request, before falling back to `AgentOptions`. Configured Agent-loop resumes, headless adoption, and SDK resume explicitly record their deployment route.
Plugins must select a complete provider/model route before prompt assembly. `agent/request` can adjust controls but rejects provider or model changes. `installModelSelection` and `ModelSelectionRef` are removed; selection readers and writers use the shared Session projection.
## Migration
1. In custom Cordis compositions, mount `dsh-session-projection` before `dsh-agent`.
2. Replace mutable selection references and route-changing `agent/request` listeners with `recordModelSelection(ctx.sessionProjections, agent, selection)` from `@deepseek-ai/dsh-agent`. Validate the route before recording it. Use `currentModelSelection()` to read the choice and `installModelSwitchNotice(agent.ctx)` when the composition needs a durable model-switch notice.
3. To override a saved route during programmatic resume, call `recordDeclaredRoute()` in the Agent setup callback. Supplying `agentOptions` alone provides only a fallback for a Session without a selection or request.
4. Pass the fixed route in `agent/pre-step.callConfig` when dispatching that event. Direct `SessionReferenceResolver.prepare()` callers pass the route as the fourth argument, before the optional signal.
5. ACP controllers that require one model throughout a turn must wait for the active prompt to finish before changing its options. Verify that a mid-turn change is recorded as `model/selection`, the next step uses it, and resume retains an unused choice.
@@ -0,0 +1,24 @@
---
kind: upgrade-guide
description: "Session 模型选择现在于步骤准备时生效,请求监听器必须保留已选路由。"
---
# Session 模型选择从下一个步骤开始生效
[English](guide.md) | 中文
## 变更
ACP(Agent Client Protocol)的模型与推理强度变更从下一个模型步骤开始生效,包括当前轮次的工具后续步骤。此前,ACP 会在整个提示词轮次中固定选择。已准备的步骤及其重试保留捕获的选择。图片准入检查准入开始时选择的路由;后续选项变更可能在步骤开始前选中另一条路由。
Agent 注册表现在依赖 `sessionProjections`,并拥有持久 `modelSelection` 投影。程序化恢复优先还原记录中的选择,包括尚未被请求使用的选择,然后才回退到 `AgentOptions`。配置式 Agent-loop 恢复、headless 接管与 SDK 恢复会显式记录各自的部署路由。
插件必须在提示词组装前选定完整的提供方/模型路由。`agent/request` 可以调整控制参数,但会拒绝提供方或模型变更。`installModelSelection` 与 `ModelSelectionRef` 已移除,选择的读取方与写入方使用共享 Session 投影。
## 迁移
1. 在自定义 Cordis 组合中,先挂载 `dsh-session-projection`,再挂载 `dsh-agent`。
2. 将可变选择引用与改变路由的 `agent/request` 监听器替换为 `@deepseek-ai/dsh-agent` 导出的 `recordModelSelection(ctx.sessionProjections, agent, selection)`。记录前先校验路由。使用 `currentModelSelection()` 读取选择;组合需要持久模型切换提示时,调用 `installModelSwitchNotice(agent.ctx)`。
3. 要在程序化恢复时覆盖已保存路由,在 Agent setup 回调中调用 `recordDeclaredRoute()`。仅提供 `agentOptions` 时,它只为没有选择或请求记录的 Session 提供回退值。
4. 分发 `agent/pre-step` 时通过 `callConfig` 传入固定路由。直接调用 `SessionReferenceResolver.prepare()` 时,将路由作为第四个参数,放在可选信号之前。
5. 要求整轮使用同一个模型的 ACP 控制器,必须等待活跃提示词完成后再更改选项。验证轮次中的变更被记录为 `model/selection`、下一个步骤使用该选择,且恢复保留尚未使用的选择。
+2 -2
View File
@@ -27,8 +27,8 @@
en: 4ed8e37834267edb
zh: a32d5dedb4e8e919
/deepseek-ai-dsh-acp/use-this-package/protocol-contract:
en: 29099e63f9a173c6
zh: 80d98e87f442d335
en: 8f358b006f93f6ab
zh: a02dc9640cac4edd
/deepseek-ai-dsh-acp/understand-the-implementation:
en: ea8afe89dbdd1934
zh: 6567979f92f34afd
+1 -1
View File
@@ -77,7 +77,7 @@ A prompt remains in flight while managed descendants run, while their results tr
A failure to append `turn/start` or `turn/end` rejects the prompt even when a preceding turn completed.
Session configuration offers opaque provider/model choices from the live LLM service catalog and a `reasoning_effort` selector when the exact model declares one. A prompt snapshots that selection before asynchronous image admission and pins it across every model step in that turn; a concurrent option change applies to the next turn. ACP clients are trusted controllers: stdio MCP entries authorize their absolute commands and environment, HTTP entries authorize their absolute HTTP(S) URLs and headers, and any initial connection or discovery failure rolls back the unpublished Agent. Unsupported surfaces are omitted or rejected: `session/load`, deletion, fork, additional directories, SSE or ACP-transport MCP, modes, commands, plans, terminals, client filesystem operations, and elicitation.
Session configuration offers opaque provider/model choices from the live LLM service catalog and a `reasoning_effort` selector when the exact model declares one. An option change records the Session selection and takes effect at the next model step, including a continuation in the current turn; an already prepared step and its retries keep their captured selection. Image admission checks the selection current when admission begins. Resume restores a recorded selection even when no request has used it yet. ACP clients are trusted controllers: stdio MCP entries authorize their absolute commands and environment, HTTP entries authorize their absolute HTTP(S) URLs and headers, and any initial connection or discovery failure rolls back the unpublished Agent. Unsupported surfaces are omitted or rejected: `session/load`, deletion, fork, additional directories, SSE or ACP-transport MCP, modes, commands, plans, terminals, client filesystem operations, and elicitation.
-----
+1 -1
View File
@@ -77,7 +77,7 @@ kind: "package-reference"
即使前一轮已经完成,追加 `turn/start` 或 `turn/end` 失败仍会使提示词请求失败。
会话配置从实时 LLM(大语言模型)服务目录提供不透明的提供方/模型选项,并在确切模型声明推理选项时提供 `reasoning_effort`。提示词会在异步图片准入前快照该选择,并在该轮次的每个模型步骤中固定它;并发选项变更从下一轮次开始生效。ACP 客户端是受信控制器:stdio MCP 条目授权其绝对命令与环境,HTTP 条目授权其绝对 HTTP(S) URL 与 header;初始连接或发现失败会回滚尚未发布的 Agent。不支持的界面会被省略或拒绝:`session/load`、删除、fork、附加目录、SSE(Server-Sent Events)或 ACP 传输 MCP、mode、命令、计划、终端、客户端文件系统操作与 elicitation。
会话配置从实时 LLM(大语言模型)服务目录提供不透明的提供方/模型选项,并在确切模型声明推理选项时提供 `reasoning_effort`。选项变更记录 Session 选择,从下一个模型步骤开始生效,包括当前轮次的后续步骤;已准备的步骤及其重试保留捕获的选择。图片准入检查准入开始时的当前选择。恢复会还原记录中的选择,即使尚未有请求使用它。ACP 客户端是受信控制器:stdio MCP 条目授权其绝对命令与环境,HTTP 条目授权其绝对 HTTP(S) URL 与 header;初始连接或发现失败会回滚尚未发布的 Agent。不支持的界面会被省略或拒绝:`session/load`、删除、fork、附加目录、SSE(Server-Sent Events)或 ACP 传输 MCP、mode、命令、计划、终端、客户端文件系统操作与 elicitation。
-----
+1 -1
View File
@@ -114,7 +114,7 @@ function resourceLinkText(block: Extract<AcpContentBlock, { type: 'resource_link
* writing; cancellation after a successful content-addressed write may leave an
* unreachable object but never queues a late user message.
* @param ctx - bridge context carrying attachment and model services.
* @param route - selection pinned to the accepted prompt.
* @param route - Agent model selection current at prompt admission.
* @param prompt - untrusted ACP prompt blocks in wire order.
* @param imageEnabled - capability result advertised during initialization.
* @param signal - admission cancellation signal.
+1 -11
View File
@@ -45,7 +45,6 @@ import {
type SessionNotification,
type Stream,
} from '@agentclientprotocol/sdk'
import type { ModelSelection } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-persistence'
// Side-effect type import: declaration-merges the approval waterfall answered below.
@@ -59,7 +58,7 @@ const DEFAULT_SESSION_LIST_PAGE_SIZE = 100
export const name = 'acp'
/** Core services required by the standard automation controls. */
export const inject = ['agents', 'llm', 'sessionPersistence', 'sessions']
export const inject = ['agents', 'llm', 'sessionPersistence', 'sessionProjections', 'sessions']
/** Preserve invalid-parameter detail in the SDK wire error message. */
function invalidParams(detail: string): RequestError {
@@ -212,7 +211,6 @@ export function apply(ctx: Context, config: AcpConfig): void {
cwd: params.cwd,
mcpServers: params.mcpServers,
agentOptions: agentOptions(config),
fallbackSelection: initialSelection(config),
signal,
notify,
})
@@ -263,7 +261,6 @@ export function apply(ctx: Context, config: AcpConfig): void {
cwd: params.cwd,
mcpServers: params.mcpServers ?? [],
agentOptions: agentOptions(config),
fallbackSelection: initialSelection(config),
signal,
notify,
})
@@ -452,13 +449,6 @@ function agentOptions(config: AcpConfig): { provider?: string; model?: string }
}
}
/** Initial session selection when both deployment fields are present. */
function initialSelection(config: AcpConfig): ModelSelection | undefined {
return config.provider === undefined || config.model === undefined
? undefined
: { provider: config.provider, model: config.model }
}
interface SessionListCursor {
createdAt: number
sessionId: string
+19 -52
View File
@@ -1,9 +1,9 @@
/** Standard ACP session configuration over one Agent's model selection. */
import type { Context } from '@deepseek-ai/cordis'
import type { SessionConfigOption, SessionConfigValueId } from '@agentclientprotocol/sdk'
import { installModelSelection, type ModelSelection, type ModelSelectionRef } from '@deepseek-ai/dsh-agent'
import { currentModelSelection, recordModelSelection, type Agent, type ModelSelection } from '@deepseek-ai/dsh-agent'
import { ReasoningEffortId, type LlmCallConfig, type LlmRuntime } from '@deepseek-ai/dsh-llm'
import type SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
const MODEL_CONFIG_ID = 'model'
const REASONING_CONFIG_ID = 'reasoning_effort'
@@ -28,60 +28,27 @@ export class AcpModelConfigError extends Error {
}
}
/** Project and mutate one Agent's provider/model/reasoning selection through ACP config options. */
/**
* Project and mutate one Agent's provider/model/reasoning selection through ACP
* config options. The Session's recorded model selection is the only state; a
* change applies from the Agent's next model step.
*/
export class AcpModelControl {
/** Scoped selection reference consumed by Agent request assembly. */
readonly selection: ModelSelectionRef
private tail = Promise.resolve()
private selected: ModelSelection | undefined
private turnSelection: { turn: number; selection: ModelSelection } | undefined
private hasResolvedState = false
constructor(
private readonly llm: LlmRuntime,
initial: ModelSelection | undefined,
) {
this.selected = initial
const getCurrent = (): ModelSelection | undefined => this.turnSelection?.selection ?? this.selected
const setCurrent = (value: ModelSelection | undefined): void => { this.selected = value }
this.selection = {
get current() { return getCurrent() },
set current(value) { setCurrent(value) },
assembled: undefined,
}
}
private readonly projections: SessionProjectionRegistry,
private readonly agent: Agent,
) {}
/**
* Install request/prompt consistency listeners in the unpublished Agent scope.
* @param agentCtx - Agent scope that consumes this selection.
* Read the selection the Agent's next model step uses.
* @returns a detached selection, or undefined when neither the Session nor the deployment names a route.
*/
install(agentCtx: Context): void {
installModelSelection(agentCtx, this.selection)
}
/**
* Snapshot the selection attached to the next accepted ACP prompt.
* @returns a detached future selection, or undefined when listeners supply the route.
*/
snapshot(): ModelSelection | undefined {
return this.selected === undefined ? undefined : { ...this.selected }
}
/**
* Pin one admitted ACP message's selection for every step in its turn.
* @param turn - admitted Agent turn.
* @param selection - exact prompt-admission selection.
*/
pinTurn(turn: number, selection: ModelSelection): void {
this.turnSelection = { turn, selection: { ...selection } }
}
/**
* Release only the exact completed turn's routing override.
* @param turn - completed Agent turn.
*/
releaseTurn(turn: number): void {
if (this.turnSelection?.turn === turn) this.turnSelection = undefined
current(): ModelSelection | undefined {
return currentModelSelection(this.projections, this.agent)
}
/**
@@ -103,14 +70,14 @@ export class AcpModelControl {
set(configId: string, value: unknown, signal?: AbortSignal): Promise<SessionConfigOption[]> {
return this.serialize(async () => {
if (typeof value !== 'string') throw new AcpModelConfigError(`${configId} requires a select value`)
const current = this.selected
const current = this.current()
if (current === undefined) throw new AcpModelConfigError('this session has no model selection')
if (configId === MODEL_CONFIG_ID) {
const state = await this.state(signal)
const selected = state.choices.get(value)
if (selected === undefined) throw new AcpModelConfigError(`unknown model option: ${value}`)
await this.resolveSelection(selected, signal)
this.selected = selected
recordModelSelection(this.projections, this.agent, selected)
} else if (configId === REASONING_CONFIG_ID) {
const info = await this.llm.resolveModelInfo(current.provider, current.model, signal)
const providerDefault = value === PROVIDER_DEFAULT_REASONING_VALUE
@@ -121,11 +88,11 @@ export class AcpModelControl {
) {
throw new AcpModelConfigError(`unknown reasoning effort for ${current.provider}/${current.model}: ${value}`)
}
this.selected = await this.resolveSelection({
recordModelSelection(this.projections, this.agent, await this.resolveSelection({
provider: current.provider,
model: current.model,
...providerDefault ? {} : { reasoningEffort: ReasoningEffortId(value) },
}, signal)
}, signal))
} else {
throw new AcpModelConfigError(`unknown session config option: ${configId}`)
}
@@ -142,7 +109,7 @@ export class AcpModelControl {
/** Build detached model choices and the dependent reasoning option. */
private async state(signal?: AbortSignal): Promise<ConfigState> {
const selected = this.selected
const selected = this.current()
if (selected === undefined) return { choices: new Map(), options: [] }
let resolved: ModelSelection
let routeAvailable = true
+23 -57
View File
@@ -10,7 +10,8 @@ import {
type SessionNotification,
type StopReason,
} from '@agentclientprotocol/sdk'
import type { Agent, AgentHandle, AgentOptions, AgentStatus, ModelSelection } from '@deepseek-ai/dsh-agent'
import { installModelSwitchNotice } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
import { createUserMessage, errorChain, type UserMessage } from '@deepseek-ai/dsh-llm'
import { type Session, type SessionEvent, type SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
import { AcpContentError, admitAcpPrompt } from './content.ts'
@@ -32,7 +33,6 @@ interface AcpSessionBuildOptions {
cwd: string
mcpServers: readonly McpServer[]
agentOptions: AgentOptions
fallbackSelection: ModelSelection | undefined
signal: AbortSignal
notify: (notification: SessionNotification) => Promise<void>
}
@@ -74,29 +74,20 @@ function internalError(detail: string): RequestError {
return RequestError.internalError(undefined, detail)
}
/** Restore the latest logged route before falling back to deployment config. */
function selectionFor(
logged: {
config: { provider: string; model: string; reasoningEffort?: ModelSelection['reasoningEffort'] }
adapterDefaults?: { reasoningEffort?: boolean }
} | undefined,
fallback: ModelSelection | undefined,
): ModelSelection | undefined {
return logged === undefined
? fallback
: {
provider: logged.config.provider,
model: logged.config.model,
...logged.config.reasoningEffort === undefined || logged.adapterDefaults?.reasoningEffort === true
? {}
: { reasoningEffort: logged.config.reasoningEffort },
}
/**
* Install switch notices and mount the requested MCP clients in the unpublished Agent scope.
* @param agentCtx - unpublished Agent scope.
* @param options - workspace and MCP servers requested for the session.
*/
async function composeAgent(agentCtx: Context, options: AcpSessionBuildOptions): Promise<void> {
installModelSwitchNotice(agentCtx)
await mountAcpMcpServers(agentCtx, options.mcpServers, options.cwd)
}
/**
* Per-session ACP module. It owns the unpublished Agent composition, selected
* route, one-prompt admission slot, ordered standard updates, and memoized
* quiescent teardown.
* Per-session ACP module. It owns the unpublished Agent composition, model
* configuration view, one-prompt admission slot, ordered standard updates,
* and memoized quiescent teardown.
*/
export class AcpSession {
/** The exact top-level Agent owned by this ACP session. */
@@ -105,16 +96,14 @@ export class AcpSession {
private outputTail = Promise.resolve()
private inflight: InflightPrompt | undefined
private closing: Promise<void> | undefined
private readonly pendingSelections = new Map<string, ModelSelection>()
private constructor(
private readonly ctx: Context,
handle: AgentHandle,
modelControl: AcpModelControl,
private readonly notify: (notification: SessionNotification) => Promise<void>,
) {
this.agent = handle.agent
this.modelControl = modelControl
this.modelControl = new AcpModelControl(ctx.llm, ctx.sessionProjections, handle.agent)
this.disposeAgent = () => handle.dispose()
}
@@ -127,48 +116,32 @@ export class AcpSession {
* @returns the fully composed per-session module.
*/
static async create(ctx: Context, options: CreateAcpSessionOptions): Promise<AcpSession> {
const modelControl = new AcpModelControl(ctx.llm, options.fallbackSelection)
const handle = await ctx.agents.create({
sessionId: options.sessionId,
meta: { cwd: options.cwd },
agentOptions: options.agentOptions,
signal: options.signal,
setup: async (agentCtx) => {
modelControl.install(agentCtx)
await mountAcpMcpServers(agentCtx, options.mcpServers, options.cwd)
},
setup: agentCtx => composeAgent(agentCtx, options),
})
return new AcpSession(ctx, handle, modelControl, options.notify)
return new AcpSession(ctx, handle, options.notify)
}
/**
* Restore a persisted Agent and compose the request's fresh MCP connections.
* The Session's recorded model selection survives; deployment options supply
* the route only when the Session records neither a selection nor a request.
* @param ctx - ACP plugin context with Agent, LLM, and persistence services.
* @param options - persisted identity, workspace, fallback route, MCP, and notifier.
* @param options - persisted identity, workspace, initial route, MCP, and notifier.
* @returns the restored per-session module.
*/
static async resume(ctx: Context, options: ResumeAcpSessionOptions): Promise<AcpSession> {
let modelControl: AcpModelControl | undefined
const handle = await ctx.agents.resume({
resumeSessionId: options.sessionId,
agentOptions: options.agentOptions,
signal: options.signal,
setup: async (agentCtx, agent) => {
modelControl = new AcpModelControl(
ctx.llm,
selectionFor(agent.session.requestHeader(), options.fallbackSelection),
)
modelControl.install(agentCtx)
await mountAcpMcpServers(agentCtx, options.mcpServers, options.cwd)
},
setup: agentCtx => composeAgent(agentCtx, options),
})
/* v8 ignore start -- a fulfilled Agent resume necessarily ran setup to completion. */
if (modelControl === undefined) {
await handle.dispose()
throw internalError('session/resume did not compose model selection')
}
/* v8 ignore stop */
return new AcpSession(ctx, handle, modelControl, options.notify)
return new AcpSession(ctx, handle, options.notify)
}
/**
@@ -276,7 +249,7 @@ export class AcpSession {
if (requestSignal?.aborted === true) onRequestAbort()
try {
let admissionFailure: unknown
const promptSelection = this.modelControl.snapshot()
const promptSelection = this.modelControl.current()
try {
if (this.ctx.agents.get(this.agent.id) !== this.agent) {
throw internalError('prompt was not queued: the agent was disposed outside the bridge')
@@ -298,12 +271,10 @@ export class AcpSession {
})
inflight.messageId = message.id
inflight.messageQueued = true
if (promptSelection !== undefined) this.pendingSelections.set(message.id, promptSelection)
try {
this.agent.followup(message)
} catch (error: unknown) {
inflight.messageQueued = false
this.pendingSelections.delete(message.id)
throw error
}
} catch (error: unknown) {
@@ -393,20 +364,16 @@ export class AcpSession {
inflight.endReason = event.data.reason
inflight.agentError = undefined
}
if (event.type === 'turn/end') this.modelControl.releaseTurn(event.data.turn)
}
}
/**
* Correlate an accepted user message with its Agent turn and pinned route.
* Correlate an accepted user message with its Agent turn.
* @param message - claimed durable inbox message.
* @param turn - allocated Agent turn.
*/
onInboxClaimed(message: UserMessage, turn: number): void {
if (this.inflight !== undefined && this.inflight.messageId === message.id) this.inflight.turn = turn
const selection = this.pendingSelections.get(message.id)
this.pendingSelections.delete(message.id)
if (selection !== undefined) this.modelControl.pinTurn(turn, selection)
}
/**
@@ -475,7 +442,6 @@ export class AcpSession {
} catch (error: unknown) {
failures.push(error)
}
this.pendingSelections.clear()
if (failures.length === 1) throw failures[0]
/* v8 ignore start -- independent teardown failures can aggregate only under multiple simultaneous provider faults. */
if (failures.length > 1) {
+9 -11
View File
@@ -1,3 +1,4 @@
import { recordModelSelection } from '@deepseek-ai/dsh-agent'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { mkdtemp, rm } from 'node:fs/promises'
@@ -667,7 +668,7 @@ describe('automation-only ACP bridge', () => {
expect(harness.adapter.requests[0]?.reasoningEffort).toBeUndefined()
})
it('pins image admission and request routing to one prompt selection', async () => {
it('checks images against the admission route and uses the latest selection when the step starts', async () => {
harness = await makeBridgeHarness({ imageCapable: true, script: [textResponse('image accepted')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -696,7 +697,7 @@ describe('automation-only ACP bridge', () => {
releaseValidation.resolve(undefined)
await expect(prompt).resolves.toEqual({ stopReason: 'end_turn' })
expect(harness.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'mock' })
expect(harness.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'plain' })
harness.attachments!.beforeValidate = undefined
await expect(harness.client.prompt({
sessionId: created.sessionId,
@@ -704,7 +705,7 @@ describe('automation-only ACP bridge', () => {
})).rejects.toThrow(/does not declare image input/)
})
it('applies a mid-turn model change to the following turn', async () => {
it('applies a mid-turn model change to the following step', async () => {
harness = await makeBridgeHarness({ script: [oneToolCall(), textResponse('first turn'), textResponse('second turn')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -715,7 +716,7 @@ describe('automation-only ACP bridge', () => {
if (plain === undefined) throw new Error('expected Mock Plain in the model catalog')
harness.ctx.tools.register(defineContentToolFixture({
name: 'switch_model',
description: 'Switch the following turn to the plain model.',
description: 'Switch the following step to the plain model.',
parameters: {},
execute: async () => {
await harness!.client.setSessionConfigOption({
@@ -730,7 +731,7 @@ describe('automation-only ACP bridge', () => {
await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'first' }] })
await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'second' }] })
expect(harness.adapter.requests.map(request => request.model)).toEqual(['mock', 'mock', 'plain'])
expect(harness.adapter.requests.map(request => request.model)).toEqual(['mock', 'plain', 'plain'])
})
it('mounts a standard stdio MCP server inside the created session', async () => {
@@ -834,20 +835,17 @@ describe('automation-only ACP bridge', () => {
expect(harness.ctx.agents.get(SessionId(sessionId))?.options).toEqual({})
})
it('allows request listeners to supply a route when ACP has no initial selection', async () => {
it('uses a recorded selection when ACP has no initial route', async () => {
harness = await makeBridgeHarness({
config: { provider: undefined, model: undefined },
script: [textResponse('listener-routed')],
})
harness.ctx.on('agent/request', async (_payload, next) => ({
...await next(),
provider: 'mock',
model: 'mock',
}))
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(created.configOptions).toEqual([])
const agent = harness.ctx.agents.get(SessionId(created.sessionId))!
recordModelSelection(harness.ctx.sessionProjections, agent, { provider: 'mock', model: 'mock' })
await expect(harness.client.prompt({
sessionId: created.sessionId,
prompt: [{ type: 'text', text: 'route me' }],
+30 -16
View File
@@ -1,4 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { type Agent, type ModelSelection } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { ReasoningEffortId, type LlmRuntime } from '@deepseek-ai/dsh-llm'
import { AcpModelControl } from '../src/model-control.ts'
@@ -30,21 +34,32 @@ function llmRuntime(overrides: Partial<LlmRuntime> = {}): LlmRuntime {
} as unknown as LlmRuntime
}
async function createControl(runtime: LlmRuntime, initial?: ModelSelection) {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(SessionId('model-control'))
const agent = { id: session.id, session, options: initial ?? {} } as Agent
return { control: new AcpModelControl(runtime, ctx.sessionProjections, agent), session }
}
describe('ACP model configuration control', () => {
it('represents an absent route and validates value types before mutation', async () => {
const control = new AcpModelControl(llmRuntime(), undefined)
const { control, session } = await createControl(llmRuntime())
expect(control.snapshot()).toBeUndefined()
expect(control.current()).toBeUndefined()
await expect(control.options()).resolves.toEqual([])
await expect(control.set('model', false)).rejects.toThrow(/requires a select value/)
await expect(control.set('model', 'missing')).rejects.toThrow(/no model selection/)
control.selection.current = { provider: 'mock', model: 'mock' }
expect(control.selection.current).toEqual({ provider: 'mock', model: 'mock' })
session.append('model/selection', { provider: 'mock', model: 'mock' })
expect(control.current()).toEqual({ provider: 'mock', model: 'mock' })
})
it('synthesizes an unlisted current route and exposes reasoning descriptions', async () => {
const control = new AcpModelControl(llmRuntime({ listProviders: () => [] }), {
const { control } = await createControl(llmRuntime({ listProviders: () => [] }), {
provider: 'private',
model: 'unlisted',
})
@@ -64,17 +79,13 @@ describe('ACP model configuration control', () => {
options: [{ name: 'Low', description: 'Less thought.' }, { name: 'High' }],
})
control.pinTurn(3, { provider: 'turn', model: 'pinned' })
expect(control.selection.current).toEqual({ provider: 'turn', model: 'pinned' })
control.releaseTurn(2)
expect(control.selection.current).toEqual({ provider: 'turn', model: 'pinned' })
control.releaseTurn(3)
expect(control.selection.current).toEqual({ provider: 'private', model: 'unlisted' })
expect(control.current()).toEqual({ provider: 'private', model: 'unlisted' })
})
it('keeps the selected route when its provider catalog is temporarily unavailable', async () => {
const listModels = vi.fn(() => Promise.reject(new Error('catalog unavailable')))
const control = new AcpModelControl(llmRuntime({ listModels }), { provider: 'mock', model: 'mock' })
const { control } = await createControl(llmRuntime({ listModels }), { provider: 'mock', model: 'mock' })
const options = await control.options()
@@ -86,12 +97,15 @@ describe('ACP model configuration control', () => {
})
it('rejects an unadvertised reasoning effort and accepts a later valid change', async () => {
const control = new AcpModelControl(llmRuntime(), { provider: 'mock', model: 'mock' })
const { control, session } = await createControl(llmRuntime(), { provider: 'mock', model: 'mock' })
await expect(control.set('reasoning_effort', 'extreme')).rejects.toThrow(/unknown reasoning effort/)
const options = await control.set('reasoning_effort', 'low')
expect(options.find(option => option.id === 'reasoning_effort')).toMatchObject({ currentValue: 'low' })
expect(session.snapshotEvents().map(event => ({ type: event.type, data: event.data }))).toEqual([{
type: 'model/selection', data: { provider: 'mock', model: 'mock', reasoningEffort: 'low' },
}])
})
it('exposes and restores a provider-owned reasoning default', async () => {
@@ -115,7 +129,7 @@ describe('ACP model configuration control', () => {
},
}),
})
const control = new AcpModelControl(runtime, { provider: 'mock', model: 'mock' })
const { control } = await createControl(runtime, { provider: 'mock', model: 'mock' })
const initial = await control.options()
expect(initial.find(option => option.id === 'reasoning_effort')).toMatchObject({
@@ -126,6 +140,6 @@ describe('ACP model configuration control', () => {
const restored = await control.set('reasoning_effort', '')
expect(restored.find(option => option.id === 'reasoning_effort')).toMatchObject({ currentValue: '' })
expect(control.selection.current).toEqual({ provider: 'mock', model: 'mock' })
expect(control.current()).toEqual({ provider: 'mock', model: 'mock' })
})
})
+7 -3
View File
@@ -1,7 +1,8 @@
import { AcpSession } from '../src/session.ts'
import { mountWorkingDirectoryFixture } from '../../../subagent/subagent/tests/working-directory-fixture.ts'
import { createUserMessage, type StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { SessionId } from '@deepseek-ai/dsh-session'
import { setImmediate } from 'node:timers/promises'
@@ -470,13 +471,16 @@ describe('ACP prompt lifecycle', () => {
expect(harness.adapter.requests).toEqual([])
})
it('honors cancellation in the admission-to-followup handoff gap', async () => {
it('cancels after image storage completes before admission receives the result', async () => {
harness = await makeBridgeHarness({ imageCapable: true, script: [] })
const sessionId = await newSession(harness)
const saveImages = harness.attachments!.saveImages.bind(harness.attachments!)
const cancel = vi.spyOn(AcpSession.prototype, 'cancel')
onTestFinished(() => { cancel.mockRestore() })
vi.spyOn(harness.attachments!, 'saveImages').mockImplementationOnce(async (inputs) => {
const refs = await saveImages(inputs)
queueMicrotask(() => { void harness!.client.cancel({ sessionId }) })
await harness!.client.cancel({ sessionId })
await vi.waitFor(() => { expect(cancel).toHaveBeenCalledOnce() })
return refs
})
@@ -1,3 +1,4 @@
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -9,6 +10,7 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session'
/** A registry with a tiny ring so eviction is cheap to reach. */
export async function harness(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalJobRegistry, { retainBytes: 64, settledRetainBytes: 64 })
ctx.jobs.attachController('job-controller-test')
@@ -1,3 +1,4 @@
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -45,6 +46,7 @@ async function harness(): Promise<{
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalJobRegistry)
ctx.jobs.attachController('kill-test')
@@ -1,3 +1,4 @@
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
@@ -215,6 +216,7 @@ describe('JobController', () => {
async function controllerHarness() {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalJobRegistry)
await ctx.plugin(TypertRegistry)
@@ -247,6 +249,7 @@ describe('JobController', () => {
it('waits for the job registry instead of serving without one', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TypertRegistry)
ctx.plugin(JobController, {})
@@ -21,8 +21,8 @@
en: 41bdda1ac32df558
zh: aa5d0e325924066b
/session-controller/session-media-references:
en: 266ab2b1e2fec542
zh: b3450ae28a1e0baf
en: 02c29917c8ee2054
zh: 0e47b403b7354c05
/session-controller/configuration:
en: 5ae2b340c630897f
zh: 1ffbd61ecf302ec2
+1 -1
View File
@@ -88,7 +88,7 @@ References keep local Session data, scoped Contexts, and history streams alive,
GUI model selection requires the exact provider/model pair in the available catalog; unavailable choices reject with `session/model-unavailable`. Prompt admission retains the saved route without a catalog availability gate, so request execution reports missing credentials or unavailable models. `initializeDefaultModel()` saves the first available account model after login when no other provider has a configured API key; credential checks use configured references independently of model availability. A provider with no available models rejects initialization with `session/provider-models-unavailable`. Availability never substitutes another model or rewrites a Session selection.
A successful `selectModel` response acknowledges the Session-local selection without waiting for the default profile setting to save. Default saves run in the background in submission order; a failure logs a warning and leaves the Session selection intact. New Sessions read the last successfully saved default.
A successful `selectModel` response acknowledges the recorded Session selection without waiting for the default profile setting to save. The Agent registry owns its projection; a change applies to the next step while a prepared step retains its captured choice. Default saves run in the background in submission order; a failure logs a warning and leaves the Session selection intact. New Sessions read the last successfully saved default.
-----
+1 -1
View File
@@ -88,7 +88,7 @@ Client 的首次 `follow`、重连首屏与 `loadOlder()` 至少请求 50 条以
GUI 模型选择要求确切提供方/模型对出现在可用目录中;不可用的选择以 `session/model-unavailable` 拒绝。提示词准入保留已保存的路由,不按目录可用性阻断发送,由请求执行报告凭据缺失或模型不可用。`initializeDefaultModel()` 在账号登录后、其他提供方均未配置 API key 时,将第一个可用账号模型保存为默认模型;凭据检查使用已配置的引用,不依赖模型是否可用。提供方没有可用模型时,初始化以 `session/provider-models-unavailable` 拒绝。可用性变化不会替换模型或改写会话选择。
`selectModel` 成功返回表示会话级模型选择已生效,不等待默认 profile 设置保存。默认设置在后台按提交顺序保存;保存失败会记录警告,并保留会话选择。新会话读取最近一次成功保存的默认值。
`selectModel` 成功返回表示会话模型选择已记录,不等待默认 profile 设置保存。Agent 注册表拥有该投影;变更从下一个步骤开始生效,已准备的步骤保留捕获的选择。默认设置在后台按提交顺序保存;保存失败会记录警告,并保留会话选择。新会话读取最近一次成功保存的默认值。
-----
+13 -109
View File
@@ -1,22 +1,18 @@
/** Agent activation, composition, and model-selection policy owned by API Session. */
/** Agent activation, composition, and initial model route owned by API Session. */
import { randomUUID } from 'node:crypto'
import { mkdir } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/cordis'
import { installModelSelection } from '@deepseek-ai/dsh-agent'
import type {
Agent, AgentOptions, AgentSetup, ModelSelection as AgentModelSelection, ModelSelectionRef,
} from '@deepseek-ai/dsh-agent'
import { installModelSwitchNotice } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentOptions, AgentSetup } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-default-model'
import type {} from '@deepseek-ai/dsh-agent-preset-registry'
import { brandString } from '@deepseek-ai/dsh-brand'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
import type {} from '@deepseek-ai/dsh-typert-registry'
import type { ModelSelection } from './types.ts'
/** Cold Session identity absent from persistence. */
export class ApiSessionNotFound extends Error {}
@@ -95,11 +91,6 @@ export type ApiSessionAgentResult =
| { readonly agent: Agent }
| { readonly error: ApiSessionAgentError }
type InstalledSelection = ModelSelectionRef & {
current: AgentModelSelection
consume(provider: string, model: string, reasoningEffort: string | undefined): boolean
}
/**
* Test whether generic Session routing must leave an identity to subagent routing.
* @param ctx - Host context carrying the Agent ownership registry.
@@ -170,7 +161,6 @@ export async function inspectApiSession(
export class ApiSessionAgentController {
private readonly resumes = new Map<SessionId, Promise<Agent>>()
private readonly claims = new Map<SessionId, Promise<void>>()
private readonly selections = new WeakMap<Agent, InstalledSelection>()
private readonly imageAdmissionChains = new WeakMap<Agent, Promise<void>>()
/** @param ctx - Host context carrying Agent, model, persistence, and Typert services. */
@@ -281,83 +271,6 @@ export class ApiSessionAgentController {
return this.inOrder(named, async () => this.created(await this.claim(named, request)))
}
/**
* Install or return the Session-local model selection used by prompt assembly.
* @param agent - live Agent that owns the selection.
* @returns the installed mutable selection reference.
*/
selectionFor(agent: Agent): InstalledSelection {
const installed = this.selections.get(agent)
if (installed !== undefined) return installed
const projectionState = this.ctx.sessionProjections.stateOf(agent.session, 'modelSelection')
if (projectionState === undefined) {
throw new Error('api-session: required modelSelection projection is not registered')
}
let picked = projectionState.pending === null
? undefined
: agentModelSelection(projectionState.pending)
const defaultModel = this.ctx.agentDefaultModel
const selection: InstalledSelection = {
get current(): AgentModelSelection {
if (picked !== undefined) return picked
const loggedHeader = agent.session.requestHeader()
if (loggedHeader === undefined) return defaultModel.currentSelection()
const logged = loggedHeader.config
return {
provider: logged.provider,
model: logged.model,
// An effort the adapter defaulted is not a conversation choice: restoring
// it as one would make an unchanged default read as a request change.
...(logged.reasoningEffort === undefined
|| loggedHeader.adapterDefaults?.reasoningEffort === true
? {}
: { reasoningEffort: logged.reasoningEffort }),
}
},
set current(next: AgentModelSelection) {
picked = next
},
consume(provider: string, model: string, reasoningEffort: string | undefined): boolean {
if (picked?.provider !== provider
|| picked.model !== model
|| picked.reasoningEffort !== reasoningEffort) return false
picked = undefined
return true
},
assembled: undefined,
}
installModelSelection(agent.ctx, selection)
this.selections.set(agent, selection)
return selection
}
/**
* Commit and cache one validated selection for the next prompt assembly.
* @param agent - live Agent that owns the selection.
* @param selection - validated selection to record and apply.
*/
selectForNextRequest(agent: Agent, selection: AgentModelSelection): void {
agent.session.append('model/selection', selection)
this.selectionFor(agent).current = selection
}
/**
* Let a matching durable request header retire the execution cache.
* @param agent - live Agent whose request was recorded.
* @param provider - provider route used by the request.
* @param model - provider-owned model used by the request.
* @param reasoningEffort - adapter-owned effort used by the request.
* @returns whether the pending selection was consumed.
*/
consumeSelection(
agent: Agent,
provider: string,
model: string,
reasoningEffort: string | undefined,
): boolean {
return this.selections.get(agent)?.consume(provider, model, reasoningEffort) ?? false
}
/**
* Read the current Agent preset from the Session projection.
* @param session - live Session whose projection state is available.
@@ -387,13 +300,13 @@ export class ApiSessionAgentController {
async composeAgent(presetId: string | undefined): Promise<ApiSessionComposition> {
const presets = this.ctx.get('agentPresets')
if (presets === undefined) {
return { setup: (_agentCtx, agent) => { this.installSelection(agent) } }
return { setup: (agentCtx) => { installModelSwitchNotice(agentCtx) } }
}
const resolvedId = (await presets.resolve(presetId)).id
return {
agentPreset: resolvedId,
setup: async (agentCtx, agent) => {
this.installSelection(agent)
setup: async (agentCtx) => {
installModelSwitchNotice(agentCtx)
await presets.mount(agentCtx, resolvedId)
},
}
@@ -578,12 +491,13 @@ export class ApiSessionAgentController {
}
private agentOptions(): AgentOptions {
const { provider, model } = this.ctx.agentDefaultModel.currentSelection()
return { provider, model }
}
private installSelection(agent: Agent): void {
this.selectionFor(agent)
const defaults = this.ctx.agentDefaultModel
// A Session that records no selection or request follows default changes.
return {
get provider() { return defaults.currentSelection().provider },
get model() { return defaults.currentSelection().model },
get reasoningEffort() { return defaults.currentSelection().reasoningEffort },
}
}
/**
@@ -598,13 +512,3 @@ export class ApiSessionAgentController {
return observation.projections.values.agentPreset ?? undefined
}
}
function agentModelSelection(selection: ModelSelection): AgentModelSelection {
return {
provider: selection.provider,
model: selection.model,
...(selection.reasoningEffort === undefined
? {}
: { reasoningEffort: ReasoningEffortId(selection.reasoningEffort) }),
}
}
@@ -4,6 +4,7 @@ import { modelAvailable } from './catalog.ts'
import { randomUUID } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
import { brandString } from '@deepseek-ai/dsh-brand'
import { currentModelSelection, recordModelSelection } from '@deepseek-ai/dsh-agent'
import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
import type {
@@ -93,7 +94,7 @@ function latestCompletedPrefixBoundary(events: readonly SessionEvent[]): Session
export class SessionCommandController {
/**
* @param ctx - Host context carrying Agent, model, attachment, title, and Workspace services.
* @param agents - sole owner of create, resume, and Session-local model selection.
* @param agents - sole owner of create, resume, and Agent composition.
* @param defaultCwd - project directory used when create names neither a Workspace nor a cwd.
*/
constructor(
@@ -150,9 +151,9 @@ export class SessionCommandController {
}
/**
* Validate and install one Session-local model selection; save the default in the background.
* Validate and record one Session model selection; save the default in the background.
* @param request - Session identity and requested model selection.
* @returns the normalized selection installed for the Session, without waiting for default persistence.
* @returns the normalized selection recorded for the Session, without waiting for default persistence.
*/
async selectModel(request: SessionSelectModelRequest): Promise<SessionSelectModelValue> {
const agent = await this.resolveAgent(request.sessionId)
@@ -173,7 +174,7 @@ export class SessionCommandController {
? {}
: { reasoningEffort: resolved.reasoningEffort }),
}
this.agents.selectForNextRequest(agent, selected)
recordModelSelection(this.ctx.sessionProjections, agent, selected)
void this.ctx.agentDefaultModel.saveSelection(selected).catch((error: unknown) => {
this.ctx.logger.warn(
`session-controller: model selection changed for the Session but the default was not saved: ${String(error)}`,
@@ -322,8 +323,8 @@ export class SessionCommandController {
const hasImage = request.content.some(part => part.type === 'image')
const admit = async (): Promise<SessionPromptValue> => {
try {
if (hasImage) {
const current = this.agents.selectionFor(agent).current
const current = hasImage ? currentModelSelection(this.ctx.sessionProjections, agent) : undefined
if (current !== undefined) {
const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model)
if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) {
throw new RemoteError(
@@ -28,7 +28,6 @@ import { SessionHistoryController } from './history.ts'
import { SessionFileReferences } from './file-references.ts'
import { ApiSessionList } from './list.ts'
import { buildModelCatalog, hasProviderApiKey } from './catalog.ts'
import { installModelSelectionProjection } from './model-selection-projection.ts'
import { SessionSkillCatalog } from './skill-catalog.ts'
import { SessionMediaReferences } from './media-references.ts'
import { ArchivedSessionGate } from './archived-session-gate.ts'
@@ -158,7 +157,6 @@ export class SessionController extends TypertRemoteService {
super(ctx, 'sessionController', { namespace: 'session' })
ctx.typert.resources.register('session', { field: 'sessionId' })
const resolved = SessionController.Config(config)
installModelSelectionProjection(ctx)
this.agents = new ApiSessionAgentController(ctx)
this.commands = new SessionCommandController(ctx, this.agents, process.cwd())
ctx.effect(() => ctx.fileUploads.registerAgentResolver(async (sessionId) => {
@@ -208,15 +206,6 @@ export class SessionController extends TypertRemoteService {
ctx.emit('api-session/error', agent.id, errorChain(error))
})
ctx.on('session/event', (session, event) => {
if (event.type === 'request/header') {
const agent = ctx.agents.get(session.id)
if (agent?.session === session) this.agents.consumeSelection(
agent,
event.data.header.config.provider,
event.data.header.config.model,
event.data.header.config.reasoningEffort,
)
}
if (event.type !== 'user/message' || event.data.source.kind !== 'user') return
ctx.emit('api-session/activity', session.id, event.time)
})
@@ -1,83 +0,0 @@
/** Durable model-selection intent and request-use projection. */
import type { Context } from '@deepseek-ai/cordis'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import { z } from 'zod'
import type {
ModelSelection,
ModelSelectionProjection,
ModelSelectionProjectionState,
} from './types.ts'
const modelSelectionSchema = z.object({
provider: z.string().min(1),
model: z.string().min(1),
reasoningEffort: z.string().min(1).optional(),
}) as unknown as z.ZodType<ModelSelection>
const modelSelectionProjectionStateSchema = z.object({
lastUsed: modelSelectionSchema.nullable(),
pending: modelSelectionSchema.nullable(),
}) as z.ZodType<ModelSelectionProjectionState>
const modelSelectionProjectionSchema = z.object({
lastUsed: modelSelectionSchema.nullable(),
next: modelSelectionSchema.nullable(),
}) as z.ZodType<ModelSelectionProjection>
/**
* Advance durable model-selection state by one Session event.
* @param state - selection state before the event.
* @param event - next committed Session event.
* @returns the original or advanced selection state.
*/
function applyModelSelectionProjection(
state: ModelSelectionProjectionState,
event: SessionEvent,
): ModelSelectionProjectionState {
if (event.type === 'model/selection') {
return sameSelection(state.pending, event.data)
? state
: { lastUsed: state.lastUsed, pending: event.data }
}
if (event.type !== 'request/header') return state
const lastUsed: ModelSelection = {
provider: event.data.header.config.provider,
model: event.data.header.config.model,
...(event.data.header.config.reasoningEffort === undefined
? {}
: { reasoningEffort: String(event.data.header.config.reasoningEffort) }),
}
const pending = sameSelection(state.pending, lastUsed) ? null : state.pending
return sameSelection(state.lastUsed, lastUsed) && pending === state.pending
? state
: { lastUsed, pending }
}
const modelSelectionProjection = {
key: 'modelSelection',
stateSchema: modelSelectionProjectionStateSchema,
init: () => ({ lastUsed: null, pending: null }),
apply: applyModelSelectionProjection,
wire: {
viewSchema: modelSelectionProjectionSchema,
view: state => ({ lastUsed: state.lastUsed, next: state.pending ?? state.lastUsed }),
},
stateVersion: 2,
} satisfies ProjectionDefinition<'modelSelection', ModelSelectionProjectionState>
function sameSelection(left: ModelSelection | null, right: ModelSelection | null): boolean {
return left === right || (left !== null && right !== null
&& left.provider === right.provider
&& left.model === right.model
&& left.reasoningEffort === right.reasoningEffort)
}
/**
* Register the durable model-selection projection when the registry is present.
* @param ctx - Session Controller context.
*/
export function installModelSelectionProjection(ctx: Context): void {
ctx.sessionProjections.register(modelSelectionProjection)
}
+2 -29
View File
@@ -19,26 +19,12 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
sessionListMetadata: SessionListMetadata
/** Host state for the boot-constant image-limit view. */
imageLimits: null
/** Durable model selection already used by a request and still pending for a later request. */
modelSelection: ModelSelectionProjectionState
}
interface SessionProjectionMap {
/** Persisted facts used to summarize a Session without activating it. */
sessionListMetadata: SessionListMetadata
/** Image-intake limits enforced by the Session prompt endpoint. */
imageLimits: ImageAttachmentLimits
/** Durable model selection already used and selected for the next request. */
modelSelection: ModelSelectionProjection
}
}
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
/**
* Complete validated model selection requested for subsequent prompt
* assembly. Log-only: it never enters derived model history.
*/
'model/selection': ModelSelection
}
}
@@ -104,21 +90,8 @@ export interface ModelSelection {
readonly reasoningEffort?: string
}
/** Host fold state for durable model selection. */
export interface ModelSelectionProjectionState {
/** Selection consumed by the latest recorded model request. */
readonly lastUsed: ModelSelection | null
/** Later user selection not yet consumed by a matching model request. */
readonly pending: ModelSelection | null
}
/** Client view of the durable model-selection fold. */
export interface ModelSelectionProjection {
/** Selection consumed by the latest recorded model request. */
readonly lastUsed: ModelSelection | null
/** Selection the next request should use, falling back to {@link lastUsed}. */
readonly next: ModelSelection | null
}
/** Client view of the durable model-selection fold owned by the Agent loop. */
export type { ModelSelectionProjection } from '@deepseek-ai/dsh-agent/types'
/** One adapter-owned reasoning effort for an exact model route. */
export interface ModelReasoningEffort {
@@ -1,3 +1,4 @@
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -17,7 +18,6 @@ import {
ApiSessionNotFound,
inspectApiSession,
} from '../src/agent.ts'
import { installModelSelectionProjection } from '../src/model-selection-projection.ts'
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
const roots: Context[] = []
@@ -35,10 +35,10 @@ async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentControl
roots.push(ctx)
await ctx.plugin(TypertRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
ctx.sessionProjections.register(agentPresetProjectionDefinition)
installModelSelectionProjection(ctx)
ctx.provide('agentDefaultModel', {
currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
saveSelection: () => Promise.resolve(),
@@ -286,48 +286,6 @@ describe('ApiSession Agent lookup and recovery', () => {
})
})
describe('ApiSession model selection', () => {
it('requires the model-selection projection', async () => {
const { ctx, agents } = await harness()
const live = agent(ctx, header('missing-model-projection'))
vi.spyOn(ctx.sessionProjections, 'stateOf').mockReturnValue(undefined)
expect(() => agents.selectionFor(live)).toThrow('required modelSelection projection')
})
it('reads a reasoning-free request and consumes only the exact pending selection', async () => {
const { ctx, agents } = await harness()
const logged = agent(ctx, header('logged-model'))
logged.session.append('request/header', {
header: { config: { provider: 'logged-provider', model: 'logged-model' } },
reason: 'initial',
})
expect(agents.selectionFor(logged).current).toEqual({
provider: 'logged-provider',
model: 'logged-model',
})
const pending = agent(ctx, header('pending-model'))
const selection = agents.selectionFor(pending)
agents.selectForNextRequest(pending, {
provider: 'selected-provider',
model: 'selected-model',
reasoningEffort: 'high' as never,
})
expect(selection.current).toMatchObject({
provider: 'selected-provider', model: 'selected-model', reasoningEffort: 'high',
})
expect(agents.consumeSelection(pending, 'other-provider', 'selected-model', 'high')).toBe(false)
expect(agents.consumeSelection(pending, 'selected-provider', 'other-model', 'high')).toBe(false)
expect(agents.consumeSelection(pending, 'selected-provider', 'selected-model', 'low')).toBe(false)
expect(agents.consumeSelection(pending, 'selected-provider', 'selected-model', 'high')).toBe(true)
expect(selection.current).toEqual({ provider: 'fixture', model: 'fixture-model' })
const untouched = agent(ctx, header('uninstalled-model'))
expect(agents.consumeSelection(untouched, 'fixture', 'fixture-model', undefined)).toBe(false)
})
})
describe('ApiSession creation', () => {
it('rejects a resume that subagent routing adopts before publication', async () => {
const child = await harness()
@@ -1,3 +1,4 @@
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -16,6 +17,7 @@ interface Harness {
async function harness(): Promise<Harness> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const archived: SessionId[] = []
ctx.provide('workspaceRegistry', { get archivedSessionIds() { return archived } } as never)
@@ -47,7 +49,7 @@ async function liveAgent(ctx: Context, session: Session): Promise<Agent> {
/** Propose one model step for the Agent; the innermost decision enters. */
const proposed = (ctx: Context, agent: Agent) => ctx.waterfall(
'agent/pre-step',
{ agent, messages: [], turn: 1, step: 1, signal: new AbortController().signal },
{ callConfig: { provider: 'mock', model: 'mock' }, agent, messages: [], turn: 1, step: 1, signal: new AbortController().signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
@@ -107,6 +109,7 @@ describe('Archived-session gate: lifetime', () => {
it('stops rejecting once the plugin fiber is disposed', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const archived: SessionId[] = []
ctx.provide('workspaceRegistry', { get archivedSessionIds() { return archived } } as never)
@@ -1,3 +1,4 @@
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
@@ -47,6 +48,7 @@ async function baseContext(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(TypertRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
ctx.provide('agentDefaultModel', {
@@ -1,7 +1,9 @@
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, Inbox, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
import type { Agent, Inbox } from '@deepseek-ai/dsh-agent'
import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { createAssistantMessage, createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
@@ -36,7 +38,9 @@ async function commandHarness(
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TypertRegistry)
installSessionReadTestServices(ctx)
ctx.sessionProjections.register(subagentIdentityProjectionDefinition)
const sessionId = SessionId('commands-session')
@@ -82,6 +86,11 @@ async function commandHarness(
const cancel = vi.fn()
const agent = {
id: session.id,
options: { provider: 'fixture', model: 'fixture-model' },
whenIdle: vi.fn(() => Promise.resolve()),
runMaintenance: async () => { throw new Error('unexpected maintenance') },
send: vi.fn(),
inject: vi.fn(),
session,
inbox,
status: 'running',
@@ -89,25 +98,18 @@ async function commandHarness(
steer,
followup: vi.fn(),
cancel,
} as unknown as Agent
} satisfies Agent
await ctx.agents.register(agent)
ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
ctx.provide('agentDefaultModel', {
currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
saveSelection: () => Promise.resolve(),
} as never)
const selection: ModelSelectionRef = {
current: { provider: 'fixture', model: 'fixture-model' },
assembled: undefined,
}
const agents = {
resolveAgent: (id: SessionId) => Promise.resolve(id === agent.id
? { agent }
: { error: new RemoteError('session/not-found', 'missing', { sessionId: id }) }),
selectionFor: () => selection,
serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
composeAgent: () => Promise.resolve({ setup: () => {} }),
} as unknown as ApiSessionAgentController
const agents = new ApiSessionAgentController(ctx)
vi.spyOn(agents, 'resolveAgent').mockImplementation(id => Promise.resolve(id === agent.id
? { agent }
: { error: new RemoteError('session/not-found', 'missing', { sessionId: id }) }))
vi.spyOn(agents, 'composeAgent').mockResolvedValue({ setup: () => {} })
return {
ctx,
controller: new SessionCommandController(ctx, agents, '/workspace'),
@@ -126,6 +128,7 @@ describe('Session queue commands', () => {
it('preserves the cold Agent resolver rejection', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const error = new RemoteError('session/agent-busy', 'owned by a child', { reason: 'subagent-owned' })
const controller = new SessionCommandController(ctx, {
@@ -1,7 +1,9 @@
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type {
FileAttachmentRef, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment,
@@ -14,7 +16,7 @@ import { createScope } from '@deepseek-ai/dsh-scope'
import FileUploads from '@deepseek-ai/dsh-client-file-upload'
import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import type { ApiSessionAgentController } from '../src/agent.ts'
import { ApiSessionAgentController } from '../src/agent.ts'
import { SessionCommandController } from '../src/commands.ts'
import type { SessionRequestId } from '../src/types.ts'
@@ -35,7 +37,9 @@ async function uploadHarness(origin?: 'subagent'): Promise<{
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TypertRegistry)
await ctx.plugin(CommandRuntime)
const session = ctx.sessions.create(SESSION, {
meta: { cwd: '/workspace', ...(origin === undefined ? {} : { origin }) },
@@ -44,15 +48,20 @@ async function uploadHarness(origin?: 'subagent'): Promise<{
const followup = vi.fn()
const agent = {
id: session.id,
options: { provider: 'fixture', model: 'fixture-model' },
whenIdle: vi.fn(() => Promise.resolve()),
runMaintenance: async () => { throw new Error('unexpected maintenance') },
send: vi.fn(),
inject: vi.fn(),
session,
inbox,
status: 'idle',
ctx: undefined,
ctx,
steer: vi.fn(),
followup,
cancel: vi.fn(),
} as unknown as Agent
;(agent as { ctx: Context }).ctx = createScope(ctx, agent).ctx
} satisfies Agent
agent.ctx = createScope(ctx, agent).ctx
const disposeAgent = await ctx.agents.register(agent)
const saveFile = vi.fn((input: SaveFileAttachment): Promise<FileAttachmentRef> => Promise.resolve({
attachmentId: AttachmentId(`sha256:${'cd'.repeat(32)}`),
@@ -88,15 +97,8 @@ async function uploadHarness(origin?: 'subagent'): Promise<{
listProviders: () => [{ id: 'fixture', name: 'Fixture' }],
resolveModelInfo: () => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' }),
} as never)
const selection: ModelSelectionRef = {
current: { provider: 'fixture', model: 'fixture-model' },
assembled: undefined,
}
const agents = {
resolveAgent: () => Promise.resolve({ agent }),
selectionFor: () => selection,
serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
} as unknown as ApiSessionAgentController
const agents = new ApiSessionAgentController(ctx)
vi.spyOn(agents, 'resolveAgent').mockResolvedValue({ agent })
const uploads = new FileUploads(ctx)
if (uploadRoute === undefined) throw new Error('file upload route was not registered')
return {
@@ -321,13 +323,10 @@ describe('Session file uploads', () => {
new AbortController().signal,
)
await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }]), USER_SOURCE)
first.ctx.emit('session/event', first.agent.session, {
type: 'user/message',
data: createUserMessage({
content: [{ type: 'text', text: 'extension event' }],
source: { kind: 'user', rpcId: 1 } as never,
}),
} as never)
first.agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'extension event' }],
source: { kind: 'user', rpcId: 1 } as never,
}), { surfaceOp: 'append' })
expect(first.uploads.resolve(first.agent, observed.receiptId)).toEqual(observed.file)
first.agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'observed' }],
@@ -1,3 +1,4 @@
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -23,6 +24,7 @@ describe('SessionController facade', () => {
it('owns Host service methods and publishes Agent lifecycle projections', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const sessionId = SessionId('controller-session')
const header: SessionHeader = {
@@ -86,10 +88,6 @@ describe('SessionController facade', () => {
'resolveAgent',
).mockResolvedValueOnce({ error: activationError })
await expect(resolveUploadAgent(sessionId)).rejects.toBe(activationError)
const consumeSelection = vi.spyOn(
(controller as unknown as { agents: ApiSessionAgentController }).agents,
'consumeSelection',
)
await expect(controller.resolveAgent(sessionId)).resolves.toEqual({ agent })
await expect(controller.inspect(sessionId)).resolves.toEqual({
@@ -115,9 +113,9 @@ describe('SessionController facade', () => {
header: { config: { provider: 'fixture', model: 'fixture-model' } },
reason: 'initial',
})
expect(consumeSelection).toHaveBeenCalledWith(
agent, 'fixture', 'fixture-model', undefined,
)
expect(ctx.sessionProjections.stateOf(session, 'modelSelection')).toEqual({
lastUsed: { provider: 'fixture', model: 'fixture-model' }, pending: null,
})
const unowned = ctx.sessions.create(SessionId('controller-unowned'), {
meta: { cwd: '/workspace' },
})
@@ -125,7 +123,9 @@ describe('SessionController facade', () => {
header: { config: { provider: 'fixture', model: 'other-model' } },
reason: 'initial',
})
expect(consumeSelection).toHaveBeenCalledTimes(1)
expect(ctx.sessionProjections.stateOf(unowned, 'modelSelection')).toEqual({
lastUsed: { provider: 'fixture', model: 'other-model' }, pending: null,
})
const abort = new AbortController()
const iterator = controller.follow({
@@ -144,6 +144,7 @@ describe('SessionController facade', () => {
async (outcome) => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const sessionId = SessionId(`background-${outcome}`)
const header: SessionHeader = {
@@ -200,6 +201,7 @@ describe('SessionController facade', () => {
it('waits for an admitted background promotion during teardown', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const sessionId = SessionId('background-disposal')
const header: SessionHeader = {
@@ -3,6 +3,7 @@
* metadata-only listing, Agent-free history reads, subagent ownership
* isolation, and prompt failure mapping.
*/
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
import { describe, expect, it, vi } from 'vitest'
@@ -81,6 +82,7 @@ describe('sessions.list cold merge', () => {
const ctx = new Context()
try {
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const historical = header('format-historical', 100)
const current = header('format-current', 200)
@@ -246,6 +248,7 @@ describe('attached updatedAt tracks human prompts', () => {
it('ignores pickup and non-prompt work after the latest human message', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
await new Promise(resolve => setTimeout(resolve, 0))
@@ -390,6 +393,7 @@ describe('Remote Agent and Session lookup policy', () => {
it('keeps queue-item-not-found for a cold session when no persistence backend is composed', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
@@ -410,6 +414,7 @@ describe('Remote Agent and Session lookup policy', () => {
const ctx = new Context()
await ctx.plugin(TypertRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const sessionId = sid('session-remote-cold')
const meta = header(sessionId, 1000)
@@ -450,6 +455,7 @@ describe('Remote Agent and Session lookup policy', () => {
const ctx = new Context()
await ctx.plugin(TypertRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const coldId = sid('session-remote-cold-child')
const coldMeta = header(coldId, 1000, {
@@ -494,6 +500,7 @@ describe('Remote Agent and Session lookup policy', () => {
const ctx = new Context()
await ctx.plugin(TypertRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const sessionId = sid('session-remote-resumed-child')
const meta = header(sessionId, 1000)
@@ -529,6 +536,7 @@ describe('subagent ownership fence', () => {
it('reads a cold child without an Agent and rejects generic resume or adoption', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const sessionId = sid('session-child')
const meta = header('session-child', 1000, {
@@ -607,6 +615,7 @@ describe('subagent ownership fence', () => {
it('no longer treats a descriptor-only cold child without origin as subagent-owned', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const sessionId = sid('session-legacy-child')
const meta = header('session-legacy-child', 1000, {
@@ -644,6 +653,7 @@ describe('subagent ownership fence', () => {
it('rejects origin-marked and runtime-owned live children from generic controls', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const parentSession = ctx.sessions.create(sid('session-parent'), { meta: { cwd: '/proj' } })
const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent
@@ -703,6 +713,7 @@ describe('subagent ownership fence', () => {
it('does not classify an ordinary fork from an inherited ancestor descriptor', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(sid('session-ordinary-fork'), {
seed: [{
@@ -733,6 +744,7 @@ describe('subagent ownership fence', () => {
it('canonicalizes a supplied browser zone on the exact prompt and rejects invalid names', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } })
const followup = vi.fn()
@@ -804,6 +816,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
it('lists no cold rows and reports an absent point source as not found', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
@@ -825,6 +838,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
it('maps a missing direct persistence read to session-not-found', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const inspect = vi.fn()
const stat = vi.fn(() => Promise.resolve(undefined))
@@ -851,19 +865,26 @@ describe('sessions.prompt synchronous rejection', () => {
it('rejects content without non-whitespace text or an attachment before delivery or Session events', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(sid('session-empty-prompt'))
const followup = vi.fn()
const steer = vi.fn()
await ctx.agents.register({
id: session.id,
options: { provider: 'p', model: 'm' },
whenIdle: vi.fn(() => Promise.resolve()),
runMaintenance: async () => { throw new Error('unexpected maintenance') },
send: vi.fn(),
inject: vi.fn(),
cancel: vi.fn(),
session,
inbox: inboxFor(),
status: 'idle',
ctx,
followup,
steer,
} as unknown as Agent)
} satisfies Agent)
const savedImage = {
attachmentId: 'accepted-image',
mediaType: 'image/png' as const,
@@ -947,6 +968,7 @@ describe('sessions.prompt synchronous rejection', () => {
it('maps a synchronous send throw (disposed/invalid input) to agent-busy with the reason attached', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(sid('session-throwing'))
// A live structural stub whose delivery verbs throw synchronously, the
@@ -980,6 +1002,7 @@ describe('sessions.prompt synchronous rejection', () => {
it('classifies a raced cold-resume ID collision as agent-busy', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const sessionId = sid('race-resume')
const meta: SessionHeader = header('race-resume', 1000)
@@ -1,14 +1,14 @@
/** Session Controller fork boundaries, lineage, and inherited model routing. */
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { currentModelSelection } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit'
import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { ToolCallId, createMessage, createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import SessionStore, { TOOL_OUTCOME_UNKNOWN, SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import { SessionPersistenceRevision, type SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence'
@@ -32,6 +32,7 @@ async function composed(workspaces: readonly Workspace[] = []): Promise<Context>
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { personaPrefix: '' })
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
ctx.provide('workspaceRegistry', { list: () => workspaces, archivedSessionIds: [] } as never)
@@ -662,18 +663,8 @@ describe('sessions.fork', () => {
const child = ctx.agents.get(response.value.sessionId)
if (child === undefined) throw new Error('fork did not publish the child agent')
expect(child.session.snapshotEvents().slice(0, child.session.inheritedEventCount)).toEqual(inherited)
const assembly = await child.ctx.systemPrompt.assemble()
expect(assembly.variables).toMatchObject({
provider: 'inherited-provider',
model: 'inherited-model',
})
const fallback: LlmCallConfig = { provider: 'default-provider', model: 'default-model' }
await expect(agentEvents(child.ctx, child).waterfall(
'agent/request', { turn: 1, step: 0, signal: new AbortController().signal }, () => Promise.resolve(fallback),
)).resolves.toMatchObject({
provider: 'inherited-provider',
model: 'inherited-model',
reasoningEffort: 'high',
expect(currentModelSelection(ctx.sessionProjections, child)).toEqual({
provider: 'inherited-provider', model: 'inherited-model', reasoningEffort: 'high',
})
await ctx.fiber.dispose()
})
@@ -1,4 +1,5 @@
/** Raw Session journal transport and message-aligned pagination coverage. */
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
@@ -50,6 +51,7 @@ function appendExtension(session: Session, type: string, data: unknown): Session
async function harness(): Promise<{ ctx: Context }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
return { ctx }
@@ -6,6 +6,7 @@
* prompt's turn/start clears it. The host/session-added frame shares the
* same predicate function (covered by the workspace spec's frame assertion).
*/
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
@@ -22,6 +23,7 @@ import { createSessionTestRemote, type TestSessionRemote } from './test-remote.t
async function harness(): Promise<{ ctx: Context; remote: TestSessionRemote; attach: (session: Session) => Promise<void> }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
return {
ctx,
@@ -4,22 +4,23 @@
* catalog injection, unavailable-model refusals, and the prompt-assembly
* boundary for a running selection change.
*/
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { describe, expect, it, vi, onTestFinished } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { currentModelSelection } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AttachmentStore from '@deepseek-ai/dsh-attachment'
import LlmRuntime, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type {
GenerateOptions, LlmCallConfig, LlmCallConfigAdapterDefaults, LlmModelInfo,
GenerateOptions, LlmCallConfigAdapterDefaults, LlmModelInfo,
LlmModelReasoningInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk,
UserMessage,
} from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
import { ApiSessionAgentController } from '../src/agent.ts'
import { buildModelCatalog, hasProviderApiKey } from '../src/catalog.ts'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
@@ -96,6 +97,7 @@ async function harness(logged?: {
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { personaPrefix: '' })
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', [
{ provider: 'deepseek-official', id: 'deepseek-chat', name: 'DeepSeek Chat' },
@@ -126,11 +128,23 @@ async function harness(logged?: {
}
const agent = {
id: session.id,
options: {
get provider() { return ctx.agentDefaultModel.currentSelection().provider },
get model() { return ctx.agentDefaultModel.currentSelection().model },
get reasoningEffort() { return ctx.agentDefaultModel.currentSelection().reasoningEffort },
},
session,
status: 'running',
ctx,
inbox: { nextTurn: [], nextStep: [] },
} as unknown as Agent
inbox: createInboxStub(),
whenIdle: vi.fn(() => Promise.resolve()),
runMaintenance: async () => { throw new Error('unexpected maintenance') },
send: vi.fn(),
inject: vi.fn(),
cancel: vi.fn(),
followup: vi.fn(),
steer: vi.fn(),
} satisfies Agent
await ctx.agents.register(agent)
return { ctx, agent, sessionId: session.id }
}
@@ -486,11 +500,9 @@ describe('Web session model selection', () => {
await ctx.fiber.dispose()
})
it('rejects unlisted models and switches available models only after the next assembly', async () => {
it('rejects unlisted models and records validated selections for the next step', async () => {
const { ctx, agent, sessionId } = await harness()
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
const signal = new AbortController().signal
expect(currentSelection(ctx, sessionId))
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
@@ -506,18 +518,9 @@ describe('Web session model selection', () => {
model: 'deepseek-reasoner',
reasoningEffort: 'max',
})
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
)).resolves.toEqual(seed)
expect((await ctx.systemPrompt.assemble()).variables)
.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(seed),
)).resolves.toMatchObject({
provider: 'deepseek-official',
model: 'deepseek-reasoner',
reasoningEffort: 'max',
expect(currentModelSelection(ctx.sessionProjections, agent)).toEqual(selected.selected)
expect(agent.session.snapshotEvents().at(-1)).toMatchObject({
type: 'model/selection', data: selected.selected,
})
const unsupported = await remote.selectModel(request({
@@ -612,7 +615,7 @@ describe('Web session model selection', () => {
cwd: '/tmp',
})
expect(new ApiSessionAgentController(ctx).selectionFor(agent).current)
expect(currentModelSelection(ctx.sessionProjections, agent))
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
await ctx.fiber.dispose()
})
@@ -1,3 +1,4 @@
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { resolve } from 'node:path'
import * as nativeCommand from '@deepseek-ai/dsh-native-command'
import { Context } from '@deepseek-ai/cordis'
@@ -14,6 +15,7 @@ import {
async function context(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(FsLocal)
onTestFinished(() => ctx.fiber.dispose())
@@ -1,4 +1,5 @@
/** Session creation, blank-Session reuse, and Agent preset identity through `session.create`. */
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
@@ -64,6 +65,7 @@ async function harness(options: HarnessOptions = {}) {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const presets = options.presets
const selections: { sessionId: SessionId; agentPreset: string }[] = []
@@ -1,3 +1,4 @@
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -41,6 +42,7 @@ function observation(
async function context(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
provideWorkingDirectoryFixture(ctx, '/catalog/default')
return ctx
@@ -1,3 +1,4 @@
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { Context } from '@deepseek-ai/cordis'
import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
import AgentRegistry from '@deepseek-ai/dsh-agent'
@@ -49,6 +50,7 @@ async function bench() {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([header]),
@@ -193,6 +195,7 @@ describe('SessionController subagent catalog', () => {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
const controller = createSessionTestController(ctx, defaults)
@@ -18,7 +18,6 @@
"src/history.ts",
"src/list.ts",
"src/media-references.ts",
"src/model-selection-projection.ts",
"src/archived-session-gate.ts",
"src/skill-catalog.ts"
],
+2 -2
View File
@@ -24,8 +24,8 @@
en: ca8217837ee9c698
zh: fa20daf27d4e09ef
/deepseek-ai-dsh-headless/use-this-package/choosing-the-session-identity:
en: dbaa2fac63910a4c
zh: 4fd5c17815ca444b
en: 8d009bb9ed21b946
zh: d276dd3f2c4ab43d
/deepseek-ai-dsh-headless/use-this-package/machine-readable-output:
en: 2804a96e88857b16
zh: fe20dc0fb9962785
+2
View File
@@ -73,6 +73,8 @@ The restored store uses [saved credentials before environment fallbacks](../../c
Every invocation defaults to a fresh `session-<uuid>` identity, which `--json` reports in its opening `session` event. Pass `--session-id <id>` to continue that conversation: the runner adopts the persisted Session with that id, and an id with no stored Session fails before the task runs rather than quietly opening an empty history. Adoption requires the composed `sessionPersistence` and `sessionQuery` services, so a profile that omits either fails loudly instead of returning an id whose history dies with the process. An Agent already live under the requested id in this process is refused: another owner may still drive it, so the runner cannot claim an exclusive run interval over it. The identity is opaque, so the exact string is used, whitespace included. The working directory is resolved through the mounted filesystem provider (`fs.resolve('.')` and `fs.processPath()`), or the process cwd when no filesystem service is mounted; new Sessions record that directory. Adoption compares the recorded cwd with the same provider-resolved directory and refuses a Session that is a subagent or forked session, that recorded no working directory, that runs under an agent preset this profile does not compose, or whose preset record is malformed — the check reads the preset the Session log currently records, so a Session that switched preset while blank is rejected too. A supervisor therefore cannot silently drive someone else's conversation under a different composition; any mismatch fails before the task runs.
On adoption, the runner records the current deployment selection before submitting the task. An existing request keeps its own captured route; subsequent steps read the shared Session selection.
### Machine-readable output
`--json` replaces the final-text stdout line with a newline-delimited JSON event stream, while stderr keeps only the `dsh:` diagnostics. The stream opens with `session` (carrying the identity the run used) and closes with `final`, and carries `status`, `text`, `thinking`, `tool_call`, and `tool_result` events in between. `text` and `thinking` are projected from committed assistant messages, so a retried or discarded attempt never reaches the stream; they arrive when the step commits, not per token, and default-mode stderr reasoning remains the only live text channel. The terminal `final` event carries the same lossless answer as the default mode and is not capped; every other string and object key is capped at 8 KiB and flagged with `truncated`, and one event line, its newline included, is capped at 32 KiB — an over-long event keeps its scalar fields, drops structured ones, and at the extreme reduces to `type` and `truncated`, while a payload nested 64 levels or deeper is cut at that depth. An empty tool-argument string projects as `{}`, matching what the executor runs, while arguments that JSON cannot round-trip — an overflowing number such as `1e400` — keep their raw text rather than the `null` that `JSON.stringify` would report. A failure the runner raises outside a turn writes an `error` event and ends the stream without `final`, in addition to the `dsh:` stderr line; a profile whose own plugins fail to load exits before the runner mounts, so that case keeps only the loader's stderr diagnostics. A turn that fails in-turn still ends with a `final` event (often empty) and no `error` event, so a well-formed stream can still describe a failed run: treat exit code 1 and the `turn_end` reason as the failure signal.
+2
View File
@@ -73,6 +73,8 @@ agent 会完成该任务,把提供方的每个非空推理(reasoning)增
每次调用默认使用全新的 `session-<uuid>` 标识,`--json` 会在开头的 `session` 事件里报告它。传入 `--session-id <id>` 延续这段对话:runner 沿用该 id 对应的持久化 Session,而该 id 没有持久化 Session 时会在任务运行前失败,而不是悄悄开出一段空历史。沿用要求已组合 `sessionPersistence` 与 `sessionQuery` 服务,因此缺少任一服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id。本进程中已存在持有该 id 的存活 Agent 时会被拒绝:它的原 owner 可能仍在驱动它,runner 无法取得独占的运行区间。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。工作目录通过已挂载的文件系统提供方解析(`fs.resolve('.')` 与 `fs.processPath()`);未挂载文件系统服务时使用进程 cwd,新 Session 会记录该目录。沿用会将已记录 cwd 与同一提供方解析出的目录比较,并拒绝子 agent 或 fork 会话、未记录工作目录的会话、运行在本 profile 不组合的 agent preset 下的会话,以及 preset 记录畸形的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。
接管会话时,运行器会在提交任务前记录当前部署选择。已有请求保留自身捕获的路由,后续步骤读取共享的 Session 选择。
### 机器可读输出
`--json` 用按行 JSON 事件流取代 stdout 的最终文本行,stderr 仅保留 `dsh:` 诊断信息。事件流以 `session`(携带本次运行使用的标识)开头、以 `final` 结尾,其间为 `status`、`text`、`thinking`、`tool_call` 与 `tool_result` 事件。`text` 与 `thinking` 只从已提交的 assistant 消息投影,因此被重试或丢弃的尝试不会进入事件流;它们在步骤提交时到达,而不是逐 token 到达,默认模式的 stderr 推理仍是唯一的实时文本通道。终止 `final` 事件携带与默认模式相同的无损答案,不做限长;其他每个字符串与对象键上限为 8 KiB,超出时标记 `truncated`,单条事件行(含换行)上限为 32 KiB——超长事件保留标量字段、丢弃结构化字段,极端情况下只剩 `type` 与 `truncated`,嵌套达到 64 层及以上的负载会在该深度被截断。空工具参数字符串会投影为 `{}`,与执行器实际运行的值一致;而 JSON 无法往返的参数——例如溢出为 `Infinity` 的数字 `1e400`——会保留原始文本,而不是 `JSON.stringify` 会报告的 `null`。runner 在轮次之外抛出的失败会写出 `error` 事件并在没有 `final` 的情况下结束事件流,同时向 stderr 写入 `dsh:` 行;若 profile 自身的插件加载失败,进程会在 runner 挂载前退出,该情形只保留 loader 的 stderr 诊断。轮次内失败的运行仍会以 `final` 事件(通常为空)结束且没有 `error` 事件,因此格式良好的事件流也可能描述一次失败的运行:请把退出码 1 与 `turn_end` 原因作为失败信号。
+10 -9
View File
@@ -14,8 +14,8 @@ import { randomUUID } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { brandString } from '@deepseek-ai/dsh-brand'
import { installModelSelection } from '@deepseek-ai/dsh-agent'
import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
import { installModelSwitchNotice, recordModelSelection } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-default-model'
import type {} from '@deepseek-ai/dsh-fs'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
@@ -37,7 +37,7 @@ import { projectJsonRun, boundJsonLine } from './json-stream.ts'
export const name = 'headless-runner'
/** Core services required before the one-shot turn can start. */
export const inject = ['agentDefaultModel', 'agents', 'sessions']
export const inject = ['agentDefaultModel', 'agents', 'sessionProjections', 'sessions']
/** Plugin config: the task and run options resolved from this app's injected provider service. */
export interface Config {
@@ -247,8 +247,8 @@ async function resolveAgent(
ctx: Context,
agents: Context['agents'],
sessionId: SessionId,
agentOptions: { provider: string; model: string },
setup: (agentCtx: Context) => void,
agentOptions: AgentOptions,
setup: (agentCtx: Context, agent: Agent) => void,
cwd: string,
): Promise<Agent> {
// Resuming promises the caller a log a later process can continue. Without a
@@ -332,14 +332,15 @@ async function run(ctx: Context, config: Config, io: HeadlessIo): Promise<void>
}
const selection = defaultModel.currentSelection()
const agentOptions = { provider: selection.provider, model: selection.model }
const agentOptions: AgentOptions = { ...selection }
// This bundle composes no preset roster, so the model-facing rows sit in the
// host plane and the agent reads them from the global layer. A deployment
// that DOES configure one has to join it here first
// (@deepseek-ai/dsh-agent-preset-registry README, "Composing a child agent").
const setup = (agentCtx: Context): void => {
const selected: ModelSelectionRef = { current: selection, assembled: undefined }
installModelSelection(agentCtx, selected)
const setup = (agentCtx: Context, agent: Agent): void => {
// A resumed Session continues with this run's configured model.
recordModelSelection(ctx.sessionProjections, agent, selection)
installModelSwitchNotice(agentCtx)
}
const sessionId = brandString<SessionId>(config.sessionId ?? `session-${randomUUID()}`)
const fs = ctx.get('fs')
@@ -15,7 +15,9 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import type { ModelSelection, ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types'
import type { ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types'
import type { ModelSelection } from '@deepseek-ai/dsh-agent/types'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm/brand'
import type { CommandContribution, PopupSelectSpec, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client'
import type { ModelSelectInjected } from '../src/client/slots.ts'
import { apply, inject } from '../src/client/index.ts'
@@ -98,7 +100,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
model: payload.model,
...payload.reasoningEffort === undefined
? {}
: { reasoningEffort: payload.reasoningEffort },
: { reasoningEffort: ReasoningEffortId(payload.reasoningEffort) },
}
projections.get(payload.sessionId)?.set({ lastUsed: null, next: selected })
return Promise.resolve({ ok: true as const, value: { selected } })
@@ -446,12 +448,12 @@ describe('ui-model-selection dual entry', () => {
it('retains saved effort for existing and new sessions after credentials disappear', async () => {
const b = await bench()
try {
b.setHostCurrent({ provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' })
b.setHostCurrent({ provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: ReasoningEffortId('max') })
b.mint('existing')
const existing = b.ctx.modelDirectories.directoryFor(sid('existing'))
await existing.load()
b.setProjected(sid('existing'), { lastUsed: null,
next: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'high' } })
next: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: ReasoningEffortId('high') } })
b.setRoutable(false)
b.remote.emit('settings/document-updated', ['llm-deepseek', 1])
expect(existing.store.getSnapshot().retainedEffort).toBe('High')
@@ -544,7 +546,7 @@ it.each([false, true])('reports accepted switches with blank=%s and no refused s
const scope = b.mint('analytics', blank)
try {
const face = b.seat().inject!(sid('analytics'))
await face.select({ provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'max' })
await face.select({ provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: ReasoningEffortId('max') })
expect(b.track).toHaveBeenCalledWith('model_switch', { ...blank ? {} : { session_id: 'analytics' }, switch_from: 'deepseek-official/deepseek-v4-flash', switch_to: 'deepseek-official/deepseek-v4-pro' })
b.track.mockClear()
await face.select({ provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'high' })
@@ -1675,7 +1675,7 @@ describe('default one-shot summarizer', () => {
describe('automatic listener and loader composition', () => {
function preStep(ctx: Context, owner: Agent, signal = SIGNAL) {
return agentEvents(ctx, owner).waterfall(
'agent/pre-step', { messages: [], turn: 1, step: 1, signal },
'agent/pre-step', { callConfig: { provider: 'mock', model: 'mock' }, messages: [], turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
}
@@ -310,7 +310,7 @@ async function agentInstructionsOf(agent: Agent): Promise<UserMessage> {
async function syncAgentInstructions(ctx: Context, agent: Agent): Promise<void> {
await agentEvents(ctx, agent).waterfall(
'agent/pre-step', { messages: [], turn: 1, step: 1, signal: testToolSignal },
'agent/pre-step', { callConfig: { provider: 'mock', model: 'mock' }, messages: [], turn: 1, step: 1, signal: testToolSignal },
async () => ({ kind: 'enter' as const, messages: [] }),
)
}
@@ -345,13 +345,13 @@ async function composeBaselinePrefix(ctx: Context, agent: TestAgent): Promise<Me
const signal = new AbortController().signal
await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
{ messages: [], turn: 1, step: 1, signal },
{ callConfig: { provider: 'mock', model: 'mock' }, messages: [], turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
const claimed = claimInbox(agent, 'next-step')
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
{ messages: claimed, turn: 1, step: 2, signal },
{ callConfig: { provider: 'mock', model: 'mock' }, messages: claimed, turn: 1, step: 2, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: claimed }),
)
const entered = decision.kind === 'enter' ? decision.messages : []
@@ -1724,7 +1724,7 @@ describe('workspace context request injection', () => {
const original = await stubAgent(root)
await agentEvents(ctx, original).waterfall(
'agent/pre-step',
{ messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
{ callConfig: { provider: 'mock', model: 'mock' }, messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
const inserted = original.inbox.nextStep[0]
@@ -1738,7 +1738,7 @@ describe('workspace context request injection', () => {
const claimed = claimInbox(resumed, 'next-step')
const decision = await agentEvents(ctx, resumed).waterfall(
'agent/pre-step',
{ messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
{ callConfig: { provider: 'mock', model: 'mock' }, messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
() => Promise.resolve({ kind: 'enter' as const, messages: claimed }),
)
if (decision.kind !== 'enter') throw new Error('recovered baseline was rejected')
@@ -1773,7 +1773,7 @@ describe('workspace context request injection', () => {
const original = await stubAgent(root)
await agentEvents(ctx, original).waterfall(
'agent/pre-step',
{ messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
{ callConfig: { provider: 'mock', model: 'mock' }, messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
const stale = original.inbox.nextStep[0]
@@ -1788,7 +1788,7 @@ describe('workspace context request injection', () => {
const staleClaim = claimInbox(resumed, 'next-step')
const staleDecision = await agentEvents(ctx, resumed).waterfall(
'agent/pre-step',
{ messages: staleClaim, turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
{ callConfig: { provider: 'mock', model: 'mock' }, messages: staleClaim, turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
() => Promise.resolve({ kind: 'enter' as const, messages: staleClaim }),
)
@@ -1831,7 +1831,7 @@ describe('workspace context request injection', () => {
const original = await stubAgent(root)
await agentEvents(originalCtx, original).waterfall(
'agent/pre-step',
{ messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
{ callConfig: { provider: 'mock', model: 'mock' }, messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
const stale = original.inbox.nextStep[0]
@@ -1846,7 +1846,7 @@ describe('workspace context request injection', () => {
const claimed = claimInbox(resumed, 'next-step')
const decision = await agentEvents(resumedCtx, resumed).waterfall(
'agent/pre-step',
{ messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
{ callConfig: { provider: 'mock', model: 'mock' }, messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
() => Promise.resolve({ kind: 'enter' as const, messages: claimed }),
)
@@ -1959,7 +1959,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(requestTimeoutMs) },
{ callConfig: { provider: 'mock', model: 'mock' }, messages: [prompt], turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
() => Promise.resolve(downstream),
)
@@ -2551,7 +2551,7 @@ describe('workspace context request injection', () => {
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
{ messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
{ callConfig: { provider: 'mock', model: 'mock' }, messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
() => Promise.resolve(downstream),
)
@@ -2673,7 +2673,7 @@ describe('workspace context request injection', () => {
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
{ messages: [prompt], turn: 2, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
{ callConfig: { provider: 'mock', model: 'mock' }, messages: [prompt], turn: 2, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
() => Promise.resolve({ kind: 'enter' as const, messages: [prompt] }),
)
@@ -3137,7 +3137,7 @@ describe('workspace context request injection', () => {
const reason = new Error('cancel prefix')
const pending = agentEvents(ctx, await stubAgent(root)).waterfall(
'agent/pre-step',
{ messages: [], turn: 1, step: 1, signal: controller.signal },
{ callConfig: { provider: 'mock', model: 'mock' }, messages: [], turn: 1, step: 1, signal: controller.signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
@@ -5770,7 +5770,7 @@ describe('workspace context inbox synchronization', () => {
controller.abort(new Error('abort pre-step reconciliation'))
await expect(agentEvents(ctx, agent).waterfall(
'agent/pre-step', { messages: [], turn: 1, step: 1, signal: controller.signal },
'agent/pre-step', { callConfig: { provider: 'mock', model: 'mock' }, messages: [], turn: 1, step: 1, signal: controller.signal },
async () => ({ kind: 'enter' as const, messages: [] }),
)).rejects.toThrow('abort pre-step reconciliation')
@@ -5889,7 +5889,7 @@ describe('workspace context inbox synchronization', () => {
const downstream = { kind: 'enter' as const, messages: claimed }
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', { messages: claimed, turn: 1, step: 1, signal: testToolSignal },
'agent/pre-step', { callConfig: { provider: 'mock', model: 'mock' }, messages: claimed, turn: 1, step: 1, signal: testToolSignal },
async () => downstream,
)
@@ -27,14 +27,14 @@
en: 169f100ba62f2120
zh: 3fd4fc0bd73b2370
/deepseek-ai-dsh-session-reference/use-this-package/configuration:
en: d9abc1b28c3feb40
zh: ac85539027b37130
en: 5dfb1f0c52d300bf
zh: ae731dd6fcd93027
/deepseek-ai-dsh-session-reference/understand-the-implementation:
en: db349483e453caf7
zh: 70035c291e1efe84
/deepseek-ai-dsh-session-reference/understand-the-implementation/design-concept:
en: 5c7712ca91eaae1c
zh: 2d923b605afa7a88
en: 19a5855977bd329d
zh: 8c7f5d7815c71c0b
/deepseek-ai-dsh-session-reference/understand-the-implementation/source-map:
en: 7c7b413082507150
zh: 0229eb73d441b305
+2 -2
View File
@@ -50,7 +50,7 @@ For a truncated reference, an optional spill backend saves the full captured tex
| `maxReferenceBytes` | automatic | Explicit maximum serialized JSON bytes per source; overrides the automatic budget exactly |
| `referenceContextFraction` | `0.2` | Context-window fraction per source, from `0` to `1` |
The automatic budget is `max(65536, floor(contextWindow × 4 × referenceContextFraction))` bytes per source. Model context capacity is measured in tokens; four bytes per token is a sizing heuristic, not an exact token conversion. A missing route, LLM service, adapter, or capacity uses 64 KiB; other model metadata lookup errors and cancellation fail preparation.
The automatic budget is `max(65536, floor(contextWindow × 4 × referenceContextFraction))` bytes per source. Model context capacity is measured in tokens; four bytes per token is a sizing heuristic, not an exact token conversion. A missing LLM service, adapter, or capacity uses 64 KiB; other model metadata lookup errors and cancellation fail preparation.
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-session-reference) is the exhaustive source for every accepted field and its JSDoc.
@@ -70,7 +70,7 @@ Preparation reads each referenced session's current surface exactly once, when t
The resolver discovers optional storage through `ctx.get("spillStore")` and saves only truncated references. Storage ownership is the target session; the source descriptor identifies the referenced session and label, without a fabricated tool call. Cancellation is checked after the asynchronous save and prevents publication even if an artifact was written. Artifact expiry remains the backend's existing policy.
The budget uses the provider and model captured after `system-prompt/assemble` completes for the target agent. Direct `prepare` calls before any assembly use agent options; session headers do not select the budget model. Diagnostic assemblies without an agent do not affect captured routes.
The budget uses the provider and model passed in `agent/pre-step.callConfig`, so reference sizing follows the route captured for that step. Direct `prepare` callers supply the route explicitly; prompt variables, Agent options, and stored headers do not implicitly select it.
### Source map
@@ -50,7 +50,7 @@ kind: "package-reference"
| `maxReferenceBytes` | 自动 | 每个来源的最大序列化 JSON 字节数;显式设置时精确覆盖自动预算 |
| `referenceContextFraction` | `0.2` | 每个来源的上下文窗口比例,范围为 `0` 到 `1` |
自动预算为每个来源 `max(65536, floor(contextWindow × 4 × referenceContextFraction))` 字节。模型上下文容量以 token 计量;每个 token 四字节是容量估算,不是精确的 token 换算。缺少路由、LLM(大语言模型)服务、适配器或容量时使用 64 KiB;其他模型元数据查询错误与取消会使准备失败。
自动预算为每个来源 `max(65536, floor(contextWindow × 4 × referenceContextFraction))` 字节。模型上下文容量以 token 计量;每个 token 四字节是容量估算,不是精确的 token 换算。缺少 LLM(大语言模型)服务、适配器或容量时使用 64 KiB;其他模型元数据查询错误与取消会使准备失败。
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-session-reference)是每个受支持字段及其 JSDoc 的穷尽式真源。
@@ -70,7 +70,7 @@ kind: "package-reference"
解析器通过 `ctx.get("spillStore")` 获取可选存储,只保存被截断的引用。存储归目标会话所有;来源信息标识被引用的源会话与标签,不伪造工具调用。异步保存后会检查取消,即使产物已写入,也会阻止发布。产物过期仍遵循后端既有策略。
预算使用目标 agent 的 `system-prompt/assemble` 完成后捕获的提供方与模型。首次组装前直接调用 `prepare` 时使用 agent 选项;会话头不决定预算模型。不带 agent 的诊断组装不会影响已捕获路由。
预算使用 `agent/pre-step.callConfig` 传入的提供方与模型,使引用大小与该步骤捕获的路由一致。直接调用 `prepare` 时必须显式提供路由;提示词变量、Agent 选项和已存储的请求头不会隐式选择路由。
### 源码地图
+13 -20
View File
@@ -10,7 +10,7 @@ import z from '@deepseek-ai/schemastery'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { createUserMessage, freezeMessage, LlmError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, LlmResolvedModelInfo, UserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, LlmCallConfig, LlmResolvedModelInfo, UserMessage } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
// Type-only: the `title` projection key plus the live registry and durable
// cache Context merges — the two projection faces discovery labels from.
@@ -95,7 +95,6 @@ export class SessionReferenceResolver extends TypertRemoteService {
})
private readonly config: Required<Omit<Config, 'maxReferenceBytes'>> & { maxReferenceBytes: number | undefined }
private readonly assembledRoutes = new WeakMap<Agent, { provider: string | undefined; model: string | undefined }>()
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'sessionReferenceResolver')
@@ -126,21 +125,12 @@ export class SessionReferenceResolver extends TypertRemoteService {
'SESSION_REFERENCE_INVALID_CONFIG',
)
}
// Prepend observes model-selection overrides after downstream assembly completes.
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
const assembly = await next()
if (context.agent !== undefined) {
const { provider, model } = assembly.variables
this.assembledRoutes.set(context.agent, { provider, model })
}
return assembly
}, { prepend: true })
ctx.on('agent/pre-step', async ({ agent, signal }, next): Promise<PreStepDecision> => {
ctx.on('agent/pre-step', async ({ agent, signal, callConfig }, next): Promise<PreStepDecision> => {
const decision = await next()
if (decision.kind === 'reject') return decision
return {
...decision,
messages: await this.prepareDirectMessages(agent, decision.messages, signal),
messages: await this.prepareDirectMessages(agent, decision.messages, callConfig, signal),
}
}, { prepend: true })
}
@@ -150,12 +140,14 @@ export class SessionReferenceResolver extends TypertRemoteService {
* snapshot immediately after the message that cited it.
* @param agent - agent entering the model step.
* @param messages - messages accepted by downstream pre-step listeners.
* @param route - provider and model fixed for the step.
* @param signal - active turn cancellation.
* @returns direct messages followed by their session-reference context in citation order.
*/
private async prepareDirectMessages(
agent: Agent,
messages: readonly UserMessage[],
route: Readonly<Pick<LlmCallConfig, 'provider' | 'model'>>,
signal: AbortSignal,
): Promise<UserMessage[]> {
const prepared = await Promise.all(messages.map(async (message): Promise<UserMessage[]> => {
@@ -168,7 +160,7 @@ export class SessionReferenceResolver extends TypertRemoteService {
return { type: 'text', text: parsed.text }
})
if (references.length === 0) return [message]
const resolved = await this.prepare(agent, content, references, signal)
const resolved = await this.prepare(agent, content, references, route, signal)
const direct = freezeMessage({ ...message, content: resolved.content })
/* v8 ignore if -- a parsed canonical mention always leaves one normalized reference */
if (resolved.additionalContext === undefined) {
@@ -304,13 +296,14 @@ export class SessionReferenceResolver extends TypertRemoteService {
/**
* Snapshot all references for one accepted direct message and return one aggregated durable context.
* Automatic budgets use the last assembled route, or agent options before any assembly.
* Automatic budgets use the context capacity of `route`.
* Missing model capacity or adapter uses 64 KiB; other metadata lookup failures and cancellation reject preparation.
* Truncated previews include omission facts and a full-snapshot spill locator, or an explicit unavailable notice.
* Cancellation prevents context publication, including when storage completes after cancellation.
* @param agent - target agent; references to it are rejected.
* @param content - already host-normalized readable message content.
* @param references - structured source sessions in mention order.
* @param route - provider and model whose context capacity sizes the previews.
* @param signal - optional cancellation boundary for the active turn.
* @returns detached content and optional referenced-session context.
*/
@@ -318,13 +311,14 @@ export class SessionReferenceResolver extends TypertRemoteService {
agent: Agent,
content: ContentBlock[],
references: SessionReferenceInput[],
route: Readonly<Pick<LlmCallConfig, 'provider' | 'model'>>,
signal?: AbortSignal,
): Promise<PreparedReferencedMessage> {
const acceptedContent = structuredClone(content)
const inputs = normalizeReferences(agent.id, references, this.config.maxReferences)
if (inputs.length === 0) return { content: acceptedContent }
assertNotCancelled(signal)
const maxReferenceBytes = await this.referenceBudget(agent, signal)
const maxReferenceBytes = await this.referenceBudget(route, signal)
assertNotCancelled(signal)
let prepared: PreparedSource[]
try {
@@ -375,12 +369,11 @@ export class SessionReferenceResolver extends TypertRemoteService {
return { content: acceptedContent, additionalContext }
}
private async referenceBudget(agent: Agent, signal: AbortSignal | undefined): Promise<number> {
private async referenceBudget(route: Readonly<Pick<LlmCallConfig, 'provider' | 'model'>>, signal: AbortSignal | undefined): Promise<number> {
if (this.config.maxReferenceBytes !== undefined) return this.config.maxReferenceBytes
// Options seed direct preparation; an assembled route owns model-step preparation.
const { provider, model } = this.assembledRoutes.get(agent) ?? agent.options
const { provider, model } = route
const llm = this.ctx.get('llm')
if (provider === undefined || model === undefined || llm === undefined) return DEFAULT_MAX_REFERENCE_BYTES
if (llm === undefined) return DEFAULT_MAX_REFERENCE_BYTES
let info: LlmResolvedModelInfo
try {
info = await settleWithCancellation(llm.resolveModelInfo(provider, model, signal), signal)
@@ -77,6 +77,7 @@ describe('session-reference real Loader composition', () => {
source: { kind: 'user' },
})
const decision = await agentEvents(ctx, agent).waterfall('agent/pre-step', {
callConfig: { provider: 'mock', model: 'mock' },
messages: [direct], turn: 1, step: 1, signal: new AbortController().signal,
}, () => Promise.resolve({ kind: 'enter' as const, messages: [direct] }))
expect(decision.kind).toBe('enter')
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { agentEvents, installModelSelection, type Agent, type ModelSelectionRef } from '@deepseek-ai/dsh-agent'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compaction'
import LlmRuntime, { createDeveloperMessage, createMessage, createSystemMessage, createToolResultMessage, createUserMessage, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm'
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
@@ -326,7 +326,7 @@ describe('session reference spill outcomes', () => {
const target = ctx.sessions.create(SessionId('target'))
const source = ctx.sessions.create(SessionId('source'))
appendText(source, 'complete fact')
const result = await ctx.sessionReferenceResolver.prepare(fakeAgent(target), [], [{ sessionId: source.id }])
const result = await ctx.sessionReferenceResolver.prepare(fakeAgent(target), [], [{ sessionId: source.id }], { provider: 'seed', model: 'seed' })
expect(contextText(result)).not.toContain('Reference omissions')
expect(contextText(result)).toContain('complete fact')
expect(save).not.toHaveBeenCalled()
@@ -348,7 +348,7 @@ describe('session reference spill outcomes', () => {
const captured = source.snapshotEvents().at(-1)?.seq
const read = vi.spyOn(ctx.sessionQuery, 'readSurface')
const store = ctx.spillStore as RecordingSpill
const result = await ctx.sessionReferenceResolver.prepare(fakeAgent(target), [], [{ sessionId: source.id }])
const result = await ctx.sessionReferenceResolver.prepare(fakeAgent(target), [], [{ sessionId: source.id }], { provider: 'seed', model: 'seed' })
expect(read).toHaveBeenCalledTimes(1)
expect(store.saves).toHaveLength(1)
const saved = store.saves[0]!
@@ -389,7 +389,7 @@ describe('session reference spill outcomes', () => {
const save = vi.spyOn(ctx.spillStore, 'saveText').mockImplementation(async input => ({
locator: SpillLocator(`memory:${input.suggestedName}`), bytes: Buffer.byteLength(input.content), retrievalHint: 'Read the captured transcript.',
}))
const result = await ctx.sessionReferenceResolver.prepare(fakeAgent(target), [], sources.map(source => ({ sessionId: source.id })))
const result = await ctx.sessionReferenceResolver.prepare(fakeAgent(target), [], sources.map(source => ({ sessionId: source.id })), { provider: 'seed', model: 'seed' })
expect(save.mock.calls.map(([input]) => input.suggestedName)).toEqual(['session-reference-1.txt', 'session-reference-3.txt'])
const context = result.additionalContext!
target.append('user/message', context, { surfaceOp: 'append' })
@@ -415,7 +415,7 @@ describe('session reference spill outcomes', () => {
appendText(source, 'later mutation must not appear')
return { locator: SpillLocator('memory:frozen'), bytes: Buffer.byteLength(input.content), retrievalHint: 'Read frozen capture.' }
})
const result = await ctx.sessionReferenceResolver.prepare(fakeAgent(target), [], [{ sessionId: source.id }])
const result = await ctx.sessionReferenceResolver.prepare(fakeAgent(target), [], [{ sessionId: source.id }], { provider: 'seed', model: 'seed' })
expect(read).toHaveBeenCalledTimes(1)
const full = save.mock.calls[0]![0].content
for (const text of ['checkpoint', 'recent user', 'human steer', 'visible answer']) expect(full).toContain(text)
@@ -437,7 +437,7 @@ describe('session reference spill outcomes', () => {
const target = ctx.sessions.create(SessionId('target'))
const source = ctx.sessions.create(SessionId('source'))
appendText(source, '界'.repeat(500))
const result = await ctx.sessionReferenceResolver.prepare(fakeAgent(target), [], [{ sessionId: source.id }])
const result = await ctx.sessionReferenceResolver.prepare(fakeAgent(target), [], [{ sessionId: source.id }], { provider: 'seed', model: 'seed' })
const prompt = contextText(result)
expect(prompt).toContain('"status":"unavailable"')
expect(prompt).toContain(mode === 'missing' ? 'storage-not-configured' : 'save-failed')
@@ -466,7 +466,7 @@ describe('session reference spill outcomes', () => {
})
const direct = createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: formatSessionReferenceMention({ sessionId: source.id }) }] })
const pending = agentEvents(ctx, fakeAgent(target)).waterfall('agent/pre-step',
{ messages: [direct], turn: 1, step: 1, signal: controller.signal },
{ callConfig: { provider: 'seed', model: 'seed' }, messages: [direct], turn: 1, step: 1, signal: controller.signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [direct] }))
const rejected = expect(pending).rejects.toThrow(expectCode('SESSION_REFERENCE_CANCELLED'))
await started.promise
@@ -507,7 +507,7 @@ describe('model-relative reference budgets', () => {
source.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'x'.repeat(250_000) }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
const prepare = (signal?: AbortSignal) => ctx.sessionReferenceResolver.prepare(agent, [], [{ sessionId: source.id }], signal)
const prepare = (signal?: AbortSignal) => ctx.sessionReferenceResolver.prepare(agent, [], [{ sessionId: source.id }], { provider: 'seed', model: 'seed' }, signal)
return { ctx, agent, source, resolve, prepare, resolverFiber, llmFiber }
}
@@ -533,32 +533,25 @@ describe('model-relative reference budgets', () => {
else expect(resolve).toHaveBeenCalledWith('seed', 'seed', undefined)
})
it('uses the assembled selection, not the header, seed, or next selected model', async () => {
it('uses the fixed pre-step route independently of the stored header and Agent options', async () => {
const { ctx, agent, source, resolve } = await setup()
const selection: ModelSelectionRef = { current: { provider: 'selected', model: 'large' }, assembled: undefined }
installModelSelection(ctx, selection)
await ctx.systemPrompt.assemble({ agent, scope: agent })
selection.current = { provider: 'selected', model: 'small' }
const message = createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: formatSessionReferenceMention({ sessionId: source.id }) }] })
const signal = new AbortController().signal
const enter = () => agentEvents(ctx, agent).waterfall('agent/pre-step', { messages: [message], turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [message] }))
const first = await enter()
const enter = (model: string) => agentEvents(ctx, agent).waterfall('agent/pre-step', {
callConfig: { provider: 'selected', model }, messages: [message], turn: 1, step: 1, signal,
}, () => Promise.resolve({ kind: 'enter' as const, messages: [message] }))
const first = await enter('large')
expect(first.kind).toBe('enter')
if (first.kind !== 'enter') throw new Error('expected step entry')
const firstContext = first.messages[1]
if (firstContext === undefined) throw new Error('expected reference context')
expect(bytes({ content: [], additionalContext: firstContext })).toBe(160_000)
expect(resolve).toHaveBeenLastCalledWith('selected', 'large', signal)
await ctx.systemPrompt.assemble({ agent, scope: agent })
resolve.mockResolvedValue({ provider: 'selected', id: 'small', name: 'small', context: { contextWindow: 8_000 } })
const second = await enter()
if (second.kind !== 'enter' || second.messages[1] === undefined) throw new Error('expected reference context')
expect(bytes({ content: [], additionalContext: second.messages[1] })).toBe(65_536)
await enter('small')
expect(resolve).toHaveBeenLastCalledWith('selected', 'small', signal)
})
it('uses the floor for absent metadata, service, or assembled route and ignores diagnostic assemblies', async () => {
it('uses the floor for absent capacity or service and ignores diagnostic assemblies', async () => {
const { ctx, agent, resolve, prepare, llmFiber } = await setup()
await ctx.systemPrompt.assemble()
resolve.mockResolvedValue({ provider: 'seed', id: 'seed', name: 'seed' })
@@ -566,15 +559,10 @@ describe('model-relative reference budgets', () => {
expect(resolve).toHaveBeenCalledOnce()
await ctx.systemPrompt.assemble({ agent, scope: agent })
expect(bytes(await prepare())).toBe(65_536)
expect(resolve).toHaveBeenCalledOnce()
delete agent.options.model
const other = fakeAgent(agent.session)
other.options.provider = 'seed'
await ctx.sessionReferenceResolver.prepare(other, [], [{ sessionId: SessionId('source') }])
expect(resolve).toHaveBeenCalledOnce()
expect(resolve).toHaveBeenCalledTimes(2)
await llmFiber.dispose()
other.options.model = 'seed'
expect(bytes(await ctx.sessionReferenceResolver.prepare(other, [], [{ sessionId: SessionId('source') }]))).toBe(65_536)
expect(bytes(await prepare())).toBe(65_536)
expect(resolve).toHaveBeenCalledTimes(2)
})
it('uses the floor when the real LLM runtime has no adapter for the route', async () => {
@@ -619,18 +607,18 @@ describe('model-relative reference budgets', () => {
expect(read).not.toHaveBeenCalled()
})
it('removes both listeners when the resolver fiber is disposed', async () => {
it('removes its pre-step listener when the resolver fiber is disposed', async () => {
const { ctx, agent, source, resolve, resolverFiber } = await setup()
const resolver = ctx.sessionReferenceResolver
await resolverFiber.dispose()
ctx.systemPrompt.variable('provider', () => 'disposed')
ctx.systemPrompt.variable('model', () => 'disposed')
await ctx.systemPrompt.assemble({ agent, scope: agent })
await resolver.prepare(agent, [], [{ sessionId: source.id }])
await resolver.prepare(agent, [], [{ sessionId: source.id }], { provider: 'seed', model: 'seed' })
expect(resolve).toHaveBeenLastCalledWith('seed', 'seed', undefined)
const message = createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: formatSessionReferenceMention({ sessionId: source.id }) }] })
const seed = { kind: 'enter' as const, messages: [message] }
await expect(agentEvents(ctx, agent).waterfall('agent/pre-step', { messages: [message], turn: 1, step: 1, signal: new AbortController().signal },
await expect(agentEvents(ctx, agent).waterfall('agent/pre-step', { callConfig: { provider: 'seed', model: 'seed' }, messages: [message], turn: 1, step: 1, signal: new AbortController().signal },
() => Promise.resolve(seed))).resolves.toBe(seed)
})
@@ -886,7 +874,7 @@ describe('session reference discovery and preparation', () => {
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
{ messages: [direct, ordinary, plugin], turn: 1, step: 1, signal },
{ callConfig: { provider: 'seed', model: 'seed' }, messages: [direct, ordinary, plugin], turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [direct, ordinary, plugin] }),
)
@@ -922,14 +910,14 @@ describe('session reference discovery and preparation', () => {
await expect(agentEvents(ctx, agent).waterfall(
'agent/pre-step',
{ messages: [malformed], turn: 1, step: 1, signal },
{ callConfig: { provider: 'seed', model: 'seed' }, messages: [malformed], turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'reject' as const }),
)).resolves.toEqual({ kind: 'reject' })
expect(readSurface).not.toHaveBeenCalled()
await expect(agentEvents(ctx, agent).waterfall(
'agent/pre-step',
{ messages: [malformed], turn: 1, step: 1, signal },
{ callConfig: { provider: 'seed', model: 'seed' }, messages: [malformed], turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [malformed] }),
)).rejects.toThrow(/invalid session reference URI/)
})
@@ -954,7 +942,7 @@ describe('session reference discovery and preparation', () => {
const prepared = await ctx.sessionReferenceResolver.prepare(
fakeAgent(target),
[{ type: 'text', text: 'use @source' }],
[{ sessionId: source.id, label: 'source' }],
[{ sessionId: source.id, label: 'source' }], { provider: 'seed', model: 'seed' },
)
expect(prepared.content).toEqual([{ type: 'text', text: 'use @source' }])
const context = prepared.additionalContext
@@ -1006,7 +994,7 @@ describe('session reference discovery and preparation', () => {
const prepared = await ctx.sessionReferenceResolver.prepare(
fakeAgent(target),
[{ type: 'text', text: 'use @source' }],
[{ sessionId: source.id }],
[{ sessionId: source.id }], { provider: 'seed', model: 'seed' },
)
const captured = prepared.additionalContext?.source
@@ -1047,7 +1035,7 @@ describe('session reference discovery and preparation', () => {
const prepared = await ctx.sessionReferenceResolver.prepare(
fakeAgent(target),
[{ type: 'text', text: 'inspect source' }],
[{ sessionId: source.id }],
[{ sessionId: source.id }], { provider: 'seed', model: 'seed' },
)
const context = prepared.additionalContext
if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
@@ -1073,7 +1061,7 @@ describe('session reference discovery and preparation', () => {
const prepared = await ctx.sessionReferenceResolver.prepare(
fakeAgent(target),
[{ type: 'text', text: 'use @source' }],
[{ sessionId: source.id }],
[{ sessionId: source.id }], { provider: 'seed', model: 'seed' },
)
const context = prepared.additionalContext
if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
@@ -1099,7 +1087,7 @@ describe('session reference discovery and preparation', () => {
const agent = fakeAgent(target)
const content = [{ type: 'text' as const, text: 'go' }]
const withoutReferences = await ctx.sessionReferenceResolver.prepare(agent, content, [])
const withoutReferences = await ctx.sessionReferenceResolver.prepare(agent, content, [], { provider: 'seed', model: 'seed' })
expect(withoutReferences).toEqual({ content })
expect(withoutReferences.content).not.toBe(content)
@@ -1107,28 +1095,28 @@ describe('session reference discovery and preparation', () => {
{ sessionId: one.id, label: 'first' },
{ sessionId: one.id, label: 'ignored duplicate' },
{ sessionId: two.id },
])).resolves.toMatchObject({ additionalContext: { source: { references: [{ label: 'first' }, { label: 'two' }] } } })
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [{ sessionId: target.id }]))
], { provider: 'seed', model: 'seed' })).resolves.toMatchObject({ additionalContext: { source: { references: [{ label: 'first' }, { label: 'two' }] } } })
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [{ sessionId: target.id }], { provider: 'seed', model: 'seed' }))
.rejects.toThrow(expectCode('SESSION_REFERENCE_SELF_REFERENCE'))
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [null as never]))
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [null as never], { provider: 'seed', model: 'seed' }))
.rejects.toThrow(expectCode('SESSION_REFERENCE_INVALID_REFERENCE'))
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [1 as never]))
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [1 as never], { provider: 'seed', model: 'seed' }))
.rejects.toThrow(expectCode('SESSION_REFERENCE_INVALID_REFERENCE'))
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [{ sessionId: 1 } as never]))
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [{ sessionId: 1 } as never], { provider: 'seed', model: 'seed' }))
.rejects.toThrow(expectCode('SESSION_REFERENCE_INVALID_REFERENCE'))
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [
{ sessionId: one.id }, { sessionId: two.id }, { sessionId: SessionId('three') },
])).rejects.toThrow(expectCode('SESSION_REFERENCE_TOO_MANY'))
], { provider: 'seed', model: 'seed' })).rejects.toThrow(expectCode('SESSION_REFERENCE_TOO_MANY'))
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [
{ sessionId: one.id }, { sessionId: SessionId('missing') },
])).rejects.toThrow(expectCode('SESSION_REFERENCE_READ_FAILED'))
], { provider: 'seed', model: 'seed' })).rejects.toThrow(expectCode('SESSION_REFERENCE_READ_FAILED'))
const readSurface = vi.spyOn(ctx.sessionQuery, 'readSurface')
readSurface.mockRejectedValueOnce('non-error read failure')
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [{ sessionId: one.id }]))
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [{ sessionId: one.id }], { provider: 'seed', model: 'seed' }))
.rejects.toThrow(/non-error read failure/)
readSurface.mockRejectedValueOnce('non-error signalled read failure')
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [{ sessionId: one.id }], new AbortController().signal))
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [{ sessionId: one.id }], { provider: 'seed', model: 'seed' }, new AbortController().signal))
.rejects.toThrow(/non-error signalled read failure/)
const duringRead = new AbortController()
@@ -1136,7 +1124,7 @@ describe('session reference discovery and preparation', () => {
duringRead.abort('cancelled during read')
throw new Error('read interrupted')
})
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [{ sessionId: one.id }], duringRead.signal))
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [{ sessionId: one.id }], { provider: 'seed', model: 'seed' }, duringRead.signal))
.rejects.toThrow(expectCode('SESSION_REFERENCE_CANCELLED'))
const snapshot = await ctx.sessionQuery.readSurface(one.id)
@@ -1146,7 +1134,7 @@ describe('session reference discovery and preparation', () => {
return snapshot
})
const hangingRead = new AbortController()
const pending = ctx.sessionReferenceResolver.prepare(agent, content, [{ sessionId: one.id }], hangingRead.signal)
const pending = ctx.sessionReferenceResolver.prepare(agent, content, [{ sessionId: one.id }], { provider: 'seed', model: 'seed' }, hangingRead.signal)
await vi.waitFor(() => { expect(releaseRead).toBeTypeOf('function') })
const cancelledRead = expect(pending).rejects.toThrow(expectCode('SESSION_REFERENCE_CANCELLED'))
hangingRead.abort('cancelled while storage remained pending')
@@ -1157,7 +1145,7 @@ describe('session reference discovery and preparation', () => {
const abort = new AbortController()
abort.abort('host cancelled')
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [{ sessionId: one.id }], abort.signal))
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [{ sessionId: one.id }], { provider: 'seed', model: 'seed' }, abort.signal))
.rejects.toThrow(expectCode('SESSION_REFERENCE_CANCELLED'))
})
@@ -1184,7 +1172,7 @@ describe('session reference discovery and preparation', () => {
{ surfaceOp: 'append' },
)
const prepared = await ctx.sessionReferenceResolver.prepare(fakeAgent(target), [{ type: 'text', text: 'go' }], [{ sessionId: source.id }])
const prepared = await ctx.sessionReferenceResolver.prepare(fakeAgent(target), [{ type: 'text', text: 'go' }], [{ sessionId: source.id }], { provider: 'seed', model: 'seed' })
const context = prepared.additionalContext
if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
const data = promptData(context.content[0].text) as unknown[]
@@ -1222,7 +1210,7 @@ describe('session reference discovery and preparation', () => {
const prepared = await ctx.sessionReferenceResolver.prepare(
fakeAgent(target),
[{ type: 'text', text: 'go' }],
sources.map(source => ({ sessionId: source.id })),
sources.map(source => ({ sessionId: source.id })), { provider: 'seed', model: 'seed' },
)
const context = prepared.additionalContext
if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
@@ -1237,7 +1225,7 @@ describe('session reference discovery and preparation', () => {
const ctx = await harness({ maxReferenceBytes: 16 })
const target = ctx.sessions.create(SessionId('target'))
const source = ctx.sessions.create(SessionId('source'))
await expect(ctx.sessionReferenceResolver.prepare(fakeAgent(target), [{ type: 'text', text: 'go' }], [{ sessionId: source.id }]))
await expect(ctx.sessionReferenceResolver.prepare(fakeAgent(target), [{ type: 'text', text: 'go' }], [{ sessionId: source.id }], { provider: 'seed', model: 'seed' }))
.rejects.toThrow(expectCode('SESSION_REFERENCE_BUDGET_EXCEEDED'))
})
@@ -1257,7 +1245,7 @@ describe('session reference discovery and preparation', () => {
const prepared = await ctx.sessionReferenceResolver.prepare(
fakeAgent(target),
[{ type: 'text', text: 'use @source' }],
[{ sessionId: source.id }],
[{ sessionId: source.id }], { provider: 'seed', model: 'seed' },
)
const context = prepared.additionalContext
if (context === undefined) throw new Error('expected prepared context')
@@ -98,7 +98,7 @@ async function fire(
})
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
{ messages: [proposed], turn, step, signal },
{ callConfig: { provider: 'mock', model: 'mock' }, messages: [proposed], turn, step, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [proposed] }),
)
if (decision.kind === 'enter') {
@@ -151,7 +151,7 @@ async function fire(
): Promise<void> {
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
{ messages: [], turn, step, signal },
{ callConfig: { provider: 'mock', model: 'mock' }, messages: [], turn, step, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
if (decision.kind === 'enter') {
+4 -4
View File
@@ -18,8 +18,8 @@
en: 35a960c0100a6aa4
zh: 438e133fb4c24a9e
/deepseek-ai-dsh-agent-loop/use-this-package/configure-declarative-agents:
en: 96295035babf4f90
zh: 8848f9497019ee63
en: 5d0db5059e4d819c
zh: d59c405d585882fd
/deepseek-ai-dsh-agent-loop/use-this-package/create-or-resume-agents-programmatically:
en: 82ec792637e8e57f
zh: e32f8ec5ee17afcf
@@ -33,8 +33,8 @@
en: 030202c112f89366
zh: 621d53ed755cafa3
/deepseek-ai-dsh-agent-loop/understand-the-implementation/request-headers-and-adapter-defaults:
en: b3b3f0ad930961c3
zh: f281e27136e686bc
en: 3102eb539c0cad23
zh: 7209dd24dcc43eef
/deepseek-ai-dsh-agent-loop/understand-the-implementation/source-map:
en: 2a8b93869eb5a2f9
zh: ddab8e738d188a61
+6 -2
View File
@@ -29,7 +29,7 @@ Mount `dsh-agent-loop` in any composition that should run agents. It supplies th
### Configure declarative agents
Agents declared in the config start automatically when the plugin loads. Each entry needs an `id` label; a model call additionally requires both `provider` and `model` (`agent/request` may supply a missing pair before dispatch).
Agents declared in config start automatically when the plugin loads. Each entry needs an `id` label; a model step also needs both `provider` and `model` in the Session selection or initial options before prompt assembly.
```yaml
- name: '@deepseek-ai/dsh-agent-loop'
@@ -47,7 +47,7 @@ Agents declared in the config start automatically when the plugin loads. Each en
|---|---|---|
| `maxParallelToolCalls` | `10` | Parallel-safe tool calls in flight per step; `1` is serial |
| `agents[].id` | required | Stable label; a fresh session mints `${id}-session-<uuid>` unless `sessionId` is set |
| `agents[].provider` / `agents[].model` | — | Model route; both required before dispatch |
| `agents[].provider` / `agents[].model` | — | Model route; both required before step preparation |
| `agents[].reasoningEffort` | — | Non-empty initial reasoning effort; `agent/request` may override it |
| `agents[].maxTokens` | — | Positive per-request output-token cap |
| `agents[].cwd` | — | Workspace directory for a fresh session |
@@ -56,6 +56,8 @@ Agents declared in the config start automatically when the plugin loads. Each en
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-agent-loop) lists every accepted field. `maxParallelToolCalls` is a volatile Config field sampled for the next tool group; `agents` remains startup configuration.
A configured resume records the declared route as the Session selection before publication. Programmatic resume preserves the Session choice unless its setup explicitly records another route; initial `agentOptions` alone do not override saved history.
### Create or resume agents programmatically
Plugins and hosts create agents through `ctx.agents.create()` and resume persisted sessions through `ctx.agents.resume()`; both return an `AgentHandle` whose `dispose()` owns exact teardown. The loop runs each created agent to completion — the handle is only needed when the caller must tear the agent down itself.
@@ -90,6 +92,8 @@ The package is the one concrete implementation of the public `Agent` contract. I
### Request headers and adapter defaults
Before prompt assembly, the loop reads the shared `modelSelection` projection and captures one selection for the step and its retries. Prompt variables and `agent/pre-step.callConfig` use that route. `agent/request` may replace controls such as reasoning effort or sampling, but changing provider or model rejects the step before input is committed. A later recorded choice applies to the next step, including a tool continuation in the same turn.
After `agent/request`, `ctx.llm.prepareCall()` validates adapter-owned fields and resolves reasoning-effort and output-token defaults under the active turn signal. The loop retains that exact adapter through resolution, `request/header` logging, and dispatch. It writes a full header for the first request, a changed envelope (config or tools — the prompt is not part of the header), an explicit message-series start, a request after a surface replacement or image-offload decision, and resume; unchanged steps, retries, and ordinary later turns in the same series inherit the latest header, and an in-history prompt append is not a replacement, so the request that follows it inherits the header too. Beside the header, the loop logs `request/context` — provider, model, `contextWindow`, and the route's `systemPromptUpdate` mode from `prepareCall()` — only when one of those differs from the latest snapshot. Before the next waterfall, the loop removes adapter-default fields so the current route resolves them again, while explicit settings persist. An unhandled route still fails with `NO_ADAPTER`.
The loop deep-freezes each derived message identity on its first request and reuses that proof only within the same agent. Restored messages keep their identity; request construction does not freeze their containing event wrappers. Each request freezes its local canonical header, fresh message array, and envelope while leaving the cancellation signal live. The [archived request-freeze decision](../../../.agents/notes/archived/simplification/2026-09-06-agent-request-freeze-evidence.md) explains ownership and measurement.
+6 -2
View File
@@ -29,7 +29,7 @@ kind: "package-reference"
### 配置声明式 agent
配置中声明的 agent 会在插件加载时自动启动。每个条目需要一个 `id` 标签;模型调用还同时需要 `provider` 与 `model`(`agent/request` 可以在分发前补齐缺失的这一对值)。
配置中声明的 agent 会在插件加载时自动启动。每个条目需要一个 `id` 标签;模型步骤还要求 Session 选择或初始选项在提示词组装前同时提供 `provider` 与 `model`。
```yaml
- name: '@deepseek-ai/dsh-agent-loop'
@@ -47,7 +47,7 @@ kind: "package-reference"
|---|---|---|
| `maxParallelToolCalls` | `10` | 每个步骤同时在途的并行安全工具调用数;`1` 为串行 |
| `agents[].id` | 必填 | 稳定标签;未设置 `sessionId` 时,全新会话会生成 `${id}-session-<uuid>` |
| `agents[].provider` / `agents[].model` | — | 模型路由;分发前两者都必须存在 |
| `agents[].provider` / `agents[].model` | — | 模型路由;步骤准备前两者都必须存在 |
| `agents[].reasoningEffort` | — | 非空的初始推理强度;`agent/request` 可以覆盖它 |
| `agents[].maxTokens` | — | 正数的逐请求输出 token 上限 |
| `agents[].cwd` | — | 全新会话的工作目录 |
@@ -56,6 +56,8 @@ kind: "package-reference"
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-agent-loop) 列出所有接受的字段。`maxParallelToolCalls` 是在下一个工具组开始时读取的 volatile Config 字段;`agents` 仍是启动配置。
配置式恢复会在发布前将声明的路由记录为 Session 选择。程序化恢复保留 Session 选择,除非其 setup 显式记录另一条路由;仅传入初始 `agentOptions` 不会覆盖已有历史。
### 以编程方式创建或恢复 agent
插件与宿主通过 `ctx.agents.create()` 创建 agent,通过 `ctx.agents.resume()` 恢复持久化会话;两者都返回 `AgentHandle`,其 `dispose()`(资源释放)负责精确拆除对应的 agent。循环会把每个创建的 agent 运行到完成——只有调用方需要自行拆除 agent 时才需要句柄。
@@ -90,6 +92,8 @@ const handle = await ctx.agents.create({
### 请求 header 与适配器默认值
循环在提示词组装前读取共享 `modelSelection` 投影,并为该步骤及其重试捕获一次选择。提示词变量与 `agent/pre-step.callConfig` 使用该路由。`agent/request` 可以替换推理强度、采样等控制参数,但改变提供方或模型会在提交输入前拒绝该步骤。之后记录的选择从下一个步骤开始生效,包括同一轮次中的工具后续步骤。
`agent/request` 返回后,`ctx.llm.prepareCall()` 会在活跃轮次信号下校验适配器持有的字段,并解析推理强度和输出 token 默认值。循环会在解析、`request/header` 记录与分派期间保留同一个适配器。循环会为首次请求、变化的 envelope(配置或工具——提示词不属于 header)、显式消息序列起点、surface 替换或图片省略决定后的请求及恢复写入完整 header;同一序列内内容未变的步骤、重试与普通后续轮次继承最新 header,历史内追加提示词不是替换,因此紧随其后的请求同样继承 header。在 header 之外,循环还会记录 `request/context`——提供方、模型、`contextWindow` 以及来自 `prepareCall()` 的路由 `systemPromptUpdate` 模式——且仅在其中任何一项与最新快照不同时记录。下一次 waterfall 分发前,循环移除适配器默认字段,使当前路由重新解析它们;显式设置则保留。未处理的路由仍以 `NO_ADAPTER` 失败。
循环在每个派生消息对象首次进入请求时执行深冻结,并且仅在同一 agent 内复用该证明。恢复的消息保留对象身份;构造请求不会冻结包含消息的事件包装对象。每个请求都会冻结本地规范化 header、新消息数组和请求封装,同时保留取消信号的可变性。[已归档的请求冻结决策](../../../.agents/notes/archived/simplification/2026-09-06-agent-request-freeze-evidence.md)解释了所有权与测量依据。
+26 -30
View File
@@ -12,10 +12,11 @@ import type {
AgentStatus,
CancelOptions,
InboxTarget,
ModelSelection,
PreStepDecision,
RequestErrorAction,
} from '@deepseek-ai/dsh-agent'
import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
import { agentEvents, assembleContextFor, currentModelSelection } from '@deepseek-ai/dsh-agent'
import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm'
import {
LlmError,
@@ -58,6 +59,7 @@ type PreparedStep =
messages: UserMessage[]
startsRequestSeries?: true
assembly: PromptAssembly
selection: ModelSelection
}
/** Remove adapter-derived values before plugins propose the next request config. */
@@ -269,10 +271,14 @@ export class ReactLoopAgent implements Agent {
if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": pre-step outside running phase`)
const signal = this.phase.abort.signal
const claimed = this.inbox.claim(target, position.turn)
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
const selection = currentModelSelection(this.loopCtx.sessionProjections, this)
if (selection === undefined) {
throw new Error(`agent "${this.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or record a model selection`)
}
const assembly = await this.loopCtx.systemPrompt.assemble({ ...assembleContextFor(this, signal), callConfig: selection })
signal.throwIfAborted()
const decision = await this.dispatch.waterfall(
'agent/pre-step', { messages: claimed, ...position, signal },
'agent/pre-step', { messages: claimed, ...position, signal, callConfig: selection },
(): Promise<PreStepDecision> => {
const sections = renderContextSections(assembly)
const context = this.runtimeContext.project(joinContextSections(sections), sections)
@@ -284,7 +290,7 @@ export class ReactLoopAgent implements Agent {
)
signal.throwIfAborted()
if (decision.kind === 'reject') return decision
return { ...decision, assembly }
return { ...decision, assembly, selection }
}
/** Whether the assembled tool schemas differ from the logged request header's. */
@@ -407,8 +413,10 @@ export class ReactLoopAgent implements Agent {
const renderedPrompt = renderPrompt(assembly)
let firstAttempt = true
while (true) {
const { config, preparedCall } = await this.prepareRequest(turn, step, signal)
const currentContext = this.loopCtx.systemPrompt.refreshContext(assembly, assembleContextFor(this, signal))
const { config, preparedCall } = await this.prepareRequest(turn, step, decision.selection, signal)
const currentContext = this.loopCtx.systemPrompt.refreshContext(assembly, {
...assembleContextFor(this, signal), callConfig: decision.selection,
})
const sections = renderContextSections(currentContext)
const context = this.runtimeContext.project(joinContextSections(sections), sections)
signal.throwIfAborted()
@@ -560,43 +568,31 @@ export class ReactLoopAgent implements Agent {
}
}
/** Resolve request config and bind its adapter before admitting model-visible input. */
/** Resolve request config for the step's selection and bind its adapter before admitting model-visible input. */
private async prepareRequest(
turn: number,
step: number,
selection: ModelSelection,
signal: AbortSignal,
): Promise<{ config: LlmCallConfig; preparedCall?: PreparedLlmCall }> {
const { session } = this
// A loop instance starts from its declared route, restoring only an explicit
// effort owned by that exact model. Later steps re-resolve marked defaults.
const persistedHeader = session.requestHeader()
const persistedConfig = persistedHeader?.config
const route = { provider: this.options.provider ?? '', model: this.options.model ?? '' }
const persistedReasoningEffort = persistedConfig?.provider === route.provider
&& persistedConfig.model === route.model
&& persistedHeader?.adapterDefaults?.reasoningEffort !== true
? persistedConfig.reasoningEffort
: undefined
const reasoningEffort = this.options.reasoningEffort ?? persistedReasoningEffort
// A loop instance seeds output controls from its options until it logs its
// own header; later steps re-resolve marked defaults.
const maxTokens = this.options.maxTokens
const seedConfig = deepFreeze(structuredClone(
this.requestHeaderLogged
// oxlint-disable-next-line typescript/no-non-null-assertion -- the instance logged the header it now folds
? requestProposal(persistedHeader!)
: {
...route,
...reasoningEffort === undefined ? {} : { reasoningEffort },
...maxTokens === undefined ? {} : { maxTokens },
},
))
const persisted: Partial<LlmCallConfig> = this.requestHeaderLogged
// oxlint-disable-next-line typescript/no-non-null-assertion -- the instance logged the header it now folds
? requestProposal(session.requestHeader()!)
: maxTokens === undefined ? {} : { maxTokens }
const { provider: _provider, model: _model, reasoningEffort: _effort, ...controls } = persisted
const seedConfig = deepFreeze(structuredClone({ ...controls, ...selection }))
const proposedConfig = await this.dispatch.waterfall(
'agent/request', { turn, step, signal },
() => Promise.resolve(seedConfig),
)
signal.throwIfAborted()
if (!proposedConfig.provider || !proposedConfig.model) {
throw new Error(`agent "${this.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`)
if (proposedConfig.provider !== selection.provider || proposedConfig.model !== selection.model) {
throw new Error('agent/request cannot change the provider or model after step preparation')
}
let config: LlmCallConfig
let preparedCall: PreparedLlmCall | undefined
+10 -4
View File
@@ -23,6 +23,7 @@ import type {
TurnBoundaryProjection,
} from '@deepseek-ai/dsh-agent'
import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { recordDeclaredRoute } from '@deepseek-ai/dsh-agent'
import { interruptedTurnClosers, SessionLogOffset, SessionPreparation, SessionSeq } from '@deepseek-ai/dsh-session'
import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -306,7 +307,7 @@ export interface Config {
* omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
*/
maxParallelToolCalls: Volatile<number>
/** Agents created or resumed at plugin startup. */
/** Agents created or resumed at plugin startup; a resumed agent records its configured route as the Session's model selection. */
agents: (AgentOptions & {
/** Stable config label used in logs and as the fresh combined-id prefix. */
id: string
@@ -386,8 +387,8 @@ export class AgentLoop extends Service implements AgentFactory {
})
}, 'agentLoop.transactions()')
ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()')
ctx.systemPrompt.variable('provider', context => context.agent?.options.provider)
ctx.systemPrompt.variable('model', context => context.agent?.options.model)
ctx.systemPrompt.variable('provider', context => context.callConfig?.provider ?? context.agent?.options.provider)
ctx.systemPrompt.variable('model', context => context.callConfig?.model ?? context.agent?.options.model)
for (const { id, sessionId, cwd, resumeSessionId, ...options } of this.config.agents) {
const meta = cwd === undefined ? {} : { cwd }
@@ -412,6 +413,7 @@ export class AgentLoop extends Service implements AgentFactory {
void this.resumeWith(ctx, childCtx.sessionPersistence, {
resumeSessionId,
agentOptions: options,
setup: (_agentCtx, agent) => { recordDeclaredRoute(ctx.sessionProjections, agent, options) },
}).catch((error: unknown) => {
this.reportConfiguredStartupFailure(id, 'resume', resumeSessionId, error)
})
@@ -454,7 +456,11 @@ export class AgentLoop extends Service implements AgentFactory {
await this.waitForDrainingConfiguredIdentity(ownerCtx, sessionId)
if (!this.ownership.isActive()) return
try {
await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions })
await this.resumeWith(ownerCtx, persistence, {
resumeSessionId: sessionId,
agentOptions,
setup: (_agentCtx, agent) => { recordDeclaredRoute(this.runtime.ctx.sessionProjections, agent, agentOptions) },
})
return
} catch (error: unknown) {
if (!this.ownership.isActive()) return
@@ -1,3 +1,4 @@
import { recordModelSelection } from '@deepseek-ai/dsh-agent'
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import LlmRuntime, { type RequestMessage, createUserMessage, ToolCallId, LlmError, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
@@ -450,17 +451,15 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
: undefined).toContain('has no provider/model')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
? turnEnd.data.reason.error.message
: undefined).toContain('agent/request')
: undefined).toContain('record a model selection')
})
it('the agent/request waterfall can supply the model for a model-less agent', async () => {
it('a recorded selection supplies the route before a model-less Agent starts a step', async () => {
const adapter = new MockAdapter([textResponse('routed')])
const ctx = await harness(adapter)
const agent = await ctx.agentLoop.create(SessionId('a1'), {}) // no model — router plugin decides
const agent = await ctx.agentLoop.create(SessionId('a1'), {})
ctx.on('agent/request', async (_payload, next) => {
return { ...await next(), provider: 'mock', model: 'mock' }
})
recordModelSelection(ctx.sessionProjections, agent, { provider: 'mock', model: 'mock' })
send(agent, 'go')
await waitForIdle(ctx, agent)
+15 -28
View File
@@ -6,7 +6,7 @@ import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent, type AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { recordModelSelection, type Agent, type AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
@@ -602,24 +602,11 @@ describe('agent loop', () => {
expect(turnEnds[1]?.type === 'turn/end' && turnEnds[1].data.reason.kind).toBe('completed')
})
it('supports the model-via-agent/request path with a {{model}} persona: the supplier states it via the assemble waterfall', async () => {
// AgentOptions.model unset: the model arrives in the agent/request
// waterfall (the loop's documented fallback — see runStep's no-model
// error). {{model}} renders BEFORE that waterfall, so the SAME plugin
// states the fact early on system-prompt/assemble — the owner of a
// late-bound fact owns stating it wherever it is claimed.
it('uses a recorded selection for both the request and the {{model}} persona', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter, 'You run on {{model}}.')
ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
assembly.variables['provider'] = 'mock'
assembly.variables['model'] = 'mock'
return next()
})
ctx.on('agent/request', async (_payload, next) => {
const config = await next()
return { ...config, provider: 'mock', model: 'mock' }
})
const agent = await ctx.agentLoop.create(SessionId('a-late-model'), {})
const agent = await ctx.agentLoop.create(SessionId('a-recorded-model'), {})
recordModelSelection(ctx.sessionProjections, agent, { provider: 'mock', model: 'mock' })
send(agent, 'hi')
await waitForIdle(ctx, agent)
@@ -1309,27 +1296,27 @@ describe('agent loop', () => {
expect(texts).toContain('late steering')
})
it('agent/request waterfall switches models by returning a replacement config; the switch is logged', async () => {
const adapter = new MockAdapter([textResponse('ok')])
it.each(['provider', 'model'] as const)('rejects an agent/request replacement that changes %s after step preparation', async (field) => {
const adapter = new MockAdapter([textResponse('never')])
const ctx = await harness(adapter)
const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request', async (_payload, next) => {
const config = await next()
// The seed is frozen — config is not a mutable per-call knob; a switch
// is proposed by returning a replacement, and the loop logs it.
expect(Object.isFrozen(config)).toBe(true)
expect(() => { (config as { model: string }).model = 'other-model' }).toThrow(TypeError)
return { ...config, model: 'other-model' }
return { ...config, [field]: 'other' }
})
send(agent, 'hi')
await waitForIdle(ctx, agent)
expect(adapter.requests[0]!.model).toBe('other-model')
// The header event records what the request ACTUALLY used — the switch is
// a reconstructable fact, not silent drift.
const headerEvent = agent.session.snapshotEvents().find(e => e.type === 'request/header')
expect(headerEvent?.type === 'request/header' && headerEvent.data.header.config.model).toBe('other-model')
expect(adapter.requests).toEqual([])
expect(agent.session.snapshotEvents().filter(event => ['request/header', 'user/message', 'system/message'].includes(event.type)))
.toEqual([])
expect(agent.session.snapshotEvents().at(-1)).toMatchObject({
type: 'turn/end', data: { reason: { kind: 'error', error: {
message: 'agent/request cannot change the provider or model after step preparation',
} } },
})
})
it('agent/pre-step fires once per proposed step before the step is opened', async () => {
@@ -4,6 +4,7 @@
* request/header snapshot. Each request extends its predecessor unless a logged compaction
* replacement or header change explains the difference.
*/
import { recordDeclaredRoute, recordModelSelection } from '@deepseek-ai/dsh-agent'
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
@@ -253,6 +254,7 @@ describe('request stability across the loop', () => {
sessionId: SessionId(`effort-${model}`),
seed: structuredClone(agent.session.snapshotEvents()),
agentOptions: { provider: 'mock', model },
setup: (_ctx, agent) => { recordDeclaredRoute(resumedCtx.sessionProjections, agent, agent.options) },
})
send(resumedHandle.agent, 'resumed')
await waitForIdle(resumedCtx, resumedHandle.agent)
@@ -294,15 +296,9 @@ describe('request stability across the loop', () => {
provider: 'deepseek',
model: 'deepseek-model',
})
ctx.on('agent/request', async ({ turn }, next) => {
const config = await next()
return turn === 2
? { ...config, provider: 'other', model: 'other-model' }
: config
})
send(agent, 'first')
await waitForIdle(ctx, agent)
recordModelSelection(ctx.sessionProjections, agent, { provider: 'other', model: 'other-model' })
send(agent, 'second')
await waitForIdle(ctx, agent)
@@ -328,15 +324,9 @@ describe('request stability across the loop', () => {
model: 'deepseek-model',
maxTokens: 4_096,
})
ctx.on('agent/request', async ({ turn }, next) => {
const config = await next()
return turn === 2
? { ...config, provider: 'other', model: 'other-model' }
: config
})
send(agent, 'first')
await waitForIdle(ctx, agent)
recordModelSelection(ctx.sessionProjections, agent, { provider: 'other', model: 'other-model' })
send(agent, 'second')
await waitForIdle(ctx, agent)
@@ -948,9 +938,7 @@ describe('request/context capacity records', () => {
send(agent, 'first')
await waitForIdle(ctx, agent)
ctx.on('agent/request', ({ agent: subject }, next) => subject === agent
? Promise.resolve({ provider: 'mock', model: 'large' })
: next())
recordModelSelection(ctx.sessionProjections, agent, { provider: 'mock', model: 'large' })
send(agent, 'second')
await waitForIdle(ctx, agent)
@@ -975,14 +963,9 @@ describe('request/context capacity records', () => {
const adapter = capacityAdapter({ known: 64_000 }, [textResponse('a'), textResponse('b')])
const ctx = await harness(adapter)
const agent = await ctx.agentLoop.create(SessionId('capacity-clear'), { provider: 'mock', model: 'known' })
let model = 'known'
ctx.on('agent/request', ({ agent: subject }, next) => subject === agent
? Promise.resolve({ provider: 'mock', model })
: next())
send(agent, 'first')
await waitForIdle(ctx, agent)
model = 'unknown'
recordModelSelection(ctx.sessionProjections, agent, { provider: 'mock', model: 'unknown' })
send(agent, 'second')
await waitForIdle(ctx, agent)
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { installModelSelection, type Agent, type ModelSelectionRef } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { installModelSwitchNotice, recordDeclaredRoute, recordModelSelection, type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import LlmRuntime, { createUserMessage, LlmError, type GenerateOptions } from '@deepseek-ai/dsh-llm'
import { toPiContext } from '@deepseek-ai/dsh-llm-pi-ai/src/context.ts'
@@ -39,9 +39,8 @@ async function harness(capable = new MockAdapter(Array.from({ length: 8 }, () =>
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => ({
...await next(), sections: [{ name: 'test', text: prompt, order: 0 }],
}))
const selection: ModelSelectionRef = { current: { provider: 'capable', model: 'model' }, assembled: undefined }
const agent = await ctx.agentLoop.create(SessionId('admission'), { provider: 'capable', model: 'model' })
installModelSelection(agent.ctx, selection)
installModelSwitchNotice(agent.ctx)
ctx.on('llm/stream', (request, next) => {
// Rebuild from copied source events, not the live projection's cached state.
const subject = ctx.agents.get(request.sessionId!)!
@@ -50,7 +49,7 @@ async function harness(capable = new MockAdapter(Array.from({ length: 8 }, () =>
expect(request.system).toBeUndefined()
return next()
})
return { ctx, agent, capable, plain, selection, setPrompt: (text: string) => { prompt = text } }
return { ctx, agent, capable, plain, setPrompt: (text: string) => { prompt = text } }
}
async function send(agent: Agent, text: string) {
@@ -183,7 +182,7 @@ describe('prepared-route prompt admission', () => {
h.setPrompt('prompt three')
await send(h.agent, 'third')
expect(systemTexts(h.capable.requests[2]!)).toHaveLength(3)
h.selection.current = { provider, model: 'model' }
recordModelSelection(h.ctx.sessionProjections, h.agent, { provider, model: 'model' })
h.setPrompt('')
await send(h.agent, 'clear')
const adapter = provider === 'capable' ? h.capable : h.plain
@@ -287,7 +286,7 @@ describe('prepared-route prompt admission', () => {
h.setPrompt('prompt two')
await send(h.agent, 'second')
expect(systemTexts(h.capable.requests[1]!)).toHaveLength(2)
h.selection.current = { provider: 'plain', model: 'model' }
recordModelSelection(h.ctx.sessionProjections, h.agent, { provider: 'plain', model: 'model' })
if (changed) h.setPrompt('prompt three')
await send(h.agent, 'third')
expectPlain(h.plain.requests[0]!, changed ? 'prompt three' : 'prompt two')
@@ -305,9 +304,9 @@ describe('prepared-route prompt admission', () => {
it('appends on the first capable request after a plain route', async () => {
const h = await harness()
h.selection.current = { provider: 'plain', model: 'model' }
recordModelSelection(h.ctx.sessionProjections, h.agent, { provider: 'plain', model: 'model' })
await send(h.agent, 'first')
h.selection.current = { provider: 'capable', model: 'model' }
recordModelSelection(h.ctx.sessionProjections, h.agent, { provider: 'capable', model: 'model' })
h.setPrompt('prompt two')
await send(h.agent, 'second')
expect(systemTexts(h.capable.requests[0]!)).toEqual([
@@ -357,13 +356,18 @@ describe('prepared-route prompt admission', () => {
.toEqual(['initial', 'resume'])
})
it('normalizes restored history under the resumed instance route', async () => {
it('normalizes restored history after explicitly selecting the declared resume route', async () => {
const h = await harness()
await send(h.agent, 'first')
h.setPrompt('prompt two')
await send(h.agent, 'second')
const seed = h.agent.session.snapshotEvents()
const { agent: resumed } = await h.ctx.agents.create({ sessionId: SessionId('resumed'), agentOptions: { provider: 'plain', model: 'model' }, seed: [...seed] })
const { agent: resumed } = await h.ctx.agents.create({
sessionId: SessionId('resumed'),
agentOptions: { provider: 'plain', model: 'model' },
seed: [...seed],
setup: (_ctx, agent) => { recordDeclaredRoute(h.ctx.sessionProjections, agent, agent.options) },
})
await send(resumed, 'resume')
expectPlain(h.plain.requests[0]!, 'prompt two')
expect(resumed.session.snapshotEvents().filter(event => event.type === 'request/header').at(-1)?.data.reason).toBe('resume')
@@ -398,13 +402,38 @@ describe('prepared-route prompt admission', () => {
expect(types.filter(type => ['system/message', 'user/message', 'request/header'].includes(type))).toEqual([])
})
it('retains the captured selection for a retry and uses a pending choice on the next step', async () => {
const adapter = new MockAdapter([
() => { throw new LlmError('retry this request', 'SERVER') },
textResponse('recovered'),
])
const h = await harness(adapter)
h.ctx.on('agent/request-error', () => {
recordModelSelection(h.ctx.sessionProjections, h.agent, { provider: 'plain', model: 'next-model' })
return Promise.resolve({ kind: 'retry' as const })
})
await send(h.agent, 'retry once')
expect(adapter.requests.map(({ provider, model }) => ({ provider, model }))).toEqual([
{ provider: 'capable', model: 'model' },
{ provider: 'capable', model: 'model' },
])
expect(h.plain.requests).toHaveLength(0)
expect(h.ctx.sessionProjections.stateOf(h.agent.session, 'modelSelection')?.pending)
.toEqual({ provider: 'plain', model: 'next-model' })
await send(h.agent, 'use the pending choice')
expect(h.plain.requests[0]).toMatchObject({ provider: 'plain', model: 'next-model' })
expect(h.ctx.sessionProjections.stateOf(h.agent.session, 'modelSelection')?.pending).toBeNull()
})
it('keeps generic config changes and concurrent selection on the same prepared route', async () => {
const h = await harness()
await send(h.agent, 'first')
h.setPrompt('prompt two')
h.ctx.on('agent/request', async (_payload, next) => {
const config = await next()
h.selection.current = { provider: 'plain', model: 'model' }
recordModelSelection(h.ctx.sessionProjections, h.agent, { provider: 'plain', model: 'model' })
return { ...config, temperature: 0.5, maxTokens: 100 }
})
await send(h.agent, 'second')
+12 -12
View File
@@ -15,11 +15,11 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-agent/use-this-package:
en: b48322540e229083
zh: 05aec0854397f4c3
en: 326904be67f43b86
zh: e143c7d9e1f30bc0
/deepseek-ai-dsh-agent/use-this-package/create-or-resume-an-agent:
en: e305441a5a4a9b44
zh: 3e997b12ce0ce04c
en: d6b04d55ac5f09bb
zh: 37ed39fa1c588054
/deepseek-ai-dsh-agent/use-this-package/drive-an-agent-s-conversation:
en: 71e258897e2e7d73
zh: abf0991012faa17e
@@ -36,14 +36,14 @@
en: 7c850051d2857bfa
zh: 4cab817553114e15
/deepseek-ai-dsh-agent/understand-the-implementation/step-admission:
en: 348b9ecd0890c585
zh: d0f73d28d0d2c6f1
en: fad7e357a5d4160b
zh: 03e44e1f1ac7a897
/deepseek-ai-dsh-agent/understand-the-implementation/durable-inbox:
en: f6802c7f94f623b3
zh: cc3885db80765970
/deepseek-ai-dsh-agent/understand-the-implementation/source-map:
en: 7e09b94c79dc97ca
zh: 3f9c5455c4dc563a
en: d88de2a182691dbb
zh: 837d4454f1d7dea0
/deepseek-ai-dsh-agent/understand-the-implementation/registry-and-lifecycle:
en: df92e97cedd0d02e
zh: f6040c145bffd933
@@ -63,8 +63,8 @@
en: 962c598672b0eed6
zh: d05b3bc936685ad3
/deepseek-ai-dsh-agent/model-experience/user-steering-and-injected-messages/what-the-model-sees:
en: 55fcf97dbcb7a41e
zh: 691c0ab3ea41f6f0
en: 9703263d210e6246
zh: c5568c930f10fab5
/deepseek-ai-dsh-agent/model-experience/user-steering-and-injected-messages/token-effect:
en: 5f428bfadaf4394b
zh: 03308df9fd3492a7
@@ -75,8 +75,8 @@
en: 215fc35b0f30e38a
zh: 21788ec980c300ea
/deepseek-ai-dsh-agent/model-experience/agent-scoped-request-composition/what-the-model-sees:
en: 057babd515991736
zh: acf0c05c490b689c
en: 09a31923f42fcadf
zh: c52117c2afbca7c6
/deepseek-ai-dsh-agent/model-experience/agent-scoped-request-composition/token-effect:
en: dfebf42b629656d9
zh: 9369ace52f58a579
+7 -5
View File
@@ -25,7 +25,7 @@ Use `dsh-agent` to create or resume live agents, send follow-up or steering inpu
<a id="use-this-package"></a>
## Use this package
Mount `dsh-agent` wherever live agents exist: it provides `ctx.agents` and the `Agent` handle that plugins, UI, hooks, and orchestrators work against. The service is inert until a driver registers a factory — the shipped driver is `dsh-agent-loop`, so the smallest useful composition loads both.
Mount `dsh-session-projection` before `dsh-agent`. The Agent registry provides `ctx.agents`, the `Agent` handle, and the shared `modelSelection` projection. A driver must register a factory before callers create or resume Agents; the shipped driver is `dsh-agent-loop`.
### Create or resume an agent
@@ -42,6 +42,8 @@ await handle.dispose() // stops the loop, unregisters, removes the session, un
`AgentOptions` supplies the initial provider/model route, optional adapter-owned `reasoningEffort`, and optional positive `maxTokens` output cap. The loop validates exact-model reasoning support, resolves adapter defaults, records the effective values in the request header, and applies them to each conversation request. An optional `setup(agentCtx, agent)` callback composes the agent's scoped world before it is published: `agentCtx` owns registrations, while the explicit unpublished Agent provides its Session; the Context has no reverse Agent property. Scoped tools, prompt sections, and listeners exist before any creation announcement. Setup is composition-only: drive the agent only after creation resolves.
A Session's recorded model selection takes precedence over its latest request route, which takes precedence over `AgentOptions`. Call `recordModelSelection(ctx.sessionProjections, agent, selection)` after validating a new choice; it appends `model/selection` only when the choice differs. `currentModelSelection()` reads this shared state without a separate mutable cache. Resume preserves the recorded choice; deployments that intentionally replace it call `recordDeclaredRoute()` during setup. That helper retains explicit reasoning effort only for the same route, while adapter-defaulted effort is resolved again.
### Drive an agent's conversation
The handle's methods route identified user-role messages into the agent's inbox. `followup()` queues an ordinary next-turn prompt and wakes the driver; `steer()` submits next-step input and wakes it; `inject()` adds model-facing context without waking the driver, so it lands in the next admitted step. `cancel(cause)` aborts the active activity and, unless `keepInbox` is set, clears pending work; `whenIdle()` resolves after the whole agent reaches quiescence.
@@ -82,7 +84,7 @@ The package is built on one separation: the public `Agent` surface and registry
### Step admission
`PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages, startsRequestSeries? }`. The enter branch contains the complete identified, frozen message batch. Acceptance does not commit it: after assembly and `step/start`, `agent/request` and `prepareCall()` resolve the route before the loop commits the system prompt and user batch. Cancellation during either async phase commits neither. `startsRequestSeries: true` declares a distinct model-message series; a wrapping listener preserves that declaration and the batch unless it intentionally replaces either one. Claiming removes offered messages from the inbox, while messages inserted after the claim remain pending for a later boundary.
`PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages, startsRequestSeries? }`. The enter branch contains the complete identified, frozen message batch. Acceptance does not commit it: after assembly and `step/start`, `agent/request` adjusts controls without changing the step route, and `prepareCall()` binds the adapter before the loop commits the system prompt and user batch. Cancellation during either async phase commits neither. `startsRequestSeries: true` declares a distinct model-message series; a wrapping listener preserves that declaration and the batch unless it intentionally replaces either one. Claiming removes offered messages from the inbox, while messages inserted after the claim remain pending for a later boundary.
### Durable inbox
@@ -99,7 +101,7 @@ The package is built on one separation: the public `Agent` surface and registry
| [`src/types.ts`](src/types.ts) | `AgentOptions`, cancellation causes, and inbox projection vocabulary |
| [`src/dispatch.ts`](src/dispatch.ts) | `agentEvents` fused dispatcher and `assembleContextFor(agent)` |
| [`src/consumed-work.ts`](src/consumed-work.ts) | `foldConsumedWork(events)`: what the log's consumed work became |
| [`src/model-selection.ts`](src/model-selection.ts) | `installModelSelection`: coupling one selection to assembly and routing |
| [`src/model-selection.ts`](src/model-selection.ts) | Durable model-selection fold, read/write helpers, and optional switch notices |
| [`src/archive-admission.ts`](src/archive-admission.ts) | The `turn` family of the Workspace registry's archive admission: a running turn and its user-cause cancel |
### Registry and lifecycle
@@ -140,7 +142,7 @@ The package-level contract is enough for most consumers; read these when you nee
#### What the model sees
`followup`, `steer`, and `inject` feed the owning session as identified user-role messages; accepted content becomes part of the derived history the model reads on later steps. `agent/pre-step` and the other declared events let plugins reject a proposed step or add durable request material. `installModelSelection` adds `[model changed: assistant turns above this point were generated by <previous>; the session continues with <next>]` to the first step assembled for a different provider/model route that would send a model request; provider names appear only when the switch crosses providers, and reasoning-effort-only changes add nothing. An empty first decision and a decision that removes offered messages remain no-request results. If a request step fails before logging its header, the next request step receives the notice again because the durable previous route has not changed.
`followup`, `steer`, and `inject` feed the owning session as identified user-role messages; accepted content becomes part of the derived history the model reads on later steps. `agent/pre-step` and the other declared events let plugins reject a proposed step or add durable request material. `installModelSwitchNotice` adds `[model changed: assistant turns above this point were generated by <previous>; the session continues with <next>]` to the first step assembled for a different provider/model route that would send a model request; provider names appear only when the switch crosses providers, and reasoning-effort-only changes add nothing. An empty first decision and a decision that removes offered messages remain no-request results. If a request step fails before logging its header, the next request step receives the notice again because the durable previous route has not changed.
#### Token effect
@@ -154,7 +156,7 @@ Accepted history and steering are append-only; a blocked submission sends no req
#### What the model sees
Registrations through `agent.ctx` can shadow prompt sections or tools and can install agent-only interceptors during unpublished setup, so one agent sees a different prompt and tool set than its neighbors. Model selection captures one provider/model/effort value before prompt assembly and applies it to the same step's request; a later concurrent change waits for another step.
Registrations through `agent.ctx` can shadow prompt sections or tools and can install agent-only interceptors during unpublished setup, so one agent sees a different prompt and tool set than its neighbors. Model selection captures one provider/model/effort value before prompt assembly and applies it to the same step's request and retries; a later concurrent change waits for another step.
#### Token effect
+7 -5
View File
@@ -25,7 +25,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
在存在实时 agent 的任何地方挂载 `dsh-agent`:它提供 `ctx.agents` 以及插件、UI、钩子和编排器所面向编程的 `Agent` 句柄。在没有驱动器注册工厂之前,该服务保持惰性——随附驱动器是 `dsh-agent-loop`,因此最小的可用组合需要同时加载两者。
先挂载 `dsh-session-projection`,再挂载 `dsh-agent`。Agent 注册表提供 `ctx.agents`、`Agent` 句柄与共享 `modelSelection` 投影。调用方创建或恢复 Agent 之前,必须由驱动器注册工厂;随附驱动器为 `dsh-agent-loop`。
### 创建或恢复 agent
@@ -42,6 +42,8 @@ await handle.dispose() // stops the loop, unregisters, removes the session, un
`AgentOptions` 提供初始提供方/模型路由、可选的由适配器定义的 `reasoningEffort`,以及可选的正数 `maxTokens` 输出上限。循环会校验确切模型的推理(reasoning)支持、解析适配器默认值、把生效值记录在请求头中,并将它们应用到每个对话请求。可选的 `setup(agentCtx, agent)` 回调会在 agent 发布之前组合其作用域世界:`agentCtx` 拥有注册,显式的未发布 Agent 则提供其 Session;Context 不含反向 Agent 属性。作用域工具、提示词段与监听器在任何创建公告之前就已存在。Setup 只做组合:创建完成后才能驱动 agent。
Session 中记录的模型选择优先于最近请求的路由,最近请求的路由又优先于 `AgentOptions`。校验新选择后调用 `recordModelSelection(ctx.sessionProjections, agent, selection)`;它只在选择不同时追加 `model/selection`。`currentModelSelection()` 读取这份共享状态,不使用独立的可变缓存。恢复保留记录中的选择;需要有意覆盖它的部署在 setup 期间调用 `recordDeclaredRoute()`。该辅助函数仅在路由相同时保留显式推理强度,适配器默认的强度则重新解析。
### 驱动 agent 的对话
句柄的方法把带标识的 user 角色消息路由进 agent 的收件箱。`followup()` 排队一条普通的下一个轮次提示词并唤醒驱动器;`steer()` 提交下一步输入并唤醒它;`inject()` 添加面向模型的上下文但不唤醒驱动器,因此它落在下一个被接纳的步骤中。`cancel(cause)` 中止当前活动,并在未设置 `keepInbox` 时清除待处理工作;`whenIdle()` 会在整个 agent 达到完全停稳后完成。
@@ -82,7 +84,7 @@ await handle.agent.whenIdle()
### 步骤准入
`PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages, startsRequestSeries? }`。enter 分支包含完整、带标识且冻结的消息批次。接纳不等于提交:组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析路由,循环随后才提交系统提示词与用户批次。在任一异步阶段取消都不会提交这两者。`startsRequestSeries: true` 声明一个独立的模型消息序列;包装下游 enter 的监听器会保留该声明与批次,除非有意替换其中一项。领取会从 inbox 移除候选消息,领取后插入的消息则等待后续边界。
`PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages, startsRequestSeries? }`。enter 分支包含完整、带标识且冻结的消息批次。接纳不等于提交:组装与 `step/start` 之后,`agent/request` 在不改变步骤路由的前提下调整控制参数,`prepareCall()` 绑定适配器,循环随后才提交系统提示词与用户批次。在任一异步阶段取消都不会提交这两者。`startsRequestSeries: true` 声明一个独立的模型消息序列;包装下游 enter 的监听器会保留该声明与批次,除非有意替换其中一项。领取会从 inbox 移除候选消息,领取后插入的消息则等待后续边界。
### 持久 inbox
@@ -99,7 +101,7 @@ await handle.agent.whenIdle()
| [`src/types.ts`](src/types.ts) | `AgentOptions`、取消原因与收件箱投影词汇 |
| [`src/dispatch.ts`](src/dispatch.ts) | `agentEvents` 融合分发器与 `assembleContextFor(agent)` |
| [`src/consumed-work.ts`](src/consumed-work.ts) | `foldConsumedWork(events)`:日志消费掉的工作最终怎样了 |
| [`src/model-selection.ts`](src/model-selection.ts) | `installModelSelection`:把一个选择耦合到组装与路由 |
| [`src/model-selection.ts`](src/model-selection.ts) | 持久模型选择归约、读写辅助函数及可选切换提示 |
| [`src/archive-admission.ts`](src/archive-admission.ts) | Workspace 注册表归档准入中的 `turn` 族:运行中的回合及其用户原因取消 |
### 注册表与生命周期
@@ -140,7 +142,7 @@ await handle.agent.whenIdle()
#### 模型看到什么
`followup`、`steer` 与 `inject` 以带标识的 user 角色消息馈送所属会话;被接纳的内容成为模型在后续步骤中读取的派生历史的一部分。`agent/pre-step` 与其他已声明事件让插件能够拒绝拟进入的步骤或添加持久请求材料。`installModelSelection` 会在首次为不同提供方/模型路由组装且原本会发出模型请求的步骤中加入 `[model changed: assistant turns above this point were generated by <previous>; the session continues with <next>]`;仅跨提供方切换时显示提供方名称,只改变推理强度时不添加消息。第一个决策为空时,以及某个决策移除候选消息后为空时,都不会产生请求。如果请求步骤在记录请求头前失败,持久记录中的先前路由没有变化,所以下一个请求步骤会再次收到提示。
`followup`、`steer` 与 `inject` 以带标识的 user 角色消息馈送所属会话;被接纳的内容成为模型在后续步骤中读取的派生历史的一部分。`agent/pre-step` 与其他已声明事件让插件能够拒绝拟进入的步骤或添加持久请求材料。`installModelSwitchNotice` 会在首次为不同提供方/模型路由组装且原本会发出模型请求的步骤中加入 `[model changed: assistant turns above this point were generated by <previous>; the session continues with <next>]`;仅跨提供方切换时显示提供方名称,只改变推理强度时不添加消息。第一个决策为空时,以及某个决策移除候选消息后为空时,都不会产生请求。如果请求步骤在记录请求头前失败,持久记录中的先前路由没有变化,所以下一个请求步骤会再次收到提示。
#### Token 影响
@@ -154,7 +156,7 @@ await handle.agent.whenIdle()
#### 模型看到什么
通过 `agent.ctx` 进行的注册可以遮蔽提示词段或工具,也可以在未发布 setup 期间安装仅适用于该 agent 的拦截器,因此一个 agent 看到的提示词与工具集会与其邻居不同。模型选择会在提示词组装前捕获一次提供方/模型/推理强度值,并将其应用到同一步骤的请求;之后发生的并发变更等待下一个步骤。
通过 `agent.ctx` 进行的注册可以遮蔽提示词段或工具,也可以在未发布 setup 期间安装仅适用于该 agent 的拦截器,因此一个 agent 看到的提示词与工具集会与其邻居不同。模型选择会在提示词组装前捕获一次提供方/模型/推理强度值,并将其应用到同一步骤的请求与重试;之后发生的并发变更等待下一个步骤。
#### Token 影响
+3
View File
@@ -33,6 +33,9 @@
"lib/types/**/*.d.ts"
],
"license": "MIT",
"dependencies": {
"zod": "4.4.3"
},
"peerDependencies": {
"@deepseek-ai/dsh-llm": "workspace:*",
"@deepseek-ai/dsh-scope": "workspace:*",
+5 -1
View File
@@ -13,6 +13,7 @@ import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { SessionEvent, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
import { installTurnArchiveAdmission } from './archive-admission.ts'
import { modelSelectionProjectionDefinition } from './model-selection.ts'
import type { Agent } from './types.ts'
import type { AgentOptions, SessionStartSource } from './runtime-types.ts'
@@ -20,7 +21,7 @@ export * from './runtime-types.ts'
export * from './types.ts'
export type * from './projection.ts'
export * from './consumed-work.ts'
export * from './model-selection.ts'
export { currentModelSelection, installModelSwitchNotice, recordDeclaredRoute, recordModelSelection } from './model-selection.ts'
export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts'
export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'
@@ -243,6 +244,8 @@ interface FactorySlot {
* nested lineage that starts an owning-fiber unload is excluded from its own drain.
*/
export class AgentRegistry extends Service {
static inject = ['sessionProjections']
private store = new Map<SessionId, AgentEntry>()
private factory: FactorySlot | undefined
private readonly initiators = new AsyncLocalStorage<Agent | undefined>()
@@ -282,6 +285,7 @@ export class AgentRegistry extends Service {
// Archive admission: the Workspace registry asks what still runs for a
// Session before hiding it; a running turn answers here, for every Agent.
installTurnArchiveAdmission(ctx, sessionId => this.get(sessionId))
ctx.sessionProjections.register(modelSelectionProjectionDefinition)
}
/**
+125 -75
View File
@@ -1,5 +1,8 @@
/**
* Agent-scoped model selection shared by runtime entry points.
* Session model selection: the `modelSelection` projection fold, which the
* Agent registry registers as the only selection state, plus the helpers
* runtime entry points use to read it, record `model/selection` events, and
* announce a route switch to the model.
* @module @deepseek-ai/dsh-agent/model-selection
*/
@@ -7,10 +10,14 @@ import type { Context } from '@deepseek-ai/cordis'
import {
boundContextSummary,
createUserMessage,
type LlmCallConfig,
type ReasoningEffortId,
ReasoningEffortId,
} from '@deepseek-ai/dsh-llm'
import type { PreStepDecision } from './runtime-types.ts'
import type { EpochHeader } from '@deepseek-ai/dsh-session'
import type SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import { z } from 'zod'
import type { AgentOptions, PreStepDecision } from './runtime-types.ts'
import type { Agent, ModelSelection, ModelSelectionProjectionState } from './types.ts'
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
@@ -18,22 +25,113 @@ declare module '@deepseek-ai/dsh-llm' {
}
}
/** Complete provider, model, and optional reasoning effort selected for one live Agent. */
export interface ModelSelection {
/** Registered provider route. */
provider: string
/** Provider-owned model id. */
model: string
/** Adapter-owned reasoning effort, or provider/default behavior when absent. */
reasoningEffort?: ReasoningEffortId
/** Projection reader the selection helpers require. */
type ModelSelectionProjections = Pick<SessionProjectionRegistry, 'stateOf'>
/** Build a selection, omitting an absent effort. */
function toSelection(provider: string, model: string, reasoningEffort: ReasoningEffortId | undefined): ModelSelection {
return { provider, model, ...reasoningEffort === undefined ? {} : { reasoningEffort } }
}
/** Mutable model selection plus the value captured for the current step. */
export interface ModelSelectionRef {
/** Model selected for the next step that enters prompt assembly. */
current: ModelSelection | undefined
/** Selection captured when the current step entered prompt assembly. */
assembled: ModelSelection | undefined
/** Compare two complete selections, treating an absent effort as its own value. */
function sameModelSelection(left: ModelSelection | null, right: ModelSelection | null): boolean {
return left === right || (left !== null && right !== null
&& sameRoute(left, right) && left.reasoningEffort === right.reasoningEffort)
}
/** Selection a recorded request used, omitting an effort the adapter defaulted. */
function selectionOfHeader({ config, adapterDefaults }: EpochHeader): ModelSelection {
return toSelection(config.provider, config.model, adapterDefaults?.reasoningEffort === true ? undefined : config.reasoningEffort)
}
const selectionSchema = z.object({
provider: z.string().min(1),
model: z.string().min(1),
reasoningEffort: z.string().min(1).transform(ReasoningEffortId).exactOptional(),
}).nullable()
/**
* Projection fold registered by the Agent registry: recorded selections become
* the pending choice, and each request header retires a matching one.
*/
export const modelSelectionProjectionDefinition = {
key: 'modelSelection',
stateVersion: 3,
stateSchema: z.object({ lastUsed: selectionSchema, pending: selectionSchema }),
init: () => ({ lastUsed: null, pending: null }),
apply: (state, event) => {
if (event.type === 'model/selection') {
return sameModelSelection(state.pending, event.data) ? state : { ...state, pending: { ...event.data } }
}
if (event.type !== 'request/header') return state
const lastUsed = selectionOfHeader(event.data.header)
const pending = sameModelSelection(state.pending, lastUsed) ? null : state.pending
return sameModelSelection(state.lastUsed, lastUsed) && pending === state.pending ? state : { lastUsed, pending }
},
wire: {
viewSchema: z.object({ lastUsed: selectionSchema, next: selectionSchema }),
view: state => ({ lastUsed: state.lastUsed, next: state.pending ?? state.lastUsed }),
},
} satisfies ProjectionDefinition<'modelSelection', ModelSelectionProjectionState>
/**
* Read the selection the Agent's next model step uses: the latest recorded
* selection, then the latest request's selection, then the route in
* `agent.options`. A step that already started keeps its own snapshot.
* @param projections - registry carrying the `modelSelection` projection.
* @param agent - Agent whose Session is read.
* @returns a detached selection, or undefined when no source names both provider and model.
* @throws when the `modelSelection` projection is not registered.
*/
export function currentModelSelection(
projections: ModelSelectionProjections,
agent: Agent,
): ModelSelection | undefined {
const state = projections.stateOf(agent.session, 'modelSelection')
if (state === undefined) {
throw new Error(`agent "${agent.id}": the modelSelection projection is not registered; mount dsh-session-projection`)
}
const recorded = state.pending ?? state.lastUsed
if (recorded !== null) return { ...recorded }
const { provider, model, reasoningEffort } = agent.options
return provider && model ? toSelection(provider, model, reasoningEffort) : undefined
}
/**
* Record the selection the Agent's next model step uses. A selection equal to
* {@link currentModelSelection} records nothing; callers validate the route first.
* @param projections - registry carrying the `modelSelection` projection.
* @param agent - Agent whose Session receives the `model/selection` event.
* @param selection - complete provider, model, and optional reasoning effort.
*/
export function recordModelSelection(
projections: ModelSelectionProjections,
agent: Agent,
selection: ModelSelection,
): void {
if (sameModelSelection(currentModelSelection(projections, agent) ?? null, selection)) return
agent.session.append('model/selection', toSelection(selection.provider, selection.model, selection.reasoningEffort))
}
/**
* Record a route declared by deployment configuration. The declared provider
* and model replace the current selection; a declaration without an effort
* keeps the current explicit effort for the same route and otherwise leaves
* the adapter default.
* @param projections - registry carrying the `modelSelection` projection.
* @param agent - Agent whose selection is recorded.
* @param declared - configured options; a declaration without both provider and model changes nothing.
*/
export function recordDeclaredRoute(
projections: ModelSelectionProjections,
agent: Agent,
declared: AgentOptions,
): void {
const { provider, model } = declared
if (!provider || !model) return
const current = currentModelSelection(projections, agent)
const inherited = current !== undefined && sameRoute(current, { provider, model }) ? current.reasoningEffort : undefined
recordModelSelection(projections, agent, toSelection(provider, model, declared.reasoningEffort ?? inherited))
}
function sameRoute(left: ModelSelection, right: ModelSelection): boolean {
@@ -61,72 +159,24 @@ function modelSwitchNotice(previous: ModelSelection, selected: ModelSelection) {
}
/**
* Couple one mutable selection to Agent-scoped prompt assembly and request routing.
* Prompt assembly snapshots the selected model before delegating, then applies
* its provider/model pair and effort to request config so a
* concurrent switch takes effect on a later step instead of splitting the two
* surfaces. An absent selected effort clears any inherited effort, restoring
* the selected model's provider/default behavior.
*
* A provider/model change appends a durable user-role notice to the next
* admitted request. It compares the assembled selection with the latest
* request header; effort-only changes and empty no-request decisions add no
* notice. Failure before header persistence repeats the notice on the next
* request.
*
* Append a model-switch notice when the step's fixed route differs from the
* last request header. Effort-only changes and empty no-request decisions add
* no notice; a failure before header persistence repeats it on the next step.
* @param agentCtx - The selected Agent's scoped context.
* @param selection - Mutable selection owned by the calling entry point.
* @returns Disposer for all scoped waterfall listeners.
* @returns Disposer for the scoped pre-step listener.
*/
export function installModelSelection(agentCtx: Context, selection: ModelSelectionRef): () => void {
const disposeAssembly = agentCtx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const selected = selection.current
const assembled = await next()
selection.assembled = selected
if (selected === undefined) return assembled
return {
...assembled,
variables: {
...assembled.variables,
provider: selected.provider,
model: selected.model,
},
}
})
const disposeRequest = agentCtx.on(
'agent/request',
async (_payload, next): Promise<LlmCallConfig> => {
const resolved = await next()
const selected = selection.assembled
if (selected === undefined) return resolved
const { reasoningEffort: _inheritedEffort, ...withoutInheritedEffort } = resolved
return {
...withoutInheritedEffort,
provider: selected.provider,
model: selected.model,
...selected.reasoningEffort === undefined
? {}
: { reasoningEffort: selected.reasoningEffort },
}
},
)
const disposeNotice = agentCtx.on(
export function installModelSwitchNotice(agentCtx: Context): () => void {
return agentCtx.on(
'agent/pre-step',
async ({ agent, messages, signal, step }, next): Promise<PreStepDecision> => {
async ({ agent, messages, signal, step, callConfig }, next): Promise<PreStepDecision> => {
const decision = await next()
if (decision.kind === 'reject' || signal.aborted) return decision
// The loop skips an empty first step and an emptied offered continuation.
if (decision.messages.length === 0 && (step === 1 || messages.length > 0)) return decision
const selected = selection.assembled
const previous = agent.session.requestHeader()?.config
if (selected === undefined || previous === undefined || sameRoute(selected, previous)) return decision
return { ...decision, messages: [...decision.messages, modelSwitchNotice(previous, selected)] }
if (previous === undefined || sameRoute(callConfig, previous)) return decision
return { ...decision, messages: [...decision.messages, modelSwitchNotice(previous, callConfig)] }
},
{ prepend: true },
)
return () => {
disposeAssembly()
disposeRequest()
disposeNotice()
}
}
+9 -5
View File
@@ -19,6 +19,8 @@ declare module '@deepseek-ai/dsh-system-prompt' {
interface AssembleContext {
/** Agent for this assembly; absent on diagnostics. When present, `scope` must identify the same agent. */
agent?: Agent
/** Provider and model fixed for this step; absent outside step preparation. */
callConfig?: Readonly<Pick<LlmCallConfig, 'provider' | 'model'>>
}
}
@@ -29,7 +31,7 @@ export interface AgentOptions {
/** Model id interpreted by the selected provider adapter. */
model?: string
/** Adapter-owned reasoning effort for the selected provider/model route. */
reasoningEffort?: ReasoningEffortId
reasoningEffort?: ReasoningEffortId | undefined
/** Maximum output tokens for each conversation-model request. */
maxTokens?: number
}
@@ -162,7 +164,7 @@ export type AssistantStreamFrame =
declare module './types.ts' {
interface Agent {
/** The provider route and model this agent's requests use. */
/** Initial route and output controls; the route applies only while the Session records no model selection or request. */
readonly options: AgentOptions
/** The live session this agent drives; its log is the durable source of truth. */
readonly session: Session
@@ -314,14 +316,16 @@ declare module '@deepseek-ai/cordis' {
* @param payload.turn - the turn that will own the step.
* @param payload.step - the step proposed by the loop.
* @param payload.signal - the current turn's cancellation signal.
* @param payload.callConfig - provider and model fixed for this step and its retries.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/pre-step'(this: Scoped<Agent>, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>
'agent/pre-step'(this: Scoped<Agent>, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal; callConfig: Readonly<Pick<LlmCallConfig, 'provider' | 'model'>> }, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>
/**
* Replace the frozen call configuration. `await next()` yields the config
* the machine would use (agent options on the first request, the logged
* header afterwards); return a replacement to switch. On step admission,
* the machine would use (the step's model selection with agent options on
* the first request, the logged header's controls afterwards); a
* replacement may adjust controls but must keep the provider and model. On step admission,
* this runs after assembly and `step/start`, before the system prompt and
* accepted user batch are committed. Cancellation here or during subsequent
* `prepareCall()` resolution commits neither. The prepared call capability
+47
View File
@@ -4,6 +4,7 @@
* @module @deepseek-ai/dsh-agent/types
*/
import type { ReasoningEffortId } from '@deepseek-ai/dsh-llm/brand'
import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
// Type-only: the Workspace registry's archive-admission family map this registry merges `turn` into.
import type {} from '@deepseek-ai/dsh-workspace/types'
@@ -86,6 +87,47 @@ export interface TurnBoundaryProjection {
readonly lastTurn: number
}
/** Complete provider, model, and optional reasoning effort chosen for one Session's model steps. */
export interface ModelSelection {
/** Registered provider route. */
provider: string
/** Provider-owned model id. */
model: string
/** Adapter-owned reasoning effort, or provider/default behavior when absent. */
reasoningEffort?: ReasoningEffortId
}
/**
* Durable model-selection fold for one Session. An effort the adapter
* defaulted on a recorded request is omitted, so later requests resolve the
* default again instead of pinning it as a choice.
*/
export interface ModelSelectionProjectionState {
/** Selection used by the latest recorded model request. */
readonly lastUsed: ModelSelection | null
/** Later recorded selection not yet used by a matching model request. */
readonly pending: ModelSelection | null
}
/** Client view of the durable model-selection fold. */
export interface ModelSelectionProjection {
/** Selection used by the latest recorded model request. */
readonly lastUsed: ModelSelection | null
/** Selection the next model step uses; null while the Agent's initial options supply the route. */
readonly next: ModelSelection | null
}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
/** Recorded model selection and the selection used by the latest request. */
modelSelection: ModelSelectionProjectionState
}
interface SessionProjectionMap {
/** Recorded model selection for the next model step and the latest request. */
modelSelection: ModelSelectionProjection
}
}
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
/**
@@ -100,5 +142,10 @@ declare module '@deepseek-ai/dsh-session/types' {
inserted: UserMessage[]
outcome?: 'canceled'
}
/**
* Complete model selection that applies from the next model step until a
* later selection. Log-only: it never enters derived model history.
*/
'model/selection': ModelSelection
}
}
@@ -1,3 +1,4 @@
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { runInNewContext } from 'node:vm'
@@ -15,6 +16,7 @@ async function harness(): Promise<{
dispose: () => Promise<void>
}> {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
const fiber = await ctx.plugin(AgentRegistry)
return {
ctx,
+14
View File
@@ -1,3 +1,4 @@
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context, Service, symbols } from '@deepseek-ai/cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
@@ -41,6 +42,7 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
describe('AgentRegistry', () => {
it('contributes Agent lookup and scoped Context providers while Typert is live', async () => {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
const agentFiber = ctx.plugin(AgentRegistry)
await agentFiber
await ctx.plugin(TypertRegistry)
@@ -69,6 +71,7 @@ describe('AgentRegistry', () => {
it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const lifecycle: string[] = []
ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`))
@@ -88,6 +91,7 @@ describe('AgentRegistry', () => {
it('rejects an agent whose registry and session identities differ', async () => {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const agent = stubAgent('agent-id', { session: Session.create(SessionId('session-id')) })
@@ -98,6 +102,7 @@ describe('AgentRegistry', () => {
it('tracks runtime creator ownership separately from registry order', async () => {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const root = stubAgent('root')
const child = stubAgent('child')
@@ -119,6 +124,7 @@ describe('AgentRegistry', () => {
it('rolls an entry back and pairs a partially delivered creation when a listener throws', async () => {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const lifecycle: string[] = []
ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`))
@@ -132,6 +138,7 @@ describe('AgentRegistry', () => {
it('rolls back asynchronous creation rejection and contains every disposal-listener failure', async () => {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const warnings: string[] = []
const heard: string[] = []
@@ -155,6 +162,7 @@ describe('AgentRegistry', () => {
it('awaits each creation listener before the next listener and registration completion', async () => {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
@@ -185,6 +193,7 @@ describe('AgentRegistry', () => {
it('separates entry from announcement and stale/idempotent detach cannot remove a replacement', async () => {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const lifecycle: string[] = []
ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`))
@@ -209,6 +218,7 @@ describe('AgentRegistry', () => {
it('defers detach requested by a creation listener until that dispatch unwinds', async () => {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const order: string[] = []
const agent = stubAgent('reentrant')
@@ -304,6 +314,7 @@ describe('AgentRegistry factory seam', () => {
it('requires a factory and delegates through the calling context', async () => {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
await expect(ctx.agents.create({ sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
const { factory, calls } = stubFactory()
@@ -323,6 +334,7 @@ describe('AgentRegistry factory seam', () => {
it('keeps the runtime parent in options separately from the caller context', async () => {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const { factory, calls } = stubFactory()
ctx.agents.setFactory(factory)
@@ -337,6 +349,7 @@ describe('AgentRegistry factory seam', () => {
it('rejects a second factory and clears the slot with its owner (HMR)', async () => {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const owner = await ctx.plugin(Object.assign((inner: Context) => {
inner.agents.setFactory(stubFactory().factory)
@@ -349,6 +362,7 @@ describe('AgentRegistry factory seam', () => {
it('canonicalizes an already traced Service before tracing it for the caller', async () => {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const states = new WeakMap<object, string[]>()
class TracedFactory extends Service implements AgentFactory {
@@ -1,3 +1,4 @@
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
@@ -46,6 +47,7 @@ function stop(ctx: Context, sessionId: SessionId): Promise<void> {
describe('Turn archive admission', () => {
it('reports the turn family only while the session\'s Agent is running, ahead of later providers', async () => {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const agent = await liveAgent(ctx, 'busy', 'running')
await liveAgent(ctx, 'quiet', 'idle')
@@ -61,6 +63,7 @@ describe('Turn archive admission', () => {
it('cancels a running turn with the user cause and without keeping the inbox, and leaves the rest alone', async () => {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const running = await liveAgent(ctx, 'stop-me', 'running')
const idle = await liveAgent(ctx, 'already-idle', 'idle')
@@ -74,6 +77,7 @@ describe('Turn archive admission', () => {
it('stops answering once the registry fiber is disposed', async () => {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
const fiber = await ctx.plugin(AgentRegistry)
const agent = await liveAgent(ctx, 'watched', 'running')
expect(await ask(ctx, agent.id)).toEqual([{ kind: 'turn' }])
+159 -143
View File
@@ -1,29 +1,40 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, onTestFinished } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import {
import AgentRegistry, {
agentEvents,
installModelSelection,
currentModelSelection,
installModelSwitchNotice,
recordDeclaredRoute,
recordModelSelection,
type Agent,
type AgentOptions,
type ModelSelection,
type ModelSelectionRef,
} from '../src/index.ts'
import {
createUserMessage,
ReasoningEffortId,
type LlmCallConfig,
type UserMessage,
} from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { createUserMessage, ReasoningEffortId, type UserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, type EpochHeader } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
const INITIAL = { provider: 'alpha', model: 'a0' }
const SIGNAL = new AbortController().signal
const INPUT = createUserMessage({
content: [{ type: 'text', text: 'continue' }],
source: { kind: 'user' },
})
function createAgent(): Agent {
return { session: Session.create(SessionId('model-selection')) } as Agent
async function harness(options: AgentOptions = INITIAL) {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
const fiber = await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(SessionId('model-selection'))
const agent = { id: session.id, session, ctx, options } as Agent
const current = () => currentModelSelection(ctx.sessionProjections, agent)
const record = (selection: ModelSelection) => { recordModelSelection(ctx.sessionProjections, agent, selection) }
const header = (config: ModelSelection, adapterDefaults?: EpochHeader['adapterDefaults']) => session.append('request/header', {
header: { config, ...adapterDefaults === undefined ? {} : { adapterDefaults } }, reason: 'initial',
})
return { ctx, agent, session, fiber, current, record, header }
}
function expectedNotice(from: string, to: string) {
@@ -36,156 +47,161 @@ function expectedNotice(from: string, to: string) {
}
}
async function switchHarness(current: ModelSelection, previous?: ModelSelection) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
const selection: ModelSelectionRef = { current, assembled: undefined }
const dispose = installModelSelection(ctx, selection)
const agent = createAgent()
if (previous !== undefined) {
agent.session.append('request/header', { header: { config: previous }, reason: 'initial' })
}
await ctx.systemPrompt.assemble()
return { agent, ctx, dispose, selection }
}
async function preStep(
ctx: Context,
agent: Agent,
callConfig: ModelSelection,
{
messages = [INPUT],
offered = [INPUT],
step = 1,
signal = SIGNAL,
}: {
messages?: UserMessage[]
offered?: UserMessage[]
step?: number
signal?: AbortSignal
} = {},
messages = [INPUT], offered = [INPUT], step = 1, signal = SIGNAL,
}: { messages?: UserMessage[]; offered?: UserMessage[]; step?: number; signal?: AbortSignal } = {},
) {
return agentEvents(ctx, agent).waterfall(
return agentEvents(agent.ctx, agent).waterfall(
'agent/pre-step',
{ turn: 1, step, messages: offered, signal },
() => Promise.resolve({ kind: 'enter' as const, messages }),
{ turn: 1, step, messages: offered, signal, callConfig },
() => Promise.resolve({ kind: 'enter' as const, messages, startsRequestSeries: true as const }),
)
}
describe('installModelSelection()', () => {
it('snapshots prompt variables and request routing together, then disposes its listeners', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
const selection: ModelSelectionRef = { current: undefined, assembled: undefined }
const dispose = installModelSelection(ctx, selection)
const agent = createAgent()
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
const signal = new AbortController().signal
describe('Session model selection', () => {
it('uses initial options only until a selection or request is recorded and returns detached values', async () => {
const h = await harness({})
expect(h.current()).toBeUndefined()
h.agent.options.provider = 'alpha'
expect(h.current()).toBeUndefined()
h.agent.options.model = 'a0'
expect(h.current()).toEqual(INITIAL)
expect(h.ctx.sessionProjections.snapshot(h.session).values.modelSelection).toEqual({ lastUsed: null, next: null })
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
)).resolves.toBe(seed)
selection.current = {
provider: 'alpha',
model: 'a1',
reasoningEffort: ReasoningEffortId('high'),
}
expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' })
selection.current = { provider: 'beta', model: 'b1' }
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
)).resolves.toEqual({
provider: 'alpha',
model: 'a1',
reasoningEffort: ReasoningEffortId('high'),
temperature: 0.2,
})
expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'beta', model: 'b1' })
const inherited: LlmCallConfig = {
provider: 'alpha',
model: 'a1',
reasoningEffort: ReasoningEffortId('max'),
temperature: 0.2,
}
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(inherited),
)).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 })
dispose()
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', { turn: 2, step: 0, signal }, () => Promise.resolve(seed),
)).resolves.toBe(seed)
await ctx.fiber.dispose()
h.record(INITIAL)
expect(h.session.snapshotEvents()).toEqual([])
const selected = { provider: 'beta', model: 'b1', reasoningEffort: ReasoningEffortId('high') }
h.record(selected)
selected.model = 'mutated'
const read = h.current()!
read.model = 'also mutated'
expect(h.current()).toEqual({ provider: 'beta', model: 'b1', reasoningEffort: 'high' })
h.record(h.current()!)
expect(h.session.snapshotEvents()).toHaveLength(1)
h.agent.options.model = 'other initial model'
expect(h.current()?.model).toBe('b1')
})
it('announces same-provider and cross-provider route changes from the assembled selection', async () => {
const { agent, ctx, dispose, selection } = await switchHarness(
{ provider: 'alpha', model: 'a1' },
{ provider: 'alpha', model: 'a0' },
)
await expect(preStep(ctx, agent)).resolves.toMatchObject({
messages: [INPUT, expectedNotice('a0', 'a1')],
})
it('retires only a matching pending selection and reconstructs it from the log', async () => {
const h = await harness()
h.header(INITIAL)
const selected = { provider: 'beta', model: 'b1', reasoningEffort: ReasoningEffortId('high') }
h.record(selected)
for (const config of [
{ ...selected, provider: 'other' },
{ ...selected, model: 'other' },
{ ...selected, reasoningEffort: ReasoningEffortId('low') },
]) {
h.header(config)
expect(h.ctx.sessionProjections.stateOf(h.session, 'modelSelection')?.pending).toEqual(selected)
}
const restored = { ...h.agent, session: Session.create(SessionId('restored'), h.session.snapshotEvents()) }
expect(currentModelSelection(h.ctx.sessionProjections, restored)).toEqual(selected)
h.header(selected)
expect(h.ctx.sessionProjections.stateOf(h.session, 'modelSelection')).toEqual({ lastUsed: selected, pending: null })
expect(h.current()).toEqual(selected)
const state = h.ctx.sessionProjections.stateOf(h.session, 'modelSelection')
h.header(selected)
expect(h.ctx.sessionProjections.stateOf(h.session, 'modelSelection')).toBe(state)
})
selection.current = { provider: 'beta', model: 'b1' }
await ctx.systemPrompt.assemble()
selection.current = { provider: 'alpha', model: 'a2' }
await expect(preStep(ctx, agent)).resolves.toMatchObject({
messages: [INPUT, expectedNotice('alpha/a0', 'beta/b1')],
})
await ctx.systemPrompt.assemble()
await expect(preStep(ctx, agent)).resolves.toMatchObject({
messages: [INPUT, expectedNotice('a0', 'a2')],
})
it('leaves adapter-defaulted reasoning unselected while retaining explicit effort', async () => {
const h = await harness()
const selected = { provider: 'beta', model: 'b1' }
h.record(selected)
h.header({ ...selected, reasoningEffort: ReasoningEffortId('high') }, { reasoningEffort: true })
expect(h.ctx.sessionProjections.stateOf(h.session, 'modelSelection')).toEqual({ lastUsed: selected, pending: null })
expect(h.current()).toEqual(selected)
expect(h.ctx.sessionProjections.snapshot(h.session).values.modelSelection).toEqual({ lastUsed: selected, next: selected })
h.header({ ...selected, reasoningEffort: ReasoningEffortId('low') })
expect(h.current()).toEqual({ ...selected, reasoningEffort: 'low' })
})
dispose()
await ctx.fiber.dispose()
it('reuses fold state for duplicate recorded selections and unrelated events', async () => {
const h = await harness()
const selected = { provider: 'beta', model: 'b1' }
h.record(selected)
const state = h.ctx.sessionProjections.stateOf(h.session, 'modelSelection')
h.session.append('model/selection', selected)
h.session.append('user/message', INPUT, { surfaceOp: 'append' })
expect(h.ctx.sessionProjections.stateOf(h.session, 'modelSelection')).toBe(state)
})
it('records declared routes, inherits explicit effort only for the same route, and ignores incomplete declarations', async () => {
const h = await harness({})
const declare = (options: AgentOptions) => { recordDeclaredRoute(h.ctx.sessionProjections, h.agent, options) }
declare({ provider: 'alpha' })
declare({ model: 'a0' })
expect(h.current()).toBeUndefined()
declare({ ...INITIAL, reasoningEffort: ReasoningEffortId('high') })
declare(INITIAL)
expect(h.current()).toEqual({ ...INITIAL, reasoningEffort: 'high' })
expect(h.session.snapshotEvents()).toHaveLength(1)
declare({ provider: 'beta', model: 'b1' })
expect(h.current()).toEqual({ provider: 'beta', model: 'b1' })
declare({ provider: 'beta', model: 'b1', reasoningEffort: ReasoningEffortId('low') })
expect(h.current()?.reasoningEffort).toBe('low')
})
it('removes the selection projection when the Agent registry unloads and fails without it', async () => {
const h = await harness()
await h.fiber.dispose()
expect(h.ctx.sessionProjections.snapshot(h.session).values.modelSelection).toBeUndefined()
expect(h.current).toThrow('the modelSelection projection is not registered')
expect(() => { h.record({ provider: 'beta', model: 'b1' }) }).toThrow('the modelSelection projection is not registered')
})
})
describe('installModelSwitchNotice()', () => {
it('announces the fixed step route, preserves request-series declarations, and repeats before a header commits', async () => {
const h = await harness()
h.header(INITIAL)
installModelSwitchNotice(h.ctx)
h.record({ provider: 'alpha', model: 'future' })
const selected = { provider: 'beta', model: 'b1' }
for (let attempt = 0; attempt < 2; attempt += 1) {
await expect(preStep(h.agent, selected)).resolves.toMatchObject({
startsRequestSeries: true,
messages: [INPUT, expectedNotice('alpha/a0', 'beta/b1')],
})
}
h.header(selected)
await expect(preStep(h.agent, selected)).resolves.toMatchObject({ messages: [INPUT] })
await expect(preStep(h.agent, { provider: 'beta', model: 'b2' })).resolves.toMatchObject({
messages: [INPUT, expectedNotice('b1', 'b2')],
})
})
it('does not announce initial, same-route, effort-only, rejected, aborted, or disposed steps', async () => {
const { agent, ctx, dispose, selection } = await switchHarness({ provider: 'alpha', model: 'a0' })
await expect(preStep(ctx, agent)).resolves.toMatchObject({ kind: 'enter', messages: [INPUT] })
agent.session.append('request/header', {
header: { config: { provider: 'alpha', model: 'a0' } }, reason: 'initial',
})
selection.current = {
provider: 'alpha',
model: 'a0',
reasoningEffort: ReasoningEffortId('high'),
}
await ctx.systemPrompt.assemble()
await expect(preStep(ctx, agent)).resolves.toMatchObject({ kind: 'enter', messages: [INPUT] })
selection.current = { provider: 'alpha', model: 'a1' }
await ctx.systemPrompt.assemble()
const rejected = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
{ turn: 1, step: 1, messages: [], signal: SIGNAL },
const h = await harness()
const dispose = installModelSwitchNotice(h.ctx)
await expect(preStep(h.agent, INITIAL)).resolves.toMatchObject({ messages: [INPUT] })
h.header(INITIAL)
await expect(preStep(h.agent, INITIAL)).resolves.toMatchObject({ messages: [INPUT] })
await expect(preStep(h.agent, { ...INITIAL, reasoningEffort: ReasoningEffortId('high') }))
.resolves.toMatchObject({ messages: [INPUT] })
const selected = { provider: 'alpha', model: 'a1' }
await expect(agentEvents(h.ctx, h.agent).waterfall(
'agent/pre-step', { turn: 1, step: 1, messages: [], signal: SIGNAL, callConfig: selected },
() => Promise.resolve({ kind: 'reject' as const }),
)
expect(rejected).toEqual({ kind: 'reject' })
const aborted = new AbortController()
aborted.abort()
await expect(preStep(ctx, agent, { signal: aborted.signal })).resolves.toMatchObject({ messages: [INPUT] })
)).resolves.toEqual({ kind: 'reject' })
await expect(preStep(h.agent, selected, { signal: AbortSignal.abort() })).resolves.toMatchObject({ messages: [INPUT] })
dispose()
await expect(preStep(ctx, agent)).resolves.toMatchObject({ kind: 'enter', messages: [INPUT] })
await ctx.fiber.dispose()
await expect(preStep(h.agent, selected)).resolves.toMatchObject({ messages: [INPUT] })
})
it('preserves empty no-call decisions and announces an empty tool continuation', async () => {
const { agent, ctx } = await switchHarness(
{ provider: 'alpha', model: 'a1' },
{ provider: 'alpha', model: 'a0' },
)
await expect(preStep(ctx, agent, { messages: [] })).resolves.toEqual({ kind: 'enter', messages: [] })
await expect(preStep(ctx, agent, { messages: [], step: 2 })).resolves.toEqual({ kind: 'enter', messages: [] })
await expect(preStep(ctx, agent, { messages: [], offered: [], step: 2 })).resolves.toMatchObject({
messages: [{ source: { summary: 'a0 → a1' } }],
const h = await harness()
h.header(INITIAL)
installModelSwitchNotice(h.ctx)
const selected = { provider: 'alpha', model: 'a1' }
await expect(preStep(h.agent, selected, { messages: [] })).resolves.toMatchObject({ messages: [] })
await expect(preStep(h.agent, selected, { messages: [], step: 2 })).resolves.toMatchObject({ messages: [] })
await expect(preStep(h.agent, selected, { messages: [], offered: [], step: 2 })).resolves.toMatchObject({
messages: [expectedNotice('a0', 'a1')],
})
await ctx.fiber.dispose()
})
})
@@ -12,6 +12,8 @@ const probe = String.raw`
import assert from 'node:assert/strict'
import { Context } from './vendor/cordis/lib/index.js'
import AgentRegistry from './packages/core/agent/lib/index.js'
import SessionProjectionRegistry from './packages/session/session-projection/lib/index.js'
import { turnBoundaryProjectionDefinition } from './packages/core/agent-loop/lib/index.js'
import LocalFileSystem from './packages/fs/fs-local/lib/index.js'
import SystemPrompt from './packages/core/system-prompt/lib/index.js'
import ToolRuntime from './packages/core/tools/lib/index.js'
@@ -26,12 +28,14 @@ ctx.provide('workingDirectory', { ensure: () => Promise.resolve(root) })
try {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalFileSystem, { cwd: root })
ctx.provide('sessionProjections', { stateOf() { return { openTurnStartSeq: 1, lastTurn: 1 } } })
ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
await ctx.plugin(Present, { maxFiles: 2 })
const scope = ctx.plugin(() => {})
const session = Session.create('built-present', [], { version: SESSION_FORMAT_VERSION, id: 'built-present', createdAt: 0, cwd: root, isSeeded: false })
session.append('turn/start', { turn: 1 })
const owner = { id: 'built-present', session, ctx: scope.ctx, options: {}, status: 'idle' }
await ctx.agents.register(owner)
const events = []
@@ -1,3 +1,4 @@
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -11,6 +12,7 @@ const contexts: Context[] = []
async function fixture() {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
async function owner(id: string) {
const fiber = ctx.plugin(() => {})
@@ -1,3 +1,4 @@
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { describe, expect, it } from 'vitest'
import { provideWorkingDirectoryFixture, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
import { Context } from '@deepseek-ai/cordis'
@@ -114,6 +115,7 @@ async function setupBase(jobs: boolean) {
provideWorkingDirectoryFixture(ctx)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TerminalSessionService)
const stub = stubBackend()
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff. Show more