- 人工智能
- AI Agent
- Agent 框架
- DeepSeek
【免费下载链接】deepseek-harness
DeepSeek Harness: Everything is a Plugin.
导读
DeepSeek Harness 中read工具的规范化输出是一个结构完整、带行号的行窗口对象{ path, offset, lines, totalLines },但旧版展示层将其压平成一段行号内嵌的纯文本,导致具备渲染能力的客户端无法像渲染 diff 那样渲染一次带行号栏、语法高亮的代码视图。本文依据仓库内已落地的实现笔记(2026-07-30-web-read-card.md),完整剖析本次"Read Card"设计:如何通过为渲染意图联合类型新增第四个card: 'read'标签、借助presentationMeta持久化通道把结构化行窗口投影到会话日志,使实时与回放两条路径上的客户端都能拿到lines/totalLines/lang结构化数据。读完本文,你将理解这套"生产者投影结构化数据、消费方按能力回退"的展示契约,并掌握langFromPath、readMetaFromMeta的防御性收窄策略及其在 read-render.ts 中的源码级实现。
背景:read 工具的规范化输出与扁平化展示之间的落差
在 规范化工具输出约定 落地之后,DeepSeek Harness 的每个工具都必须声明一个规范化输出对象,read工具返回的是:
{ path: string; offset: number; lines: [{ number: number; text: string }]; totalLines: number }这个结构在 read.ts 的output.schema中按 JSON Schema 逐字段声明,并在执行期由buildWindow严格构造。与此同时,面向模型的文本渲染由render投影器负责,输出一段 OpenCode 风格的行号文本:
<path>src/index.ts</path> <type>file</type> <content> 10: import { foo } from './foo' 11: export function bar() { ... } (Showing lines 10-11 of 132. Use offset=12 to continue.) </content>问题出在"规范化输出对象"与"面向模型文本"之间的展示投影层。旧版read的展示回调是这样声明渲染意图的:
presentCall返回GenericCallView(kind: 'read',一个跟随定位);presentResult返回GenericResultView,其唯一内容是被剥掉<path>/<type>/<content>信封后的文本。
也就是说,一个收到该视图的 UI 只看到一个压平的文本块:行号以N:前缀烘焙进文本、文件的编程语言未知、totalLines丢失。有相应能力的客户端无法像渲染 diff 那样渲染一次 read——它想要的是行号栏与内容分离、支持语法高亮的代码视图。
核心问题:结构化数据在线上(wire)无法恢复
为什么不能由客户端自行从文本里解析回结构化数据?因为工具结果的线上形态(on the wire)只包含两样东西:
- 面向模型的
ContentBlock[](已渲染的文本); - 一个不透明的
meta字段。
规范化输出对象留在工具内部,从不到达客户端,也不写入会话日志。依据 规范化工具输出约定,agent loop持久化tool/result事件时只记录content、error和可选的meta,规范化中间值被刻意排除在会话格式之外。因此想要行数组、总数和语言提示的客户端:
- 无法从
N: text文本里解析回它们——按第一个:切分存在歧义、脚注只覆盖部分分支、渲染格式一变就失效; - 唯一的出路是:工具在产生结果时,把结构化窗口投影到那个会随会话日志持久化的通道上——即
meta。
这正是本次 Read Card 设计的出发点。
决策一:渲染意图联合类型新增第四个card标签read(仅结果侧)
渲染意图 union 架构 Note 定义了一套card标签化的封闭判别联合类型:工具的presentCall/presentResult各自声明一种渲染意图,桥接层按card标签switch分发渲染。本次设计为这套词汇新增第四个标签:
// presentResult → ToolResultView(新增 read 分支后) type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | ReadResultView interface ReadResultView { card: 'read' title?: string path: string offset: number lines: ReadFileLine[] // ReadFileLine { number: number; text: string } totalLines: number lang?: string // 语法高亮语言提示,缺失时 UI 渲染纯文本 content?: ContentBlock[] // 剥信封后的文本,供无 read 能力的 UI 兜底 }几个关键设计取舍:
仅结果侧(result-side only):
ToolCallView完全不动,待定状态仍是GenericCallView(kind: 'read',跟随定位)。理由很直接:一次 read 调用在execute返回之前不携带任何文件内容——没有行数组、没有总数,调用时没有任何可展示的结构化信息。这与 bash 终端 card 形成对比:终端 card 两侧都打标签,是因为终端调用在调用时已经携带命令和 cwd;read 调用则两者皆无,若给调用侧打标签只会平添一个空变体。调用侧展示仍由 read.ts 的presentCall负责:一个以文件路径为标题、带read类型图标、offset作为跟随定位行的 generic card。ReadFileLine是共享行单元:{ number, text }与规范化输出对象中的行项、面向模型文本中的N: text行一一对应,保证三处数据语义一致。content兜底:成功路径上presentResult在结构化字段之外总是携带剥信封后的文本。这样,一个不具备 read 卡片渲染能力的 UI,可以经由自己的 generic/default card 分支照常显示文件文本,实现"生产者一次产出、消费方按能力取用"。
决策二:结构化窗口经由presentationMeta投影并持久化
read工具通过output.presentationMeta把结构化窗口投影到meta通道——这与 write/edit 工具把应用后的 diff hunk 投影到同一通道的做法一致(见 规范化工具输出约定)。在 read.ts 中,投影器只是对已有数据的一次轻量复制:
presentationMeta: (_args, value) => { const lang = langFromPath(value.path) return { path: value.path, offset: value.offset, lines: value.lines.map(({ number, text }) => ({ number, text })), totalLines: value.totalLines, ...lang === undefined ? {} : { lang }, } },执行流程:presentationMeta只对一次顶层 surface 调用运行一次,返回的{ path, offset, lines, totalLines, lang? }作为 JSON 被会话校验后存储在结果的meta上。随后presentResult在实时与回放两条路径上都把该meta收窄回ReadResultView——回放时原始的规范化输出对象已不在线上,但持久化的meta让行数组、总数和语言提示都能被还原。
为什么offset必须随 meta 持久化?
一个容易被忽略的细节:offset(窗口请求的 1-based 起始行)也必须携带。原因在于字节上限与行号窗口的交互——当readMaxBytes字节上限低于首个选中行时,buildWindow返回的是一个空的lines数组,但totalLines为正数。此时:
- 没有持久化的
offset,回放出的卡片将无法报告窗口从哪一行开始; - 续读(
Use offset=N to continue)也无从定位应从哪行继续; - 末行推断与文本重解析两种兜底方案都有损。
换言之,offset是空窗口场景下重建窗口位置的唯一可靠依据。
决策三:presentResult的防御性收窄与降级策略
presentResult的完整逻辑在 read.ts,它只在以下情况全部满足时才返回结构化卡片,否则一律返回undefined(即 generic 回退):
- 结果不是错误(
result.isError为 false); - meta 存在且结构合法——由
readMetaFromMeta防御性收窄; - 单个文本块是 read 信封——通过正则
/^<path>[^\n]*<\/path>\n<type>file<\/type>\n<content>\n([\s\S]*)\n<\/content>$/u校验并提取正文。
这里有一个至关重要的设计立场:本 card 出现之前记录的旧日志结果——信封合法但没有持久化meta——有意走同一条undefined路径。此时客户端回退到原始result.content,显示带<path>/<type>/<content>信封的原文,而不是旧展示器返回的那种剥信封 generic card。
这是项目 pre-release 立场("foundation over blast radius")下明确接受的降级:拒绝旧的磁盘格式,而不是为兼容而新增一个剥信封分支。理由有二:本次变更已重录全部已发布测试 fixture(fixture 即测试前置数据),且会话格式本身不承诺向后兼容。
语言提示推导:langFromPath与LANG_BY_EXTENSION
ReadResultView.lang由langFromPath从文件路径推导,实现在 read-render.ts,其查找表LANG_BY_EXTENSION在 read-render.ts 定义:
const LANG_BY_EXTENSION: Readonly<Record<string, string>> = { ts: 'ts', tsx: 'tsx', mts: 'ts', cts: 'ts', js: 'js', jsx: 'jsx', mjs: 'js', cjs: 'js', json: 'json', jsonc: 'json', py: 'py', rb: 'rb', go: 'go', rs: 'rs', java: 'java', c: 'c', h: 'c', cc: 'cpp', cpp: 'cpp', hpp: 'cpp', cxx: 'cpp', cs: 'cs', kt: 'kotlin', swift: 'swift', php: 'php', sh: 'sh', bash: 'sh', zsh: 'sh', yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini', md: 'md', markdown: 'md', mdx: 'mdx', html: 'html', htm: 'html', css: 'css', scss: 'scss', less: 'less', sql: 'sql', xml: 'xml', lua: 'lua', }推导规则可以归纳为四点:
- 取最后路径段:先按
/或\切出最后一个路径段(同时兼容 POSIX 与 Windows 路径分隔符); - 取最后一个点之后的扩展名,且大小写不敏感(
.TS与.ts等价); - 以下情况返回
undefined,卡片随之省略lang、UI 渲染纯文本:dotfile(如.gitignore,开头的点不算扩展名)、无扩展名(如/etc/hosts)、结尾的点、以及任何未知扩展名; - 防御原型污染:查找使用
Object.hasOwn(LANG_BY_EXTENSION, ext)做自有属性检查——一个文件名恰好以foo.constructor、foo.__proto__结尾时,绝不能命中继承成员,否则函数值会流入lang并导致工具输出 JSON 校验失败。
这张表不是可调项(tunable):它是 UI 可以忽略的展示提示,而非随部署变化的选择;未知扩展名优雅降级为纯文本而非报错。它刻意保持小规模而非穷尽的语言注册表——扩展它只需新增一行表项。
备选方案回顾:为什么是这些设计
实现笔记(2026-07-30-web-read-card.md)明确记录了四条被否决的路径,理解它们有助于把握设计的边界:
| 备选方案 | 否决理由 |
|---|---|
在presentResult中重新解析N: text文本 | 按第一个:切分有歧义(行文本自身可含:)、脚注只在部分分支陈述totalLines(精确总数会丢失)、渲染格式一变即失效。presentationMeta直接携带已结构化数据,零解析 |
调用侧也打标签(ReadCallView),镜像终端 card 的两侧对称 | read 调用在执行前无内容、无行数组、无总数,调用侧卡片只会是空变体,重复GenericCallView(kind: 'read')已表达的信息;终端两侧打标签是因为调用时确有数据(命令、cwd) |
| 把结构化窗口放进新服务或旁路通道 | meta已是既定持久化展示通道(write/edit 的应用 diff 也走它),随会话日志免费回放,无需新接线;新服务等于重新发明事件日志已有的持久化与回放 |
| 用 merge-extensible union 替代封闭标签 | 与渲染意图 union 封闭的理由一致:新卡片需要消费代码来渲染,被消费方静默丢弃的变体比编译错误更糟。把read加入封闭 union 是受认可的扩展方式——每个在card上 switch 的消费方因新成员落入 generic default 而继续编译,想要富视图的消费方自行新增分支 |
影响评估
对消费方
ToolResultView从三个成员变成四个。消费方有两个选择:
- 渲染结构化形态:读取
lines/lang/totalLines/offset,渲染成行号栏与内容分离、支持语法高亮的代码视图(与 diff 卡片的渲染体验对齐); - 路由到 generic 路径:因 read card 始终携带
content(剥信封后的文本),generic/default 分支仍能显示完整文件文本。
这次生产者变更,是让结构化数据可触及的后端——它不要求每个消费方在同一时间实现富视图,具备能力的客户端可以先消费,其余客户端不受影响。
对生产者与存储
- read 工具现在为每次顶层 read计算
presentationMeta:一次lines.map加一次langFromPath调用,是对已有数据的极小投影,成本可忽略; meta随会话日志持久化,read 结果在磁盘上略大——它已渲染为文本的行数组,如今也以结构化形式存在一份。这是换取回放时结构可重建的既定代价。
测试与验证
Read Card 的验证覆盖两层,全部在仓库内可查:
单元测试:packages/fs/tool-fs/tests/read-render.spec.ts
该文件(与 read-render.ts 同目录)逐项钉住两个纯函数:
langFromPath:已知扩展名的大小写不敏感匹配;扩展名在最后路径段与最后一个点之后读取;以及所有undefined情形(dotfile、无扩展名、结尾的点、未知扩展名);readMetaFromMeta:含与不含lang的良构收窄;以及每一种拒绝——非对象、数组、缺失或类型错误的path/totalLines/lines、畸形行项、非字符串lang;更关键的是,由于该函数收窄的是不透明的持久化 meta 边界,它还覆盖了"类型正确但语义无效"的回放 JSON:不是 1-based 整数的offset、小于offset的首行number、不是 1-based 整数的行number(0、1.5、NaN、Infinity)、不是非负整数的totalLines(-1、1.5、NaN)、行号重复/递减/超过totalLines,以及正offset处的空窗口(字节上限低于首个选中行)。
集成测试:packages/fs/tool-fs/tests/tools.spec.ts
钉住工具接线:execute把结构化窗口(含与不含lang提示)作为meta附上;presentResult把它收窄为携带剥信封content的card: 'read'视图;以及各拒绝路径(错误结果、非单文本内容、meta 有效但信封畸形、信封有效但 meta 缺失或畸形)一律回退到undefined。两个改动的源文件保持逐文件 100% 覆盖率。
快照证据
本变更携带的是持久化 meta 与扩展后联合类型的快照证据,而非新渲染视图的证据:
- 重录的 ACP 会话 fixture(
fs-read、fs-read-window、fs-edit、fs-policy-reject、fs-write-overwrite、parallel-tool-calls、agent-instructions、workspace-edit)钉住持久化的 readmeta(含{{cwd}}令牌化的路径); cordis-inspect-jsdoc快照钉住四成员的ToolResultView联合类型;- 当时的终端快照还钉住消费方的 generic dim-Markdown 回退保持逐字节一致;结构化卡片自身的组装应用 transcript 则归属于消费它的前端变更。
相关文档导航
- 工具调用展示的带标签渲染意图 union—— 本次以
read结果分支扩展的card标签词汇,含GenericCallView/TerminalCallView/DiffCallView及封闭联合的来龙去脉; - 规范化工具输出约定—— 拥有
presentationMeta持久化通道与output.schema契约,是本次投影方案的底层依据; - Web 终端 card—— 客户端消费结构化卡片的前置先例,read card 遵循相同的"生产者投影、仅结果侧"模式;
- 核心实现:read-render.ts(窗口构建、文本信封、
langFromPath、readMetaFromMeta)、read.ts(read工具注册与三个展示回调); - 测试:read-render.spec.ts、tools.spec.ts;
- 项目立场:AGENTS.md(pre-release stance:foundation over blast radius)。
- 人工智能
- AI Agent
- Agent 框架
- DeepSeek
【免费下载链接】deepseek-harness
DeepSeek Harness: Everything is a Plugin.
相关推荐
qwen-code 的 report_findings 类型化契约:让代码评审发现以结构化数据直达所有客户端
qwen code 的 report_findings 类型化契约:让代码评审发现以结构化数据直达所有客户端 导读 在 qwen code(一个运行于终端中的开
人工智能AI Agent代码智能体工具调用交互助手CLIQwenDeepSeek Harness 搜索结果卡片渲染:grep/glob 结构化 `card: 'search'` 视图的设计与实现
DeepSeek Harness 搜索结果卡片渲染:grep/glob 结构化 card: 'search' 视图的设计与实现 本篇技术指南以 DeepSeek
人工智能AI AgentAgent 框架DeepSeekbash循环读取文件:while read line结构
bash循环读取文件:while read line结构 你是否还在为处理日志文件、配置解析或数据清洗时的逐行读取需求而烦恼?本文将系统讲解 while rea
教程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考