docs: 影子上下文方案在 dsh 上的可行性核实

结论:路线②(注册独立 agent 模式)可行;路线①(劫持原有信息流)
只是看着可行。

路线②可行 —— agentPresets 是一等机制
- ctx.agentPresets.register({id, plugins}) 可由任意插件调用
  (dsh-agent-preset/lib/index.js:18-26)
- preset 子树是独立 scope + 可选 isolate realm
  (dsh-agent-preset-registry/lib/types/mount.js:183-198)
- 已有实证:standard.patch.yml:66-82 的 isolate:{compaction:true}
  group 已经在替换 compaction 服务
- 因此「压缩时机」「不压缩」「压缩前抓原文」全部可控

路线①为什么看着可行
- agent/pre-step 看着能注入,其实注入即落盘:
  loop 随后 session.append("user/message", ...)
  (dsh-agent-loop/lib/index.js:1067-1073),注入内容成为真实历史
- llm/stream 看着能改最终请求,其实 loop 请求深冻结
  (dsh-llm/lib/types/index.d.ts:37-42),mutation throws
- 「不装 compaction-basic」看着能旁路,其实 auto:false 会连
  溢出恢复一起关掉 (dsh-compaction-basic/lib/index.js:827)

关键机制核实
- compaction 自动触发只是两个 ctx.on 监听器
  (dsh-compaction-basic/lib/index.js:839/:861),受 config.auto 控制
- 服务名同 realm 撞名抛错 (cordis/lib/index.js:814),换实现必须 isolate
- surface 支持整段 replace,但被遮蔽节点必须全部列进 sourceEventSeqs
  (dsh-session/lib/index.js:314-332)
- 日志 seq 严格连续 (dsh-session/lib/index.js:426),影子副本只能活在
  dsh 之外
- MessageSourceMap 可 merge 扩展,自定义字段 lossless-JSON 存活但不进
  wire(adapter 只投影 role/content)
- setFactory 独占 (dsh-agent/lib/index.js:431 + agent-loop:1590),
  自建 runner 需弃用 2023 行的 agent-loop

三处与路线无关的硬阻塞:surface 不能插中间、llm/stream 深冻结、
前缀时序只能靠末尾追加。

人天:路线① 12-18(仅 60% 子集);路线② 18-26;混合 22-30。

