夜雨聆风学习资料网

ARTICLE · 1130645

DeepSeek-Harness 源码深读(16):默认脊柱 265 行,一行运行时逻辑都没有

DeepSeek-Harness 源码深读(16):默认脊柱 265 行,一行运行时逻辑都没有

摘要:CLI、ACP 桥接这些入口共享的地基,是个自己不写任何运行时逻辑的组合根。配置用 z.intersect 直接求交子插件 schema,捆绑层没有一份默认值可抄;入口侧 pickSpineConfig 白名单反向防泄漏;25 次挂载顺序无关,唯一一处顺序敏感点被注释就地钉死。265 行逐段拆。

打开 packages/examples/agent-spine-demo/src/index.ts,先看到 28 行 import,从 cordis 的 Timer 一路拉到 dsh-agent-loop。接着往下读,读到 265 行的文件末尾,你找不到一个循环、一个状态机、一条事件监听。它没有。

这个包叫 agent-spine-demo,名字里带着 demo,住在 examples 目录,CLI、ACP 桥接各类入口却全踩在它身上,dsh 的「默认 agent 脊柱」指的就是它。脊柱把一组通用服务(LLM 运行时、会话、系统提示、工具注册表)、后台任务注册与控制、可选的持久化 goals、agent 循环、本地 skill 与工作区指令 provider、bash/skill/jobs 三类模型工具,一次性挂载成一个捆绑 fiber。部署方只补三样:LLM 适配器、bash 执行器、展示层,文件头注释写得明白(src/index.ts:2-5)。装配的活全归它,跑起来的活一行不归它,这就是组合根。

主线案例定下来:一份最小部署配置。persona 写一句「你是部署方的运维助手」,dshHome 不传走默认,agents 声明一个 id 为 ops 的助手,workspaceContext 给 20000 字节预算,goals 不传。我们跟着这份配置穿过 apply 的 25 次挂载,看每个字段落到谁头上,顺路看两处容易翻车的地方怎么被源码堵住。

一、25 份配置,一份都不许抄

脊柱对外只暴露一个大 Config,agents、persona、tools、skills,每个字段各有其主。最省事的写法是脊柱自己抄一份默认值再往下发。抄的代价藏在时间里:子插件更新了默认值,脊柱忘了同步,同一个字段两套默认值,静默漂移,谁都不报错。

dsh 的做法是求交。Config 的定义(src/index.ts:160-175):

