给主agent单独注入:DSH注入机制与三个反直觉的坑

上一篇讲的是"少搬了一层"。这一篇讲那一层在 DSH 里到底长什么样,以及我们是怎么把它补上的。

给主agent单独注入: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)。标点:文中"我不确定"的地方是真的没验,别当结论用。

转发至

微信扫一扫分享

WeChat QR Code

有AI相关的想法,但不知道怎么落地?

我是国冰,独立AI开发者。十多年技术一线——半导体显示、智慧城市到自动驾驶;现在一个人+AI,接AI落地项目,从梳理需求到系统落地,陪你走通。

微信聊聊 微信二维码 看我能做什么