给主agent单独注入:DSH注入机制与三个反直觉的坑
上一篇讲的是"少搬了一层"。这一篇讲那一层在 DSH 里到底长什么样,以及我们是怎么把它补上的。
面向想自己动 DSH 的人。所有结论都有落盘数据或源码位置支撑;不确定的地方我会标出来。
一、先看清 DSH 的注入全景
DSH 往一次请求里塞东西的地方,三个注入器 + 一个系统提示。
三个注入器都是 `user` 消息,都落在对话历史里(不是系统提示):
| 注入器 | 来源 | 父子是否同份 | 更新方式 |
|---|---|---|---|
| workspace 指令基线 | dsh-agent-instructions 读工作区文件 |
相同 | 内容变化时追加"更新块" |
| runtime context | dsh-system-prompt 的 context 段 |
按会话生成 | 变了就发新快照 |
| skills catalog | dsh-tool-skill 扫 skills 目录 |
相同 | 原地替换(数组里同一条换掉) |
看第二行和第一行的差别 —— 这就是整件事的钥匙:runtime context 天然按会话区分,而 workspace 基线只有"按工作区取一份"这一个维度。
runtime context 的段顺序表里甚至给这件事留了正式插槽:
const CONTEXT_ORDERS = {
SANDBOX_POLICY: 110,
APPROVAL_POLICY: 115,
SUBAGENT_DELEGATION: 120, // ← "你是被派来的"
};所以我们的活不是"发明按会话区分",而是把 workspace 基线对齐到同一水平。
系统提示那一边(dsh-system-prompt 的 SECTION_ORDERS):
-1000 harness:identity "You are an AI agent powered by DeepSeek Harness."
0 deployment:persona-prefix
500 PLAN_POLICY 600 TEAM_POLICY 800 PTC_ONLY 900 FILE_REFERENCE
1000–2800 每个工具一段(bash/read/write/glob/grep/…/subagent)
2900 report 3100 mcp 9000 deliverable 9900 structured output
10000 harness source 10100 web surface 10200 persona suffix两个要点:
- `harness:identity` 是硬编码字面量(dsh-system-prompt/lib/index.js:216-220),只有一个默认 true 的开关、全包无人设置它,而且这一行不在 preset 里(挂宿主组合)。我没定位到宿主组合文件,所以"能不能从我们这侧动它"我不确定 —— 但也不必动:平台自述本来就该是硬的。
- `persona` 是配置项(dsh-persona 把 config.prefix 原样注册成 section)。
还有一个容易被忽略的事实:注入块自己声明了从属地位。每一段 workspace 指令的开头都写着:
> "…They do not override system, developer, or direct user instructions."
身份属于系统提示,规则属于注入 —— 这不是审美偏好,是这两层在 DSH 里的真实优先级。
二、按会话区分的三个原生能力(和一个缺口)
能力①:会话头里有分类
// SessionHeader(dsh-tool-cordis 的类型声明里能看到)
{ version, id, createdAt, cwd?, parentSession?,
isSeeded, origin?: 'subagent', delegationDepth?: number, agentPreset? }origin: "subagent" 由 dsh-subagent/lib/index.js:510 设置,delegationDepth 每层 +1。插件直接读 `agent.session.header` 就能判断自己在给谁注入 —— 不需要猜。
能力②:子 agent 的 persona 可以被覆盖
dsh-tool-subagent 的 Config:
const Config = z.object({
provider: z.string().required(),
toolName: z.string().default("subagent"),
modelSelectionSettings: z.boolean().default(false),
enableRunInBackground: z.boolean().default(true),
backgroundMode: z.union(["one-shot", "continuable"]).default("one-shot"),
agentOptions: z.object({ provider, model, reasoningEffort, maxTokens }).default(undefined),
persona: z.string(), // ← 子 agent 人格
toolFilter: z.object({ allow, deny }).default(undefined),
maxDepth: z.union([z.natural(), z.const("provider-managed")]).default(3),
});机制在 applyChildComposition:
childCtx.get("agentPresets")?.composeFrom(childCtx, parent.ctx);
childCtx.systemPrompt.context({ name: "subagent:delegation", text: SUBAGENT_DELEGATION_CONTEXT });
if (composition.persona !== void 0)
childCtx.systemPrompt.section({
name: "deployment:persona-prefix", // ← 和父级同名
text: composition.persona,
});
if (composition.toolFilter !== void 0) childCtx.tools.restrict(composition.toolFilter);同一个 section 名 + 子级作用域更近 → 覆盖(源码注释里用的词就是 shadowing persona section)。实测:只在 preset 的两条 subagent 行各加一个 persona: 键,子会话的系统提示就从"你是小黑、你的用户是国冰"变成了"你是被派来的执行者"。
两个注意:
- `persona` 是全局单串,所有子 agent 共用,里面不能写只有主 agent 成立的话
- `toolFilter` 只能按工具名(tools.restrict({allow, deny}),对着 restrictableNames 校验),不能按路径 —— 所以它解决不了"别读某个文件",只能"整个别用某个工具"
能力③:子 agent 拿得到自己的身份判据
每个子会话的 runtime context 里都有这段固定文本:
"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."
于是"规则"可以写成可自检的形式 —— 因为有这个判据在它手里。我们最终把启动协议写成了:"如果你是被派来的执行者,跳过这几步。判据是你的上下文里有没有上面那段。"
缺口:候选表是静态的
dsh-agent-instructions 的配置:
instructionFileCandidates: z.array(z.string()).default(["AGENTS.md", "CLAUDE.md"]),
localInstructionFileCandidates: z.array(z.string()).default(["AGENTS.local.md", "CLAUDE.local.md"]),没有会话维度,也没法给子 agent 换个 preset —— resolveChildAgentOptions 只透传 provider/model/effort/maxTokens,子会话的 agentPreset 是从父级 scope 链读出来的。
所以:要条件注入,只能自己写插件。
三、插件实现
目标一句话:`origin === 'subagent'` 或 `delegationDepth > 0` 就什么都不做;否则把 `main/` 下的文件读出来注入。
挂载
preset 里一行。dsh-agent-presets 的 classifyRowSpecifier 认绝对路径和 file: URL:
if (name.startsWith("file:")) return { kind: "file", specifier: name };
if (isAbsolute(name)) return { kind: "file", specifier: pathToFileURL(name).href };- id: main-inject
name: '/home/robin/.dsh/plugins/xiaohei-main-inject/index.js'
config:
files: [main/RULES.md, main/PORTRAIT.md]依赖解析要自己解决:插件在 DSH 包树之外,import { createUserMessage } from "@deepseek-ai/dsh-llm" 会直接 ERR_MODULE_NOT_FOUND(Node 从文件位置向上找 node_modules)。办法是软链:
ln -s <dsh>/node_modules/@deepseek-ai ~/.dsh/plugins/node_modules/@deepseek-ai核心:三步
ctx.on("agent/pre-step", async ({ agent, messages, signal }, next) => {
const decision = await next(); // ① waterfall:先拿上游的 decision
if (decision?.kind === "reject") return decision;
if (sessionClass(agent) !== "main") return decision; // ② 条件就在这一行
/* …读文件、拼 body… */
const desired = createUserMessage({
content: [{ type: "text", text: body }],
source: { kind: "agent-instructions", form: "instructions" },
});
const key = canonical(body);
if (seen.get(agent) === key) return decision; // 自己的状态
if (listHas(decision.messages, key) || listHas(inbox.nextStep, key)) return decision;
inbox.splice("next-step", Infinity, 0, [desired]); // ③ 走 inbox
seen.set(agent, key);
return decision;
});三个必须知道的细节
1)交付必须走 `agent.inbox`,不要改 `decision.messages`。
agent.inbox.splice("next-step", Infinity, 0, [msg]) 和 agent-loop 自己的 send/steer 是同一条路:
send(message, target, wakeup) {
this.inbox.splice(resolvedTarget, Infinity, 0, [message]);
…
}Infinity 会被 mutate() 夹到 inbox.length,所以可以直接用。inbox 里的消息会被正常 claim → 由 agent-loop 提交进会话,于是它成为历史的一部分:后续每一步自然都在、去重能命中、而且进审计记录。
第一版我们直接 decision.messages.toSpliced(...) 插一条 —— 那是每步临时拼一条,永远进不了会话,结果是每一步都重新注入一块。
2)去重判据不能建立在对面的副本上。
我们一开始写的是"和我生成的那条逐字节相同就跳过"。看起来天经地义,但它永不成立:
我们生成: 8396 字符
harness 落盘存的: 8395 字符 ← 少一个(行尾空白被规范化)差一个字符 → `isDeepStrictEqual` 永远为假 → 每步重注一份。
修法两条:用自己的内容哈希记住"这份我给过了",比对时先规范化(折叠空白 + trim)再比。
> 通用规则:凡是要跟平台落盘内容比对的,一律先规范化,别信逐字节相等。
3)`source` 刻意不带 `baseline: true`。
上游的 visibleBaselineSource 靠 source.kind === "agent-instructions" && source.baseline === true 认"当前可见的基线"。我们不带这个标记,它就不会把我们这条误当成自己的基线,两边互不干扰。
四、三个反直觉的 DSH 行为
① 撞上候选名的文件,放到哪儿都会被自动注入
dsh-agent-instructions 除了根目录候选表,还有一条目录作用域发现:挂在 tools/result 上,工具碰到某个路径就 projectTouch,去那个目录里找同名候选文件,作为"适用于该目录的指令"注入(约 470–520 行)。
实测:我们把档案命名为 main/USER.md,只因为写了一行 `main/RULES.md`,它顺手扫到了 main/USER.md(当时 USER.md 还在候选表里)就注入了。
推论:AGENTS.md / CLAUDE.md / AGENTS.local.md / CLAUDE.local.md 这几个名字,在工作区任何目录里都是"危险名字"。给私人内容起名,要避开它们。
② 平台会规范化你写进去的文本
见上。这条本身不起眼,但它制造的 bug 是"逻辑全对、结果全错"。
③ 加载时机分两种
- 插件代码:每进程加载一次 —— 改了必须重启
- 插件读的数据文件:每步从磁盘重读 —— 改了下一步就生效,不用重启
(preset 属于前者。所以"改一行 YAML"和"改插件"都要重启,而"改 main/RULES.md"不用。)
五、验收方法(比结论更重要)
这一天我们下错过两个结论,都是被落盘数据纠正的。三个可复用的验法:
① 数落盘 transcript,别数 UI。 会话记录是 zstd 压缩的 JSONL:
zstd -dc ~/.dsh/sessions/<workspace>/<session>/session.v3.jsonl.zstd \
| python3 -c "…数 source.kind=='agent-instructions' 的事件、或按 step 数注入块份数…""每一步重注一块"这个 bug,是靠"11 步 10 份注入块"确认的。
② 想知道子 agent 看到了什么,派一个 probe 去读它自己的系统提示。 不要去问它感觉如何,直接读它会话里的 system/message 事件 —— 那是它系统提示的逐字副本。我们的两次验证(人格是否被覆盖、基线里有哪些文件)都是这么做的。
③ 会话头 + descriptor 是分类的依据。 session 事件里的 origin / delegationDepth,和 subagent/descriptor,能确认一条会话到底是谁、在什么位置。
六、最后:效果
| 子 agent 的无条件基线 | 13,771 B(AGENTS + SOUL + IDENTITY + USER) | 1,282 B(只有 AGENTS.md) |
|---|---|---|
| 子 agent 的人格 | "你是小黑,你的用户是国冰" | "你是被派来的执行者" |
| 子 agent 是否知道私人档案路径 | 知道 | 不知道 |
| 主 agent 的规则与档案 | 和子 agent 混在一起 | 单独一份,条件注入 |
| 新增代码 | — | 一个插件,约 135 行 |
补这一层用的东西不多:一行 preset、一个软链、一个不到 150 行的插件。难的不是写代码,是搞清楚 DSH 的分层 —— 以及别拿"读代码觉得应该如此"去覆盖"落盘数据说不是这样"。
所有源码位置基于 2026-09-19 的 DSH 安装(Node 24.18.0)。标点:文中"我不确定"的地方是真的没验,别当结论用。
有AI相关的想法,但不知道怎么落地?
我是国冰,独立AI开发者。十多年技术一线——半导体显示、智慧城市到自动驾驶;现在一个人+AI,接AI落地项目,从梳理需求到系统落地,陪你走通。
看我能做什么