export const Config = z.intersect([  AgentLoop.Config,  SystemPrompt.Config,  z.object({    tools: ToolRuntime.Config,    dshHome: z.string(),    sessionTitle: SessionTitleConfigSchema,    skills: SkillConfigSchema,    workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),    // …(toolBash / jobs / toolJobs / invariants / goals 同式转发)    goals: z.union([z.const(false), GoalConfigSchema]),  // …(as unknown as z<Pick<…>> 类型断言收尾)]) as unknown as z<Config>

这段是捆绑包配置 schema 的全部组装方式。z.intersect 把 agent-loop、system-prompt 两个 owner 的 schema 和一张转发字段表直接求交,校验与默认值填充和子插件用同一份定义。注意 AgentLoop.Config 里本来就含 agents 字段,交集里 agents 天然继承 loop 的校验;字段表里的 toolBash、jobs 这些键虽然在脊柱声明,取值的 schema 还是各 owner 的。翻遍 265 行,捆绑层自己写下的默认值只有一处,sessionTitle 的示例策略(src/index.ts:141 的 .default(EXAMPLE_SESSION_TITLE_CONFIG),兜底标题最多 5 个词、40 字节,成品上限 80 字节,定义在 :43-47),其余字段的缺省全部由 owner 决定。

运行时转发守同一条纪律,写法却分两种。SystemPrompt 那次挂载(src/index.ts:225-230):

  ctx.plugin(SystemPrompt, {    includeHarnessIdentity: config.includeHarnessIdentity ?? true,    includeRuntimeContext: config.includeRuntimeContext ?? true,    persona: config.persona ?? '',    ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},  })

这段是四个字段转发的实况。注意 persona 和 toolOrder 的差别:persona 用 ?? '' 兜底,toolOrder 必须 !== undefined 才条件展开。原因在 owner 那头:system-prompt 对空串有明确语义,persona 缺省就是空文本;它对 toolOrder 缺省也有明确语义,纯字典序排工具(orderTools 收到 undefined 走默认分支,system-prompt/src/index.ts:164)。假如脊柱顺手写个 ?? [],owner 眼里的「没传」就被改写成「传了个空数组」,校验跟着变味。我们的最小配置传了 persona、没传 toolOrder,两行各走各的语义,互不干扰。

二、入口的配置,怎么不串味

方向反过来还有一道题。app 包(比如 CLI)的配置对象里,脊柱字段和入口专属设置混在一起,整包传给脊柱,入口设置就漏进捆绑层。pickSpineConfig(src/index.ts:182-200)管这个:

  return {    ...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {},    ...config.includeHarnessIdentity !== undefined ? { includeHarnessIdentity: config.includeHarnessIdentity } : {},    // …(includeRuntimeContext / persona / toolOrder / tools / dshHome / sessionTitle 同式)    workspaceContext: config.workspaceContext,    ...config.skills !== undefined ? { skills: config.skills } : {},    // …(toolBash / jobs / toolJobs / invariants / goals 同式)  }

这段是 app 包接入脊柱的标准姿势,白名单拷贝。逐字段 !== undefined 才带上,没定义的连键都不出现,owner schema 继续按缺省解释;workspaceContext 无条件透传,它在 Config 里本来就是必填(src/index.ts:112),没有缺省一说。上一节求交管「不复制」,这一节白名单管「不泄漏」,方向相反。CLI 的解析结果经这一层过滤,落到 apply 手里的只剩脊柱认识的字段。

三、25 次挂载怎么排都对,例外被注释钉死

apply 主体(src/index.ts:212-265)一共 25 次 ctx.plugin。初读的人都会问同一个问题:这个顺序错不得吧?注释答得干脆(src/index.ts:206-210):

 * explicitly forwarded config. Load order is irrelevant (cordis * pends each fiber on its `inject` until the services it needs exist), but the * listing mirrors the dependency layering for readability: the LLM vocabulary * and core registries first, then extension plugins that wrap request/tool * seams, then the loop that drives them.

这段是挂载顺序的免责声明。cordis 里每个插件实例是一个 fiber,它 inject 的服务没就绪就挂起等待,依赖关系由框架排定,清单顺序只照顾阅读:先 LLM 词汇和核心注册表,再包裹 request/tool 扩展点的插件,最后挂驱动它们的循环。所以那 25 行 ctx.plugin 随便打乱,运行时一个都不会乱。

清单本身过一遍账。六个核心服务先落:Timer、LlmRuntime、SessionStore、SessionTitle、SystemPrompt、ToolRuntime(:220-231)。注册表与扩展跟上:SkillRegistry、SkillFileSystem、AgentRegistry、llmRetry、LocalJobRegistry(:234-244)。五个诊断伴随插件无条件全挂,session、agent、scope、agent-loop 四个真的,加本包一个空壳(:245-249)。模型侧消费者按开关挂:bashEnv 加 toolBash、workspaceContext、toolSkill、toolJobs(:250-260)。AgentLoop 收尾(:261)。25 个里只有 goals 三件套要显式 opt-in(:239 的 if),缺省即不挂载,其余全默认在场。

全文件唯一的例外在尾部(src/index.ts:254-259):

  if (config.workspaceContext !== false) {    ctx.plugin(workspaceContext, config.workspaceContext)  }  // Both plugins prepend session-prefix messages. Registration order is the  // rendered order, so workspace instructions must precede the skill catalog.  if (skillsEnabled) ctx.plugin(toolSkill, config.skills?.tool ?? {})

这段是唯一一处顺序真正有语义的挂载。agent-instructions 和 tool-skill 都往会话前部注入消息,注册顺序即渲染顺序,workspace 指令必须先于 skill 目录注册,两行英文注释把这条约束就地写死在代码旁边。落到模型每轮看到的消息序:直接 prompt、工作区指令、skill 目录。我们的 ops 助手每轮先读到 AGENTS.md 里的项目说明,再读到 skill 目录,靠的就是这两行的先后。

脊柱的全貌这时候可以拼出来了:

五层排列无箭头,唯一顺序敏感的一对用橙线标出

四、dshHome 两条链路,启动时对账

apply 的第一件事不是挂插件,是查账(src/index.ts:213-218):

  const nestedDshHome = config.skills?.filesystem?.dshHome  if (config.dshHome !== undefined && nestedDshHome !== undefined    && resolveDshHome(config.dshHome) !== resolveDshHome(nestedDshHome)) {    throw new Error('agent-spine-demo: dshHome and skills.filesystem.dshHome must resolve to the same directory')  }  const dshHome = resolveDshHome(config.dshHome ?? nestedDshHome)

这段是 apply 的前置防御。dshHome 同时喂两条链路:bashEnv 把它注入模型的 shell 环境(:251),SkillFileSystem 拿它当本地 skill 扫描根(:235)。skills.filesystem.dshHome 又能单独配置,两处解析到不同目录的话,模型看到的 DSH_HOME 和实际扫描的 skill 根对不上,这种错留到运行时才发现就晚了。注意比较前先过 resolveDshHome,归一逻辑不长(home-paths/src/index.ts:87-91):

export function resolveDshHome(configured?: string, env: Record<string, string | undefined> = process.env): string {  const fromEnv = env[DSH_HOME_ENV]  const selected = configured ?? (fromEnv !== undefined && fromEnv.trim().length > 0 ? fromEnv : defaultDshHome())  return resolve(expandHomePath(selected))}

这段是三级优先的完整实现:显式配置、非空 $DSH_HOME、~/.dsh(defaultDshHome,home-paths/src/index.ts:61),选完做 tilde 展开加 resolve。注意 fromEnv 判的是 trim 后非空,只写个 DSH_HOME= 空串不会拦住默认值。脊柱拿它把两处配置归一之后再比,写法差异先抹掉,剩下的矛盾启动时直接 throw。

我们的最小配置连顶层 dshHome 都没传,nested 也是 undefined,防御放行,归一结果落到 ~/.dsh,经 Object.assign 强制塞进 skill provider 的配置(:235),两条链路拿到同一个目录。

五、收尾一行挂循环,agents 可以不传

apply 的最后四行(src/index.ts:261-264):

  ctx.plugin(AgentLoop, {    agents: config.agents ?? [],    ...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {},  })

这段是循环的挂载与转发。AgentLoop 排在清单末尾,agents 缺省降级为 []。可省是有讲究的:ACP 桥接这类入口不预创建任何 agent(注释 src/index.ts:71-72),请求到来时再经 ctx.agents 注册表创建。maxParallelToolCalls 的条件展开和 toolOrder 同理,不越 owner 一步。

我们的 ops 声明传进去之后,agent-loop 构造函数对它过一遍(agent-loop/src/index.ts:355):没带 sessionId、没有持久化服务,走到 :361 的 create,一条新会话建起来;带 resumeSessionId 的声明走 inject(['sessionPersistence']) 恢复(:370);哪一步失败都不炸进程,reportConfiguredStartupFailure(:385)发一个 agent-loop/config-start-failed 事件了事。脊柱把声明原样递过去,生老病死都在 loop 那头。

maxParallelToolCalls 在 loop 那头有个运行时细节。它暴露成 live getter(agent-loop/src/index.ts:331-333),设置提交后的变更只约束下一个工具组:组开始时解构一次配置(tool-calls.ts:131),放行前用 inFlight.size < maxParallelToolCalls 做检查(tool-calls.ts:199),进行中的调用不被打断。还有一条刻意为之:agents 被排除在用户设置节之外(agent-loop/src/index.ts:240-243),注释说它是启动期一次性消费的组合数组,存起来的修改只能看起来像生效过。部署声明与用户偏好在 schema 层就分了家,脊柱只转发前者,一个字不多碰。

结语

回到主线案例,逐字段对账。persona 那句话进 SystemPrompt(:225-230),workspaceContext 的 20000 字节进 agent-instructions(:255),agents 里的 ops 声明进 AgentLoop(:261-264),dshHome 走默认落 ~/.dsh,goals 不传,三块 goal 插件压根不挂(:239 的 if 直接跳过)。25 次挂载,我们的配置只动了四个字段。LLM 适配器、bash 执行器、展示层,脊柱留白的三样,部署方自己补。

这个包还有两处细节值得单独指给你。其一,它只导出具名导出(src/index.ts:6-7),因为 Loader 对 default 的解包会丢弃 Config schema,这条约束记在 docs/postmortem/0001,一次真实事故换来的,所以文件里全是 import * as。其二,包自带的 invariant 伴随插件是个空壳,const install: InvariantInstaller = () => {}(src/invariant.ts:21),安装函数体是空的,存在意义是在 invariants 的包名过滤体系里(package_allowlist / package_blocklist,runtime-diagnostics/invariants/src/index.ts:97-98)占住自己的包名。

265 行读完,运行时逻辑确实一行没有。全文件唯一的 throw 在第 216 行,报的是两个 dshHome 不指向同一个目录,一根脊柱的全部脾气就这一下。

脊柱挂了 bash、skill、jobs 三类模型工具,fs 不在其中。ctx.fs 的抽象接缝和 fs-local 后端是独立的一环:realpath 定文件身份、版本 token 防写冲突、Win32 安全描述符不丢失。深读线下一篇拆 packages/fs/fs-local,从脊柱留白的地方接着走。

你维护过的项目里有没有这种纯组装的模块?配置转发是抄一份默认值,还是求交?评论区聊聊。


本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注,下一篇见。

相关学习资料