第七部:产品面——同一运行时的不同面孔

同一个 Cordis 运行时,长出五个产品面:Headless CLI 一次性跑完、JSON-RPC Server 长期驻留、TS/Python SDK 协议共享、ApiProxy 控制面、Conversation UI 只投影不持真。

同一棵 Cordis 运行时树长出多个入口:终端 CLI、齿轮状的 JSON-RPC 服务器、带括号的 SDK 代码窗口、多面板的 Web 浏览器与机器人手臂式的 ACP 自动化,五个入口共享树根。
展开阅读路线与实验入口

你用 DeepSeek Harness 的方式有几种?终端敲 dsh --profile headless "写个排序"?Python 里 from deepseek_harness import DeepSeekHarness?浏览器里点开对话界面?还是编辑器插件通过 stdio 连一个常驻进程?

看起来是五个产品。但你打开进程列表看——它们不是五个不同的 Agent 实现。它们是同一棵 Cordis 运行时树上长出的五个入口,共享根容器里的 system prompt、tools、sandbox、approval,只是在入口层做了不同的包装。

第七部回答一个反直觉的问题:为什么五个入口共享同一套 AgentLoop,但你写脚本调用 SDK 和在浏览器里点按钮,体验天差地别?答案不在 Agent 里,在入口层做了什么。

这一部解决什么

读完这五章,你能分清五个产品面各自的边界在哪里、谁是事实源谁是投影、谁是一次性进程谁是常驻服务。你会知道 Headless 不是 ACP,SDK 不是简单 API wrapper,ApiProxy 不是透明代理,Conversation UI 从来不持有真相。把这些搞混,你会在 SDK 里找 Session 对象,在浏览器里找 Agent 引用,在 ApiProxy 里找模型调用代码——全部找错地方。

如果你不是线性通读,想先把产品面脑图搭起来,可以这么走:先看 Headless / Server / SDK 的入口边界,再看 ApiProxy 到底是什么,最后看 Conversation UI 为什么只是投影。第七部不难在五个名词本身,而难在别把事实源、包装层、投影层混成一团。

Headless CLI 提供命令行交互,JSON-RPC Server 暴露协议,TypeScript/Python SDK 封装客户端,ApiProxy 是控制面板,Conversation UI 投影到前端界面

本部实验全部只读:grep/cat 源码文件,用 $DSH_SOURCE_DIR 指向官方固定 commit checkout。

本节源码依据(3 处)

从最小的产品面开始:Headless CLI。你以为它就是”没有 TUI 的 CLI”?它比那更极端——它连 Agent 都不长期持有。

深入浅出 DeepSeek Harness 第七部:产品面——同一运行时的不同面孔 Chapter 43

命令行里,问一句话就退出

headless 产品面:一个一次性 runner,创建或接管一个 Agent,把任务推到静止,流式输出到 stdout,然后退出进程。它不启动 UI、不常驻、不等下一条输入;它是脚本、CI 和外部编排器能直接对话的那张脸。

Written in Chinese

Source version
dsh-v0.1.7-rc.2
Verified on
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

第 0 秒,你在终端敲下 dsh --profile headless "run the tests"。没有窗口、没有侧栏、没有输入框——进程起来,解析这行命令,创建一个 Agent,把这句话作为用户消息推进去,然后静静地等它跑完。模型的推理过程一路滚在 stderr 上,跑完那一刻,最终回答落在 stdout 的最后一行,进程以 0 退出。这就是 headless:第六部那套 Agent 运行时、append-only 会话日志、工具与权限,不换一点内核,只换一张”问一句话就退出”的脸。它是脚本、CI、cron 和外部编排器能直接 spawn 对话的那张产品面——而它的每一个退出码,都来自同一个会话被推到静止的那一刻。

解析与消费:启动层是只读配置

headless 的第一件事不是跑任务,而是把命令行读成一份只读配置。packages/bundle/headless/src/startup.ts 用 commander 定义这个一次性 app 的命令:位置参数 [task...] 接住任务文本(多词拼成空格分隔),--json 把 stdout 从最终消息换成换行分隔的运行事件,--session-id <id> 要求接管一个已持久化的会话——“an unknown id is an error”。它把这些值 publish 成一个 Cordis 服务 headlessStartup,runner 是这个服务的普通消费者,自己的配置是惰性的,等服务就绪才读。

这里有个容易被忽略的细节:--json 的识别不靠 commander 的 option 解析结果,而是一个手写的 jsonRequested(argv) 扫描。它逐段扫原始 argv,遇到 -- 就停(后面全是透传参数),遇到 --session-id 就跳过下一段(那个值可能字面上就是 --json,但它不是本调用的开关)。只有这样,脚本里 dsh --profile headless --session-id session-xxx -- "用 --json 风格输出" 这种话才不会被误判成 JSON 模式——开关的语义必须和”把 --json 当作文本内容”区分开。进程事实(是否 TTY、stdout)被收进 startup-internals.ts,让测试不用碰真实进程就能替换。

