结论:路线②(注册独立 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 仓库。
865 lines
41 KiB
Markdown
865 lines
41 KiB
Markdown
# 影子上下文(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` |
|