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

41 KiB
Raw Blame History

影子上下文(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):

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):

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):

'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):

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):

var CompactionEngine = class extends Service {
	constructor(ctx) {
		super(ctx, "compaction");
	}
};

实现函数(dsh-compaction-basic/lib/types/region.d.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):

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):

_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):

if (this.config.auto) this._registerAutomaticCompaction();

auto 的 schema 与默认值(index.js:817、:85):

auto: z.boolean()          // :817
auto: config.auto ?? true  // :85

2.3 ★ 能让 compaction「完全不发生」吗?—— 能,三条路

路 A:config.auto = false(最干净,零代码)

- 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):

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):

- 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):

export interface SessionSurface {
    readonly nodes: readonly SessionSeq[];
    readonly replaceGeneration: number;
    readonly contentGeneration: number;
}

replace 的写法(dsh-compaction-basic/lib/index.js:648-659 已给出实证):

session.append("user/message", checkpointMessage, {
    surfaceOp: { op: "replace", startSeq: start, endSeq: end },
    sourceEventSeqs: [/* 必须覆盖全部被遮蔽节点 */]
});

落盘后的 fold 动作(dsh-session/lib/index.js:461-476):

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):

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):

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:

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…):

// 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:

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:

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):

declare module '@deepseek-ai/cordis' {
    interface Context {
        agentPresets: AgentPresetRegistry;
    }
}

注册接口(同文件):

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):

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):

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):

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):

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):

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):

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