Files
HologramAgent-DSH-Memory-Port/docs/shadow-context.md
jianf 533f801c24 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 仓库。
2026-10-30 09:29:37 +08:00

865 lines
41 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 影子上下文(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` |