headless 一次运行的决策流:commander 解析 task/--json/--session-id 后 publish headlessStartup 服务;run() 先 await loader 再取 agents/sessions。缺省走 agents.create(随机 session-id + meta.cwd),带 --session-id 走 resolveAgent:先断言 sessionPersistence 与 sessionQuery 在场,再过 assertAdoptable 闸门(拒 preset/subagent/cwd 不符),冷会话 query.observeSession 后 agents.resume,接管前再对活日志断言一次。两路汇合到 whenIdle → followup(task),推理流走 stderr;收尾 sessions.flush → summarize,--json 时 projectJsonRun 把全过程拍成 NDJSON,最后 appExit:completed 退 0、其余退 1。

本节源码依据(4 处)

run() 的等待与早退

run() 是整个一次性面孔的心脏,但它的第一步不是创建 Agent,而是 await ctx.get('loader')?.await()。注释写得明白:loader 的兄弟插件是并发挂载的,必须等整个应用就绪、作用域工具与适配器都拼完,再创建 Agent——否则一个半路 Agent 会拿到半初始化的工具表。随后它从全局服务里取 agents、agentDefaultModel、sessions;任何一个缺席就直接 return,不跑任务也不退出——这是一个”宿主没给我必要零件”的安静失败,留给 launcher 处理。

早退之后是两个校验。其一,config.sessionId 若存在但 trim() 后为空,抛 headless-runner: sessionId must not be blank——注释解释:Cordis overlay 会直接设置该行、绕过 CLI 的 trim 检查,所以同一个公共设置必须在 runner 这里也失败,不能变成一个空白身份。其二,任务文本:task 缺省或为 - 时从 stdin 读全部内容(runner-internals.readStdin 把 process.stdin 攒成 buffer 再转 utf8);读完若 trim() 为空,抛 a task is required, for example: dsh --profile headless "run the tests"。一条”空会话、空任务”的命令不会静默成功,它必须在第一时间显式失败。

早退/校验点触发条件行为源码位置
loader 未就绪兄弟插件仍在挂载await loader,再继续index.ts run()
缺核心服务agents/agentDefaultModel/sessions 任一缺席静默 return,不跑不退出index.ts run()
空白 sessionId—session-id 传了但 trim 为空抛错,拒绝空白身份index.ts run()
空任务task 缺省/- 读 stdin 后 trim 为空抛错并给出示例命令index.ts run()
本节源码依据(4 处)

创建 vs 接管:resolveAgent 的闸门哲学

过了早退校验,run() 算出模型选择(defaultModel.currentSelection())和一个 setup——它把当前模型选择经 installModelSelection 塞进子上下文。注释点明:这个 bundle 不组合任何 preset 名册,模型面向的行放在 host 平面、由 agent 从全局层读;真要配名册的部署得自己在这里 join 一个 child agent。session-id 则是 config.sessionId ?? session-${randomUUID()},再用 brandString<SessionId> 打成品牌类型。cwd 从挂载的 fs 服务解出真实进程目录——这为后面 cwd 校验提供了基准。

分叉在 config.sessionId === undefined:缺省走 agents.create({ sessionId, meta: { cwd }, agentOptions, setup }),一个全新随机身份。带 id 则走 resolveAgent,这是接管逻辑的全部重量所在。它先断言两个服务在场:sessionPersistence(“the Session would not survive this process”——没有它,接管的会话根本写不回磁盘)和 sessionQuery(“dsh-base provides it”)。然后分三层判断:

第一层,活会话拒绝。如果 agents.get(sessionId) 已有一个活 Agent,先 assertAdoptable 跑一遍,再抛”该会话在本进程中存活,一次性 runner 不能独占一个运行区间”——一个已经在跑的会话不能被另一个一次性进程抢去驱动。第二层,冷会话接管。query.observeSession(sessionId) 读出 header 与事件快照,assertAdoptable 过后 agents.resume({ resumeSessionId, agentOptions, setup })。第三层,不存在。既不活也不冷,抛”session 不存在;省略 —session-id 开始新会话”。

assertAdoptable 本身是一整套闸门,它回答”这个持久化会话配不配被一次性进程驱动”:

闸门条件判定拒绝理由
会话跑在 agent preset 下currentPreset 非空一次性 runner 不组合 preset,拒绝
subagent 或 fork 会话origin===‘subagent’ 或 parentSession 存在不能直接驱动子代理/分叉会话
未记录 cwdheader.cwd 缺省无工作目录,无法接管
cwd 不符header.cwd !== 当前进程目录“记录在 X,不是 Y”

这里有一个推理上的精妙:接管不是”读一次快照就完”。run() 在 agents.resume 回来、真正拥有这个运行区间之后,又对 agent.session 的活日志 assertAdoptable 了一次。注释说:resume 时的检查读的是快照,而在快照与现在这个区间之间,某个 overlay 仍可能追加一条 preset 选择;所以要在提交任务之前,对着 runner 实际持有的日志重读一遍闸门。一次”快照闸门 + 活日志闸门”的双重校验,把”半路被人改了 preset”这个时间窗焊死了。这是为什么 headless 敢在 CI 里重复接管同一个会话——它从不信任任何一份快照是最终事实。

