diff --git a/docs/shadow-context.md b/docs/shadow-context.md new file mode 100644 index 0000000..d996a4a --- /dev/null +++ b/docs/shadow-context.md @@ -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): AsyncIterable +``` + +契约原文(同文件 `: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; + abstract compactNow(agent, signal, sourceCommandId?): Promise; + abstract compactRegion(start: SessionSeq, end: SessionSeq, + agent: Agent, signal?: AbortSignal): Promise; +} +``` + +服务注册名由基类构造函数固定(`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; +``` + +**替换体的落盘**(`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>; +mount(ctx: Context, id?: string): Promise; +recompose(ctx: Context, id: string): Promise; +serviceFor(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 & { ... })[]; +} +``` + +即:**一个 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` |