附完整证据索引(6 组,全部 lib/*.js 实现级核对)。
原文只读,未修改 HologramAgent 仓库。
This commit is contained in:
2026-10-30 09:29:37 +08:00
parent 28d9d5e929
commit 533f801c24

864
docs/shadow-context.md Normal file
View File

@ -0,0 +1,864 @@
# 影子上下文(Shadow Context)方案在 dsh 上的可行性核实
> 状态:**调查,不含实现**
> 目标:核实两条路 —— ①「劫持/外挂 dsh 原有信息流」;②「独立注册一种 agent 模式」
> 宿主:dsh(deepseek-harness)`0.2.1-rc.2`,cordis `4.0.5`
> 取证:只读 `lib/*.d.ts` 权威类型 **并** 核对 `lib/*.js` 真实实现。每条结论标注 `文件:行`。
---
## 结论(先给答案)
| 路线 | 可行? | 一句话 |
|---|---|---|
| **② 注册一种 agent 模式** | ✅ **真正可行,且是官方支持的一等机制** | `agentPresets` 注册表 + `cordis:group` + `isolate`。现有 preset 已经在用 `isolate: {compaction: true}` 挂自己的 compaction(`dsh-web-app/presets/standard.patch.yml:66-82`)。这是 dsh 设计时就留好的口子。 |
| **① 劫持/外挂原有信息流** | ⚠️ **看着可行,实际是三条不同成熟度的路拼起来的** | `agent/pre-step` 能改本轮进模型的消息但**改不了持久面**(loop 随后 `session.append`,`dsh-agent-loop/lib/index.js:1067-1073`);`llm/stream` 能做最终请求改写但它收到的 `GenerateOptions` 对 loop 请求**是深冻结的**(`dsh-llm/lib/types/index.d.ts:36-42`);真正干净的「替换 compaction」只能走 ②(isolate realm),单独做会撞 `service "compaction" has been registered`(`cordis/lib/index.js:814`)。 |
**推荐**:走 ②。它是唯一能同时满足「不碰 dsh 内核」「不伪造持久面」「语义可解释」的路。
**硬阻塞**(两条,与路线无关):
1. **`surface replace` 只能替换已存在的 surface 节点,且必须覆盖全部被遮蔽节点**(`dsh-session/lib/index.js:314-332` 的 `assertSourceEventReferences`)。所以「把某段历史整体换成影子版本」在 surface 上可行,但**换掉的那些节点必须逐一列进 `sourceEventSeqs`**,且被换掉的事件仍在 append-only 日志里。
2. **日志 seq 必须严格连续**(`dsh-session/lib/index.js:426`)。任何「删除/重排事件」的想法都会在 replay 时炸。影子副本**只能完全活在 dsh 之外**,不能写回日志。
---
## 1. 用户输入 → 模型调用的完整链路
### 1.1 六个环节(按执行顺序)
```
用户输入
↓ ① inbox 暂存
agent.inbox / ReactLoopInbox
↓ ② claim + 组装
preStep() ← systemPrompt.assemble() 在这里
↓ ③ waterfall: agent/pre-step ← 插件可改 decision.messages
↓ ④ turn/start + step/start ← 写日志(边界事件)
↓ ⑤ step(decision)
├─ prepareRequest() ← waterfall: agent/request(只能换 provider/model)
├─ systemPrompt.project() ← 决定 system message 怎么进 surface
├─ session.append("user/message", ...) ← ★ decision.messages 在这里落盘
├─ buildRequest() ← session.deriveMessages() 派生 LLM 消息
└─ llm.stream(request) ← waterfall: llm/stream(最终请求)
```
### 1.2 逐环节的代码位置与读写边界
| # | 环节 | 位置 | 插件能做什么 | 能改持久面吗 |
|---|---|---|---|---|
| ① | inbox 暂存 | `dsh-agent-loop/lib/index.js:907` `this.inbox.claim(target, position.turn)` | 只能通过 `agent.followup/steer/inject` 间接写 | ❌ |
| ② | prompt 组装 | `dsh-agent-loop/lib/index.js:907` `await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))` | `systemPrompt.section/context/tools/variable` 注册;`system-prompt/assemble` waterfall 可改整个 `PromptAssembly` | ❌(只影响本次组装) |
| ③ | **pre-step waterfall** | `dsh-agent-loop/lib/index.js:909-919` | **可整段替换 `decision.messages`**,或 `{kind:'reject'}` 拒绝本步 | ⚠️ 不能直接改日志,但**返回值会被 loop 落盘** |
| ④ | 边界事件 | `dsh-agent-loop/lib/index.js:987` `session.append("turn/start", {turn})` / `:997` `step/start` | 无钩子 | — |
| ⑤a | `agent/request` | `dsh-agent-loop/lib/index.js:1197-1201` | waterfall 返回 `LlmCallConfig`,但**只有 `provider/model/reasoningEffort/temperature/maxTokens/stop`**(`dsh-llm/lib/types/call-config.d.ts:22-26`),**没有 messages** | ❌(明确写了「this waterfall cannot mutate messages」) |
| ⑤b | **surface 落盘** | `dsh-agent-loop/lib/index.js:1067-1073` | 无钩子。`decision.messages` 逐个 `session.append("user/message", ..., {surfaceOp:"append"})` | ✅ **但这是 loop 干的,不是插件** |
| ⑤c | 派生 LLM 消息 | `dsh-agent-loop/lib/index.js:1274` `const boundaryMessages = session.deriveMessages()` | **只读 surface,不看 inbox,也不看你 plugin 的内部状态** | — |
| ⑤d | **llm/stream** | `dsh-llm/lib/index.js:2397` `this.ctx.waterfall(this, "llm/stream", options, () => this.adapterStream(...))` | **可 yield 自己的 chunk 短路,或包一层** | ❌(不改日志) |
### 1.3 ★ 关键发现:pre-step 的改动**会被持久化**
这一点决定了「影子上下文」能不能不落痕迹。看 `step()` 的落盘段(`dsh-agent-loop/lib/index.js:1066-1073`):
```js
let contextAdmitted = false;
if (firstAttempt) for (const message of decision.messages) if (message.source.kind === "runtime-context") {
if (context !== void 0 && !contextAdmitted) {
this.session.append("user/message", context, { surfaceOp: "append" });
contextAdmitted = true;
}
} else this.session.append("user/message", message, { surfaceOp: "append" });
if (context !== void 0 && !contextAdmitted) this.session.append("user/message", context, { surfaceOp: "append" });
```
⇒ **你在 `agent/pre-step` 里返回的每一条 message,都会变成 `user/message` 事件永久写进会话日志**,
并进入下一次 `deriveMessages()`。
所以「影子上下文」如果靠 pre-step 注入,**注入的内容就成了真实历史**——它不是影子,是正文。
要做到「只在本次请求存在、不留痕」,pre-step 是错的钩子。
### 1.4 pre-step 能否「删掉」消息?
能,但有代价。`PreStepDecision.messages` 是**替换**语义(`dsh-agent/lib/types/runtime-types.d.ts:92-99`):
```ts
export type PreStepDecision = { kind: 'reject' } | {
kind: 'enter';
messages: UserMessage[];
startsRequestSeries?: true;
};
```
`messages` 是「进入本步的消息列表」的完整替换。**但注意**:它替换的是 **inbox 里刚 claim 出来的那几条**
(`claimed = this.inbox.claim(...)`,`index.js:907`),**不是整个 surface 历史**。
历史早已在之前的 step 里 append 完了,改不了。
⇒ pre-step 的能力边界:**能改「本轮新进的消息」,不能改「已经进过日志的历史」**。
想改历史只能走 surface replace(§3),而那就是 compaction 干的事。
### 1.5 全部可介入的 waterfall/hook 点(完整清单)
| 事件 | mode | 位置(类型定义) | 能否改消息 | 能否改请求 |
|---|---|---|---|---|
| `system-prompt/assemble` | waterfall | `dsh-system-prompt/lib/types/index.d.ts:27` | 间接(改 sections) | ❌ |
| `agent/pre-step` | waterfall | `dsh-agent/lib/types/runtime-types.d.ts:304` | ✅ 本轮新消息 | ❌ |
| `agent/request` | waterfall | 同上 :327 | ❌ | ✅ 但只有 config 标量 |
| `agent/request-error` | waterfall | 同上 :348 | ❌ | 只能 `{kind:'retry'}` |
| `agent/turn-stopping` | serial | 同上 :387 | 只能 `agent.steer()` 追加 | ❌ |
| `agent/created` / `disposed` / `status` | serial / emit / emit | 同上 :227 / :240 / :252 | ❌ | ❌ |
| `agent/inbox/inserted` / `claimed` / `discarded` | emit | 同上 :263 / :277 / :289 | ❌(只读通知) | ❌ |
| `agent/assistant-stream` | emit | 同上 :366 | ❌ | ❌ |
| `tools/pre-execute` / `execute` / `post-execute` / `result` | waterfall×3 / emit | `dsh-tools/lib/types/index.d.ts:47 / :58 / :70 / :92` | ❌ | ❌ |
| **`llm/stream`** | **waterfall** | `dsh-llm/lib/types/index.d.ts:45` | ❌ | ✅ **最深**,但见 §1.6 |
| `session/event` | emit | `dsh-session/lib/types/index.d.ts:61` | ❌ | ❌ |
| `session/flush` | parallel | 同上 :69 | ❌ | ❌ |
**没有**「上下文即将超限」事件,**没有**「请求消息即将发送」的可改写 waterfall(除了 `llm/stream`)。
### 1.6 `llm/stream` 的读写边界(最深的口子,但有硬约束)
签名(`dsh-llm/lib/types/index.d.ts:45`):
```ts
'llm/stream'(this: LlmRuntime, options: GenerateOptions,
next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>
```
契约原文(同文件 `:36-42`):
> `options` - the full request. A LOOP-built request carries the process-local
> `markAgentLoopRequest` identity and **arrives deep-frozen (mutation throws)**:
> its content is a pure function of the session log (the reconstructability
> Agent Note), **so listeners read it, never rewrite it.**
⇒ **对 loop 发起的请求,`options` 是深冻结的,改不了 `messages`**(改会抛)。
你能做的只有两件事:
1. **短路**:不调 `next()`,自己 yield chunk 流(伪造回复)—— 但那样 assistant message 会进日志,且内容与真实模型无关。
2. **包一层**:`yield* next()` 前后做观测/计时(`dsh-session-checkpoint-policy/lib/index.js:61-65` 就是这么用的)。
**要真正改请求**,必须自己**重新拼一个 `GenerateOptions`** 然后交给 `ctx.llm.stream(shadowOptions)`——
但那是**另一次模型调用**,它产生的 assistant message 会以新的 `assistant/message` 事件进日志。
即:**你没法让 dsh「用影子上下文调模型,然后把结果当成这次的真实回复」而不留痕**,
除非你自己接管 ⑤a~⑤d 全部(= 自己实现 runner,见 §5)。
---
## 2. `compactSurfaceRegion` / `CompactionTrigger` 的精确签名与替换可行性
### 2.1 精确签名
**服务抽象**(`dsh-compaction/lib/types/index.d.ts`):
```ts
export type CompactionTrigger = 'pressure' | 'context-overflow'; // :25
export declare abstract class CompactionEngine extends Service { // :~100
abstract compactIfNeeded(agent: CompactionAgentContext,
trigger: CompactionTrigger,
signal: AbortSignal): Promise<CompactionResult | null>;
abstract compactNow(agent, signal, sourceCommandId?): Promise<CompactionResult | null>;
abstract compactRegion(start: SessionSeq, end: SessionSeq,
agent: Agent, signal?: AbortSignal): Promise<CompactionResult>;
}
```
服务注册名由基类构造函数固定(`dsh-compaction/lib/index.js:169-173`):
```js
var CompactionEngine = class extends Service {
constructor(ctx) {
super(ctx, "compaction");
}
};
```
**实现函数**(`dsh-compaction-basic/lib/types/region.d.ts`):
```ts
export declare function compactSurfaceRegion(
dependencies: RegionDependencies,
session: Session,
start: SessionSeq,
end: SessionSeq,
agent: Agent,
options: CompactionTransactionOptions,
signal?: AbortSignal
): Promise<CompactionResult>;
```
**替换体的落盘**(`dsh-compaction-basic/lib/index.js:648-659`):
```js
session.append("user/message", checkpointMessage, {
surfaceOp: { op: "replace", startSeq: start, endSeq: end },
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs]
});
```
### 2.2 调用点:它其实只是两个事件监听器
这是最重要的发现。`compactSurfaceRegion` 不是被谁「调用」的——
它由 `BasicCompactionEngine` 的**自动监听器**触发(`dsh-compaction-basic/lib/index.js:834-879`):
```js
_registerAutomaticCompaction() {
const { ctx } = this;
ctx.on("agent/pre-step", async ({ agent, signal }, next) => { // :839
if (!signal.aborted) try {
const result = await this.compactIfNeeded(agent, "pressure", signal);
...
} catch (error) { ... }
return next();
});
...
ctx.on("agent/request-error", async ({ agent, failure, signal }, next) => { // :861
if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next();
...
result = await this.compactIfNeeded(agent, "context-overflow", signal);
...
return { kind: "retry" };
});
}
```
而注册本身受一个开关控制(`index.js:827`):
```js
if (this.config.auto) this._registerAutomaticCompaction();
```
`auto` 的 schema 与默认值(`index.js:817`、`:85`):
```js
auto: z.boolean() // :817
auto: config.auto ?? true // :85
```
### 2.3 ★ 能让 compaction「完全不发生」吗?—— 能,三条路
**路 A:`config.auto = false`(最干净,零代码)**
```yaml
- id: compaction-basic
name: '@deepseek-ai/dsh-compaction-basic'
config:
auto: false
```
`auto: false` ⇒ 不注册那两个监听器 ⇒ `compactIfNeeded` 永远不被自动调用。
`compactNow` 仍可被 `/compact` 命令手动触发(`dsh-command-compact/lib/index.js:55`),
但只要不装 `command-compact` 就完全静默。
⚠️ **注意**:`auto:false` 只关掉**自动**触发。`context-overflow` 那条也关了 ⇒
上游报 context_full 时会**直接失败**(`dsh-agent-loop/lib/index.js:1146-1155` 的
`agent/request-error` 若无人返回 `{kind:'retry'}` 就 `throw new LlmError`)。
所以 `auto:false` 必须配套一个**自己的溢出恢复**,否则长会话会硬失败。
**路 B:不装 `dsh-compaction-basic`,装自己的 `CompactionEngine` 子类**
服务名冲突会抛(`cordis/lib/index.js:800-823` 的 `provide`):
```js
if (this.store[key]) throw new Error(`service "${name}" has been registered at <${this.store[key].fiber.name}>`);
```
所以在**同一个 realm** 里换实现是**不可能**的——必须先 `isolate`。
这正是 §5 的 preset 机制干的事。
**路 C:在 isolate realm 里挂自己的 compaction**
已实测存在先例(`dsh-web-app/presets/standard.patch.yml:66-82`):
```yaml
- id: compaction
name: cordis:group
group: true
isolate:
compaction: true
toolResultPruner: true
config:
- id: compaction-basic
name: '@deepseek-ai/dsh-compaction-basic'
- id: command-compact
name: '@deepseek-ai/dsh-command-compact'
- id: tool-result-pruner
name: '@deepseek-ai/dsh-compaction-tool-result-pruner'
```
`isolate: {compaction: true}` ⇒ 这个 group 下的 `ctx.compaction` 解析到**realm 私有 symbol**
(`cordis/lib/index.js:1723-1727` 的 `isolate(name, label)`;`mount.d.ts:44-58` 的 `leakedServices` 注释解释了机制)。
把 `compaction-basic` 换成你自己的插件,就得到**完全可控的压缩策略**——
包括「永不压缩」「压缩前先把整段写成影子副本」这类行为。
### 2.4 结论:能否替换/旁路?
| 目标 | 可行 | 手段 |
|---|---|---|
| 让自动 compaction 完全不发生 | ✅ | `config.auto: false`(需自备溢出恢复) |
| 换成自己的压缩实现 | ✅ | isolate realm + 挂自己的 `CompactionEngine` 子类(路 C) |
| 在**现有** compaction 之外再旁路一份 | ✅ | `ctx.on('agent/pre-step', ..., {prepend:true})` 抢在前面做影子快照 |
| 阻止 `/compact` 手动命令 | ✅ | 不装 `dsh-command-compact` |
| **改 `compactSurfaceRegion` 内部行为** | ❌ | 它是模块内 `function`,未导出为可替换点;只能整体换 `CompactionEngine` |
---
## 3. surface 是否支持整段 replace / 重写?
### 3.1 支持,且是官方一等机制
`SessionSurface` 只有三个只读字段(`dsh-session/lib/types/surface.d.ts`):
```ts
export interface SessionSurface {
readonly nodes: readonly SessionSeq[];
readonly replaceGeneration: number;
readonly contentGeneration: number;
}
```
replace 的写法(`dsh-compaction-basic/lib/index.js:648-659` 已给出实证):
```js
session.append("user/message", checkpointMessage, {
surfaceOp: { op: "replace", startSeq: start, endSeq: end },
sourceEventSeqs: [/* 必须覆盖全部被遮蔽节点 */]
});
```
落盘后的 fold 动作(`dsh-session/lib/index.js:461-476`):
```js
else if (plan?.kind === "replace") {
state.nodes.splice(plan.startIdx, plan.endIdx - plan.startIdx + 1, plan.seq);
state.replaceGeneration += 1;
state.contentGeneration += 1;
}
```
⇒ **一段连续 surface 节点被「一个」新节点替换**。这就是「整段 replace」。
### 3.2 ★ 四条硬约束(必须逐条满足,否则抛错)
| # | 约束 | 代码位置 | 后果 |
|---|---|---|---|
| 1 | `startSeq`/`endSeq` **必须指向更早的事件** | `dsh-session/lib/index.js:358` | `surface replace at seq N: startSeq and endSeq must reference earlier events` |
| 2 | 两端都**必须仍在当前 surface 上** | `:364-369` `replacementRange` | `start seq ... not found in surface` |
| 3 | `startSeq` 索引必须 ≤ `endSeq` 索引 | `:369` | `is after end seq` |
| 4 | `sourceEventSeqs` **必须包含每一个被遮蔽的节点** | `:331-332` | `sourceEventSeqs must include every shadowed surface node; missing ...` |
外加一条特殊保护(`:419-423`):
```js
function assertSystemHeadRewrite(event, state, startIdx, shadowedSeqs, events, baseSeq) {
if (startIdx !== 0) return;
if (events[state.nodes[0] - baseSeq]?.type !== "system/message") return;
if (event.type !== "system/message" || shadowedSeqs.length !== 1)
throw new Error("surface replace: node 0 holds the system prompt and may be rewritten only by a system/message over exactly that node");
}
```
⇒ **node 0(system prompt)只能用 `system/message` 一对一替换**,不能被 compaction 那种整段 replace 吞掉。
### 3.3 重写后 turn 序号 / 消息 id 会不会断链?
**不会断。** 三条证据:
**① turn/step 是事件数据里的字段,不是 surface 位置**
`turn/start` / `step/start` / `turn/end` 不是 surface event
(`dsh-session/lib/index.js:157-160` 的 `SURFACE_EVENT_TYPES` 只含
`system/message` / `developer/message` / `user/message` / `assistant/message` / `tool/result`)。
它们留在日志里,`turn` / `step` 数字在 `event.data.turn` 中,replace 不动它们。
**② 消息 id 是消息自己的字段,replace 生成的是新消息、新 id**
被 replace 掉的原消息 id 随事件留在日志里;新 checkpoint message 是新 id。
两者的对应关系靠 `sourceEventSeqs` 记录(`:314-332`)。
**③ 依赖「序号连续」的地方只有 replay**
`planSurfaceEvent` 第一件事(`:426`):
```js
if (event.seq !== expectedSeq) throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`);
```
这是**日志事件的 seq 连续性**(每次 append +1),不是 surface 位置连续性。
replace 只改 `state.nodes`,不改日志 seq ⇒ 不影响。
**结论**:surface replace 安全,**前提是你只 append 新事件、从不动日志数组**。
任何「删日志」「重排日志」的黑魔法都会在 resume/replay 时炸。
### 3.4 但有一个代价:被替换的原文「模型看不到了」
replace 之后 `deriveMessages()` 从 `surface.nodes` 取消息(`dsh-session/lib/index.js:1672-1687`),
被遮蔽的节点的 seq 已不在 `nodes` 里 ⇒ **它们的消息不再进模型请求**。
原文仍在日志(`session.snapshotEvents()` 可读),但**模型视角永久失明**。
这正好是影子上下文要解决的问题:**原文搬到外面的影子副本,surface 留一个指针/摘要**。
---
## 4. `MessageBase` 无 timestamp,能否挂自定义字段并活到下一轮?
### 4.1 三条互补的机制,都能用
**机制 A:`MessageSourceMap` 是 declaration-merge 可扩展的**
`dsh-llm/lib/types/message.d.ts:101`:
```ts
export interface MessageSourceMap {
user: { kind: 'user' };
model: ModelMessageSource;
tool: ToolMessageSource;
'system-prompt': SystemPromptMessageSource;
}
```
注释(`:96-100`)明确写了这是 **merge-extensible sum type**,并给出扩展方式。
已有 11 个包这么干(`dsh-time-context`、`dsh-goal`、`dsh-compaction`、
`dsh-agent-instructions`、`experimental-agent-team`…):
```ts
// dsh-time-context/lib/types/index.d.ts
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
'time-context': { kind: 'time-context' } & ContextFormed;
}
}
```
⇒ **插件可以声明自己的 `kind`,并在 `source` 里带任意 JSON 字段。**
**机制 B:session 的校验对未知 `kind` 是放行的**
`dsh-session/lib/index.js:1193-1219` `assertMessageEventShape`:
```js
if (typeof source !== "object" || source === null
|| typeof source["kind"] !== "string" || source["kind"] === "")
throw new Error(`${subject} message has invalid source`);
...
if (type === "system/message") { /* 必须是 system-prompt */ }
if (type === "assistant/message") { /* 必须是 model */ }
if (type !== "tool/result") return; // ★ user/message 到此就放行
if (sourceRecord["kind"] !== "tool" || ...) throw ...
```
⇒ **`user/message` 的 `source.kind` 可以是任意非空字符串**,附加字段不被校验也不被剥离。
**机制 C:`ContextForm` 已经预留 `'recall'`**
`dsh-llm/lib/types/message.d.ts:46-58`:
```ts
export type ContextForm =
'instructions' | 'catalog' | 'snapshot' | 'notice' | 'relay' | 'recall';
```
`:92` 对应 `{ readonly form: 'recall' }`,注释是
「Material lifted out of another session's log, possibly reduced on the way in.」
—— **dsh 自己就为「从别处搬来的内容」预留了语义槽**。
### 4.2 这些字段会活到下一轮吗?—— 会,且不会被发给模型
**存活链路**(全部只读,不丢字段):
1. `session.append("user/message", message, ...)` ⇒ 事件进日志,
`data` 做 **lossless-JSON 快照**(`dsh-session/lib/types/index.d.ts` 的 `append` 注释:
「returns the logged event — ... the SNAPSHOT of `data` that entered the log」)
2. resume/replay ⇒ `Session.fromRestore(...)` 读回同一份 `data`,
`assertMessageEventShape` 对 user 消息只校验 `id/role/kind非空/content数组` ⇒ **自定义字段完整保留**
3. `deriveMessages()` ⇒ `deriveEventMessage(event)` 投影成 `Message`,
`source` 原样带上
4. **发给模型前被丢掉**:adapter 只读 `role` + `content`
(`dsh-llm-deepseek/lib/index.js:1676-1690` 的 user 分支只看 `message.content`;
`:1630-1650` 的 developer 分支同理)。
`source` **不进 wire**。
⇒ **自定义字段:本地永生,模型不可见。** 这正是挂「影子副本指针」需要的性质。
### 4.3 但「自定义 timestamp 字段」不等于「surface 有时序」
这是最容易误判的一点。你能挂 `source.ts: 1700000000000`,
但**surface 本身没有时间轴概念**:
- `SessionSurface.nodes` 是 `readonly SessionSeq[]`(`surface.d.ts`),**有序数组,不是时间轴**
- replace 只能替换**已存在的连续区间**(§3.2 约束 1/2:两端必须仍在 surface 上)
- **没有任何 API 能「按时间戳插到历史中间」**
⇒ 「在外面维护一份带时序的影子副本」**完全可行**,因为那是**你自己的数据结构**,
dsh 根本不参与。但「把影子副本按原时序塞回 dsh 的 surface」**不可行**——
最多做到「在末尾 append 一条 `source.kind='shadow-context', form='recall'` 的消息,
正文里写清每条的真实时间」。
### 4.4 结论
| 问题 | 答案 |
|---|---|
| 能否挂自定义字段到 message | ✅ `MessageSourceMap` declaration merging + 任意 JSON |
| 能否活到下一轮(含 resume) | ✅ lossless-JSON 快照 + 未知 kind 放行 |
| 会不会被发给模型 | ❌ `source` 不进 wire,adapter 只投影 `role`/`content` |
| 能否靠它实现「面内时序」 | ❌ surface 无时间轴,replace 不能插中间 |
| 能否在外面自己维护带时序影子副本 | ✅ 完全可行,与 dsh 解耦 |
---
## 5. dsh 有没有「注册一种 agent 模式/策略」的扩展点?
### 5.1 有,而且是**一等机制**:`agentPresets`
**服务**(`dsh-agent-preset-registry/lib/types/index.d.ts:26-29`):
```ts
declare module '@deepseek-ai/cordis' {
interface Context {
agentPresets: AgentPresetRegistry;
}
}
```
**注册接口**(同文件):
```ts
register(definition: PresetDefinition): Promise<() => Promise<void>>;
mount(ctx: Context, id?: string): Promise<AgentPreset>;
recompose(ctx: Context, id: string): Promise<AgentPreset>;
serviceFor<K extends keyof Context>(agent: { ctx: Context }, name: K): Context[K] | undefined;
```
**定义形态**(`dsh-agent-preset-registry/lib/types/definition.d.ts`):
```ts
export interface PresetDefinition {
readonly id: string;
readonly name?: string;
readonly description?: string;
readonly order?: number;
readonly plugins: readonly (Omit<EntryOptions, 'id'|'disabled'> & { ... })[];
}
```
即:**一个 preset = 一个 id + 一棵子插件树(Cordis entry list)**。
**插件如何注册自己的 preset**(`dsh-agent-preset/lib/index.js:18-26`):
```js
static inject = ["agentPresets"];
static Config = z.object({ id: z.string().required(), ..., plugins: z.array(z.any()).required() });
async *[Service.init]() {
yield await this.ctx.agentPresets.register(this.config);
}
```
⇒ **任何插件都能 `ctx.agentPresets.register({id, plugins})` 注册一种 agent 模式。**
### 5.2 为什么这正好是「影子上下文」需要的口子
preset 的子树是**独立 scope + 可选 isolate realm**(`mount.d.ts:170-198`):
```js
export async function mountPreset(ctx, id, plugins) {
if (scopeOf(ctx) === undefined) throw new Error('agent-preset: mounting requires a scope');
await ctx.fiber.await();
const tree = new PresetTree(ctx);
ctx.effect(() => () => { tree.root.stop(); }, 'agent-preset.tree');
await tree.root.update(prepareProfileEntries(ctx, plugins, ctx.baseUrl));
const audit = await auditRows(tree);
const leaked = leakedServices(ctx, ctx.fiber);
if (audit.failed.length > 0) throw new Error(audit.failed.join('\n'));
if (leaked.length > 0) throw new Error(`Preset services require isolate realms: ${leaked.join(', ')}`);
...
}
```
绑定到 agent 的 scope(`index.js:271-279`):
```js
join(ctx, key, generation) {
const binding = { parent: bindScopeParent(key, generation.key), generation };
...
}
```
⇒ preset 是 agent 的**父 scope**。agent 内所有 `ctx.xxx` 解析时,
先在 preset realm 找,再回落到 root。
**已实测的先例**:`dsh-web-app/presets/standard.patch.yml:66-82` 的
`isolate: {compaction: true, toolResultPruner: true}` group——
preset 已经在替换 compaction 服务了。我们只要把那个 group 里的
`dsh-compaction-basic` 换成自己的插件即可。
### 5.3 那「注册 agent runner 本身」呢?—— 不行,是独占的
`AgentRegistry.setFactory`(`dsh-agent/lib/index.js:429-437`):
```js
setFactory(factory) {
return this.ctx.effect(() => {
if (this.factory !== void 0) throw new Error("an agent factory is already registered");
...
});
}
```
而 `dsh-agent-loop` 在构造时**无条件抢占**(`dsh-agent-loop/lib/index.js:1590`):
```js
ctx.effect(() => ctx.agents.setFactory(this), "agentLoop.setFactory()");
```
⇒ **全局只有一个 agent factory,且被 `dsh-agent-loop` 占了。**
替换它 = 不用 `dsh-agent-loop`(整套 turn/step/tool/compaction 都得自己写)。**不建议。**
### 5.4 「自己包一个 runner」这条路(替代方案,代价高)
理论上可行:不装 `dsh-agent-loop`,自己实现 `AgentFactory` 并 `ctx.agents.setFactory(myFactory)`。
`Agent` 接口是公开的(`dsh-agent/lib/types/runtime-types.d.ts` 的 `declare module './types.ts'`
里有 `session` / `inbox` / `send` / `steer` / `runMaintenance` / `whenIdle` 等)。
代价:要自己实现
- turn/step 状态机(`turn/start`/`step/start`/`turn/end` 事件形状)
- tool call 调度(`executeToolCalls` 的并行/独占/abort 语义)
- system prompt 投影(`SystemPromptProjection`)
- runtime context 投影(`RuntimeContextProjection`)
- 请求组装 + `markAgentLoopRequest` + 深冻结
- 断线恢复 / resume / fork
`dsh-agent-loop/lib/index.js` 是 **2023 行**。这不是「包一层」,是「重写一个」。
### 5.5 最接近的替代:preset 里换掉 3 个服务 + 加 1 个监听器
比起重写 runner,preset 内可以:
| 想控制的东西 | preset 内怎么换 |
|---|---|
| 上下文压缩策略 | `isolate: {compaction: true}` + 挂自己的 `CompactionEngine` 子类 |
| 消息注入时机 | `ctx.on('agent/pre-step', ..., {prepend: true})`(preset 内注册,agent-scoped) |
| 系统提示词 | `ctx.systemPrompt.section({complete: true, ...})`(注意 `complete` 会压掉其他 section) |
| 工具集 | `ctx.tools.register` + `ctx.tools.restrict` |
| 自己的持久化 | `super(ctx, 'shadowContext')` 注册一个新服务(preset 的 isolate realm 里) |
**这四件事合起来,就是一个「影子上下文 agent 模式」的完整实现**,且全是官方扩展点。
---
## 6. 两条路的实现代价与风险
### 6.1 路线 ①:劫持/外挂原有信息流
**形态**:在 root(或 agent scope)注册一堆 `ctx.on(...)`,试图在不替换任何服务的前提下
「在 dsh 的信息流旁边插一份影子副本,并让模型看到影子版本」。
#### 可行的子集(60%)
| 能力 | 手段 | 代价 |
|---|---|---|
| 旁路观测每条消息 | `session/event` emit | 0(只读) |
| 在发送前注入影子片段 | `agent/pre-step` 返回追加的 message | 低,**但会落盘成真实历史** |
| 拦最终请求做观测/计时 | `llm/stream` 包 `next()` | 低 |
| 把历史写成影子副本 | 自己监听 `session/event` 维护 side store | 中(要处理 resume/replay 重建) |
#### 挂不上的部分(40%,且是核心)
| 需求 | 为什么挂不上 |
|---|---|
| 让模型看到「影子版本的历史」而不改日志 | loop 只从 `session.deriveMessages()` 取消息(`dsh-agent-loop/lib/index.js:1274`),它读的是 surface;你要改 surface 就得 replace(= compaction 的活,需要 `ctx.compaction` 之外的手段) |
| 改写 loop 请求的 messages | `llm/stream` 收到的 loop 请求**深冻结**(`dsh-llm/lib/types/index.d.ts:38-40`),改会抛 |
| 替换 compaction 而不 isolate | `provide` 撞名抛错(`cordis/lib/index.js:814`) |
| 控制「什么时候压缩」 | 只能通过 `config.auto` 开关,无法给现有实现喂策略(`CompactionTrigger` 只有两个值,无 query) |
⇒ 路线 ① **单独做只能到「旁路观测 + 追加注入」**,拿不到「模型看影子版本」。
#### 人天估算:**12–18 人天**(做到 60% 子集)
- side store + resume 重建:4–6
- `session/event` 增量维护 + 时序:3–4
- pre-step 注入 + 去重:2–3
- `llm/stream` 观测层:2–3
- 判据:1–2
#### 风险
- **高**:注入内容永久污染日志(§1.3),无法撤销
- **高**:`llm/stream` 深冻结会在上线后才暴露(类型上 `GenerateOptions` 看起来可变,运行时冻结)
- **中**:`agent/pre-step` 是 `prepend: true` 时与其他 listener(如 compaction)的顺序耦合,dsh 不保证顺序稳定
### 6.2 路线 ②:注册独立 agent 模式(preset)
**形态**:插件 `ctx.agentPresets.register({id:'shadow', plugins:[...]})`,
子树内 `isolate: {compaction: true}` 挂自己的压缩器 + 自己的 `shadowContext` 服务。
#### 能拿到的(90%)
| 能力 | 手段 |
|---|---|
| 完全控制压缩时机 | 自己的 `CompactionEngine` 子类:`compactIfNeeded` 里先写影子副本再决定是否 replace |
| 完全控制「不压缩」 | 子类 `compactIfNeeded` 直接 `return null` |
| 自己的持久化 | `super(ctx, 'shadowContext')` + `node:sqlite`/`ctx.storage.domain` |
| 在压缩前抓取原文 | `agent/pre-step`(prepend)或直接在自己的 `compactIfNeeded` 里读 `session.snapshotEvents()` |
| 影子指针挂在消息上 | `MessageSourceMap` 扩展 + `source.kind='shadow-*'`(§4) |
| 按模式隔离 | 只有选中该 preset 的 agent 生效,其他 agent 不受影响 |
#### 挂不上的(10%)
| 需求 | 为什么 |
|---|---|
| 让 surface「插回历史中间」 | §3.2 约束:replace 只能换已存在区间,不能插 |
| 改写 loop 的最终请求 | `llm/stream` 深冻结(除非自己在 preset 内短路重发) |
| 替换 agent factory | `setFactory` 独占(§5.3) |
#### 人天估算:**18–26 人天**
- preset 骨架 + 注册 + 验证 mount:2–3
- 自己的 `CompactionEngine` 子类(含 `compactRegion` 语义复刻):5–7
- shadow store(sqlite + 时序 + 索引):4–6
- `agent/pre-step` 注入表层 + `source` 扩展:2–3
- 消息↔影子副本双向映射 + resume 重建:3–4
- 判据(含「压缩后原文可取回」变异):2–3
#### 风险
- **低**:全程官方扩展点,无 hack
- **中**:`CompactionEngine` 是 `abstract class`,要实现 3 个 abstract 方法;
`compactRegion` 的语义(区间校验、`compaction/start`/`end` 括号、`sourceEventSeqs` 覆盖)
必须自己复刻 —— 复刻不完整会在 resume 时炸
- **中**:preset 的 `auditRows` 要求所有行可用(`mount.js:192-195`),
子插件加载失败会让整个 preset mount 失败
### 6.3 混合方案(推荐)
**preset 骨架(②)+ 只读旁路(① 的可行子集)**:
- preset 提供 isolate 的 `shadowContext` 服务与自己的 compaction
- shadowContext 内部监听 `session/event` 维护影子时序(不改 surface)
- 需要让模型看到影子内容时,通过**自己的 compaction 子类**在 replace 时插入影子指针消息
- 全程 `auto:false` 关掉 dsh 默认压缩,避免两套压缩打架
人天:**22–30**(② 的 18–26,加 ① 的 side-store 部分 4–6)
---
## 7. 硬阻塞清单(与路线无关)
| # | 阻塞 | 证据 | 影响 |
|---|---|---|---|
| 1 | **surface 不能插入中间位置** | `dsh-session/lib/index.js:364-369`(`replacementRange` 要求两端在当前 nodes 里) | 影子副本无法「按原时序」回到 dsh;只能末尾追加 + 正文写时间 |
| 2 | **日志 seq 必须严格连续** | `dsh-session/lib/index.js:426` | 任何日志层黑魔法(删/重排)在 replay 时炸 |
| 3 | **loop 请求深冻结** | `dsh-llm/lib/types/index.d.ts:36-42`;实现 `dsh-llm/lib/index.js:2397` | `llm/stream` 只能读/短路,不能改 messages |
| 4 | **agent factory 独占** | `dsh-agent/lib/index.js:431`;`dsh-agent-loop/lib/index.js:1590` | 想自己当 runner 必须弃用 agent-loop(2023 行) |
| 5 | **`compaction` 服务名独占(同 realm)** | `cordis/lib/index.js:814` | 换实现必须 isolate(= 必须走 preset) |
| 6 | **`CompactionTrigger` 无 query** | `dsh-compaction/lib/types/index.d.ts:25` | 「按相关性淘汰」必须自己实现整个 `CompactionEngine` |
| 7 | **pre-step 注入会落盘** | `dsh-agent-loop/lib/index.js:1067-1073` | 「不改日志的影子注入」不成立 |
---
## 8. 最终判断
### 哪条可行
**路线 ②(注册独立 agent 模式)可行。** 它是 dsh 设计时就留好的一等机制,
`agentPresets.register` + `cordis:group` + `isolate: {compaction: true}` 三件套
全部是公开扩展点,且 `dsh-web-app/presets/standard.patch.yml:66-82` 已有
「preset 替换 compaction」的实证。
### 哪条只是看着可行
**路线 ①(劫持/外挂原有信息流)看起来可行,实际是三条成熟度不同的路拼起来的**:
- `agent/pre-step` 看着像「注入钩子」,其实**注入即落盘**(§1.3)——它不是影子,是正文
- `llm/stream` 看着像「最终请求改写点」,其实**loop 请求深冻结**(§1.6)——只能读
- 「不装 compaction-basic 就没了」看着像「能旁路」,其实**auto:false 会连溢出恢复一起关掉**(§2.3)
它唯一干净的用法是**只读旁路观测**(`session/event` + `llm/stream` 计时),
以及**在 preset 内部**配合使用(那就已经是路线 ② 的一部分了)。
### 硬阻塞
一句话:**dsh 的 surface 是「只追加日志上的一个可替换视图」,不是「可编排的时序容器」。**
影子上下文需要的是「在面内按时间摆放内容」,而 dsh 只能做到
「换掉一段已有内容」或「在末尾追加」。这条差异无法用插件绕过——
它是 `replacementRange` 与 `assertSourceEventReferences` 的数据结构决定的。
**可行的形态因此被限定为**:
```
影子副本活在 dsh 之外(自己的 store,带完整时序)
+ dsh surface 上只留「当前有效视图」
+ 需要看原文时,用一条 source.kind='shadow-context' 的 message 在末尾追加指针
+ 压缩策略由 preset 内自己的 CompactionEngine 子类接管
```
---
## 附录:证据索引
### dsh 输入链路
| 主题 | 位置 |
|---|---|
| `preStep` 全文(assemble + waterfall + 默认 decision) | `dsh-agent-loop/lib/index.js:902-934` |
| `turn()` 循环(turn/start、step/start、turn-stopping) | `dsh-agent-loop/lib/index.js:938-1041` |
| `step()` 全文 | `dsh-agent-loop/lib/index.js:1043-1173` |
| **`decision.messages` 落盘** | `dsh-agent-loop/lib/index.js:1066-1073` |
| `buildRequest` + `deriveMessages()` | `dsh-agent-loop/lib/index.js:1227-1288`(derive 在 `:1274`) |
| `prepareRequest` + `agent/request` waterfall | `dsh-agent-loop/lib/index.js:1175-1219` |
| `setFactory` 独占 | `dsh-agent-loop/lib/index.js:1590` / `dsh-agent/lib/index.js:429-437` |
| `agent/pre-step` 类型 | `dsh-agent/lib/types/runtime-types.d.ts:293-311`(事件名在 :304) |
| `PreStepDecision` | `dsh-agent/lib/types/runtime-types.d.ts:92-99` |
| `agent/request` 类型(含「cannot mutate messages」) | `dsh-agent/lib/types/runtime-types.d.ts:311-328`(事件名在 :327) |
| `LlmCallConfig`(只有 6 个标量) | `dsh-llm/lib/types/call-config.d.ts:22-26` |
| **`llm/stream` 类型 + 深冻结契约** | `dsh-llm/lib/types/index.d.ts:32-46` |
| `llm/stream` 实现 | `dsh-llm/lib/index.js:2397` |
| `llm/stream` 生产用法(checkpoint) | `dsh-session-checkpoint-policy/lib/index.js:61-65` |
| `GenerateOptions` 全文 | `dsh-llm/lib/types/types.d.ts`(`GenerateOptions` 段) |
| `systemPrompt.section` / `context()` / `assemble()` | `dsh-system-prompt/lib/types/index.d.ts:250 / :269 / :314` |
| `AssembleContext`(基定义仅 scope/signal) | `dsh-system-prompt/lib/types/index.d.ts:37-44` |
### compaction
| 主题 | 位置 |
|---|---|
| `CompactionTrigger` | `dsh-compaction/lib/types/index.d.ts:25` |
| `CompactionEngine` 抽象类 | `dsh-compaction/lib/types/index.d.ts:102`(`compactIfNeeded` :116 / `compactNow` :137 / `compactRegion` :157) |
| **服务名硬编码** | `dsh-compaction/lib/index.js:169-173`(`super(ctx, "compaction")`) |
| `compactSurfaceRegion` 签名 | `dsh-compaction-basic/lib/types/region.d.ts` |
| **自动触发的两个监听器** | `dsh-compaction-basic/lib/index.js:839`(pre-step)/ `:861`(request-error) |
| `auto` 开关 | `dsh-compaction-basic/lib/index.js:827`、`:817`、`:85` |
| replace 落盘写法 | `dsh-compaction-basic/lib/index.js:648-659` |
| `provides` 撞名抛错 | `cordis/lib/index.js:800-823`(`:814`) |
| `isolate()` 实现 | `cordis/lib/index.js:1723-1727` |
| **preset 已 isolate compaction 的实证** | `dsh-web-app/presets/standard.patch.yml:66-82`、`dsh-web-app/presets/minimal.patch.yml:27-32` |
### surface
| 主题 | 位置 |
|---|---|
| `SessionSurface` 三个只读字段 | `dsh-session/lib/types/surface.d.ts`(interface 段) |
| `replacementRange`(两端必须在 surface 上) | `dsh-session/lib/index.js:363-375` |
| **`sourceEventSeqs` 必须覆盖全部被遮蔽节点** | `dsh-session/lib/index.js:314-332`(`:331-332`) |
| replace 的 seq 必须更早 | `dsh-session/lib/index.js:358` |
| node 0 系统提示词保护 | `dsh-session/lib/index.js:419-423` |
| **replay seq 连续性检查** | `dsh-session/lib/index.js:426` |
| fold 的 replace 动作 | `dsh-session/lib/index.js:461-476` |
| `SURFACE_EVENT_TYPES`(turn/step 不在其中) | `dsh-session/lib/index.js:157-164` |
| `deriveMessages()` | `dsh-session/lib/index.js:1672-1687` |
### message 扩展
| 主题 | 位置 |
|---|---|
| `MessageSourceMap`(merge-extensible) | `dsh-llm/lib/types/message.d.ts:96-105` |
| `ContextForm` 含 `'recall'` | `dsh-llm/lib/types/message.d.ts:46-58`、`:92` |
| `MessageBase`(无 timestamp) | `dsh-llm/lib/types/message.d.ts:124-136` |
| **user/message 的 source.kind 放行未知值** | `dsh-session/lib/index.js:1193-1219`(`:1216` 的 `if (type !== "tool/result") return;`) |
| adapter 只投影 role+content | `dsh-llm-deepseek/lib/index.js:1630-1690` |
| 自定义 kind 的 11 个先例 | `grep -rn "MessageSourceMap" --include=*.d.ts dsh-*/lib/types/*.d.ts` |
### agent 模式注册
| 主题 | 位置 |
|---|---|
| `agentPresets` 服务声明 | `dsh-agent-preset-registry/lib/types/index.d.ts:26-29` |
| `register` / `mount` / `recompose` / `serviceFor` | 同文件(`AgentPresetRegistry` 段) |
| `PresetDefinition` | `dsh-agent-preset-registry/lib/types/definition.d.ts` |
| `mountPreset`(audit + leaked services 检查) | `dsh-agent-preset-registry/lib/types/mount.d.ts:170-198` |
| `bindScopeParent`(preset 成为 agent 的父 scope) | `dsh-agent-preset-registry/lib/types/index.js:274-276` |
| 插件注册 preset 的写法 | `dsh-agent-preset/lib/index.js:18-26` |
| preset YAML 实样 | `dsh-web-app/presets/standard.patch.yml:1-130` |
| `cordis:group` + `isolate` 语法 | 同上 `:47-49`、`:66-82` |