结论:路线②(注册独立 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 仓库。
41 KiB
影子上下文(Shadow Context)方案在 dsh 上的可行性核实
状态:调查,不含实现 目标:核实两条路 —— ①「劫持/外挂 dsh 原有信息流」;②「独立注册一种 agent 模式」 宿主:dsh(deepseek-harness)
0.2.1-rc.2,cordis4.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 内核」「不伪造持久面」「语义可解释」的路。
硬阻塞(两条,与路线无关):
surface replace只能替换已存在的 surface 节点,且必须覆盖全部被遮蔽节点(dsh-session/lib/index.js:314-332的assertSourceEventReferences)。所以「把某段历史整体换成影子版本」在 surface 上可行,但换掉的那些节点必须逐一列进sourceEventSeqs,且被换掉的事件仍在 append-only 日志里。- 日志 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-localmarkAgentLoopRequestidentity 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(改会抛)。
你能做的只有两件事:
- 短路:不调
next(),自己 yield chunk 流(伪造回复)—— 但那样 assistant message 会进日志,且内容与真实模型无关。 - 包一层:
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 这些字段会活到下一轮吗?—— 会,且不会被发给模型
存活链路(全部只读,不丢字段):
session.append("user/message", message, ...)⇒ 事件进日志,data做 lossless-JSON 快照(dsh-session/lib/types/index.d.ts的append注释: 「returns the logged event — ... the SNAPSHOT ofdatathat entered the log」)- resume/replay ⇒
Session.fromRestore(...)读回同一份data,assertMessageEventShape对 user 消息只校验id/role/kind非空/content数组⇒ 自定义字段完整保留 deriveMessages()⇒deriveEventMessage(event)投影成Message,source原样带上- 发给模型前被丢掉: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 |