本节源码依据(5 处)

运行、流式与收尾:stdout 是答案,stderr 是过程

Agent 就位后,run() 先 agent.whenIdle() 等它彻底静下来(创建/resume 本身可能带一轮初始化),记下 firstSeq = agent.session.seq——这是本次任务在会话日志里的起始水位。然后开两条输出通道:若 --json,projectJsonRun(ctx, agent, io.stdout, { cwd }) 把整个运行投影成 NDJSON 写进 stdout;否则 streamReasoning(ctx, agent, io.stderr) 把推理流实时吐到 stderr。stdout 与 stderr 的职责被严格分开:过程走 stderr,答案走 stdout——这样脚本可以 answer=$(dsh ...) 只抓最后一行答案,而模型的”思考过程”不会污染它。

推进任务就是一行 agent.followup(createUserMessage({ content: [{type:'text', text: task}], source: {kind:'user'} })),再 await agent.whenIdle()。这个 whenIdle 是”推到静止”的实现:它等所有进行中的工具调用、子代理、事件回调都沉降。finally 里 stopReasoning?.() 关掉 stderr 流。

收尾三件套,顺序不能乱:

步骤动作为什么必须在这
sessions.flush把会话日志落盘进程马上要退,不落盘会话就丢了
summarize(session, firstSeq)算本次运行的结果与 reason只统计 firstSeq 之后的新事件
输出 + appExit写 stdout/JSON,按 reason 退 0/1退出码是给编排器看的契约

outcome.reason?.kind === 'completed' 时 io.exit(0),否则 io.exit(1);若是 error,先往 stderr 写一行 dsh: code: message。注意 io.exit 不是 process.exit——它是 ctx.get('appExit') 注入进来的请求。apply() 在挂载时第一件事就是取 appExit,取不到直接抛 headless-runner: the launcher must provide ctx.appExit before the tree mounts。headless 自己不决定进程何时死,它只请求退出;真正的退出权在 launcher 手里——这让桌面、Web 那些常驻面孔可以把”一次性 headless”当成一个普通插件挂进来,而不用担心它的退出会把宿主整个带走。

—json:把运行拍成事件流

不开 --json,stdout 只有最后那一行答案;开了,stdout 变成一台 NDJSON 事件发生器。projectJsonRun 订阅 agent 的会话事件,把每一步翻成一行 JSON:status 事件标 turn_start/step_start/step_end/turn_end,块级事件里 reasoning 块翻成 thinking、text 块翻成 text,工具调用翻成 tool_call、其结果翻成 tool_result;运行开始先吐一行 session(sessionId + cwd),最后吐一行 final(最终文本)。boundJsonLine 负责把一个对象序列化成一行(无换行),保证 NDJSON 不被流内文本冲断。

这层投影的意义是把 headless 从”只给一个答案”升级成”给一条可观测的运行轨迹”——外部编排器既能拿到最终 final.text,也能逐行渲染进度、统计工具调用、在 turn_end 里读到 reason。它和桌面/Web 面孔订阅的是同一份会话事件,只是出口从 UI 变成了 stdout 的换行分隔流。

本节源码依据(5 处)

七张脸里最薄的一张

把 headless 和同部的其他产品面并排看,它的薄是刻意的:

产品面谁在驱动会话生命周期输出出口退出语义
headless(本章)脚本/CI 一句话一次运行后请求退出stdout 答案 / stderr 过程 / NDJSONappExit(0/1) 请求制
JSON-RPC server(下一章)常驻程序shutdown 才 disposestdio 帧flush→dispose→exit(0)
Web / 桌面(46/48)人在浏览器里常驻,多会话并行WebSocket 投影流不退出

这张薄脸的代价是它不组合 preset 名册。run() 的 setup 注释把话说死:这个 bundle 不装 preset roster,模型面向的行放在 host 平面、agent 从全局层读;真要配名册的部署,得自己按 agent-preset-registry README 的”Composing a child agent”在 setup 里 join 一个。这也是为什么 assertAdoptable 要拒掉跑在 preset 下的会话——一次性 runner 手里没有组合 preset 的能力,硬接管一个带 preset 的会话,模型选择就会和它被训练时的身份对不上。薄,是边界清楚;边界清楚,才敢在 CI 里被反复 spawn。

本节源码依据(2 处)

小结:一次性,不等于不严肃

headless 看起来只是”CLI 跑一句话”,但它把同一个运行时的纪律完整搬了过来:不创建半初始化的 Agent(等 loader)、不接管别人正在跑的会话(活会话拒绝)、不跨目录接管(cwd 闸门)、不信任快照(双重断言)、不退丢失的日志(sessions.flush)、不越权退出(appExit 请求制)。它是七个产品面里最”薄”的一张——没有 UI、没有常驻——却因为薄而成为脚本与编排器唯一敢 spawn 的那张脸:一次运行,一个会话水位,一个确定的退出码,一条可重放的 NDJSON 轨迹。

下一张产品面,是这张薄脸底下那根机器对机器的数据线:第 44 章讲 JSON-RPC server——不是给人看的 CLI,而是给程序调用的常驻运行时入口。