Files
deepseek-harness/.agents/notes/implemented/feature/2026-07-30-search-render-card.zh.md
T

10 KiB
Raw Blame History

Agent Note:搜索渲染意图 —— grep 与 glob 产出结构化搜索卡片

Status: implemented

English | 中文

问题

grep 与 glob 返回结构化的 canonical 值 —— grep 是扁平的 { matches: [{ path, lineNumber, line }] },glob 是 { paths: string[] } —— 但每个 UI 只见过它们面向模型的渲染文本:grep 把匹配按文件头分组、每行 Line N:,glob 打印换行连接的路径列表,两者在内联上限(grepMaxMatches,默认 250;globMaxResults,默认 100)把后续结果落到 spill 文件时都追加一个 spill 脚注。想把搜索结果渲染成可展开的按文件匹配组、或可选择的路径列表的 web 前端,只能去重新解析那段文本。两个工具都已声明调用时的渲染意图(GenericCallView,kind: 'search'),但没有结果时视图,所以已完成的调用回退到渲染原始文本的 generic 卡片。

结构化 canonical 值不跨线传输:只有面向模型的渲染文本、以及当工具声明了 output.presentationMeta 时的一份 JSON 元数据,会经 tool/result 事件到达客户端(canonical-output 契约)。因此携带结构化数据的结果时视图必须把数据投影进 presentationMeta,再在 presentResult 里读回 —— 与 write/edit 的 diff 卡片走同一条路。

决定

packages/core/tools/src/presentation.ts 把 card: 'search' 作为 SearchResultView 加入 ToolResultView 联合,这是一个以 shape 判别的视图,表达两个工具的形状:SearchMatchesResultView(shape: 'matches')以 files: { path, matches: { lineNumber, line }[] }[] 承载 grep 按文件分组的匹配,SearchPathsResultView(shape: 'paths')承载 glob 的扁平 paths: string[]。两者都带 truncated: boolean 与 total: number。

判别子是 shape 而非 kind,是刻意为之:同一个 presentation 模块已经给 GenericCallView 一个 kind: ToolCallKind 字段,其取值恰好包含 'search'(图标类别)。持有 ToolCallView | ToolResultView 的桥接层会看到两个含义不同的 kind 字段;结果变体用 shape 把两者分开。

用一个带两种形状的视图而非两张卡片,因为两个工具是同一个视觉对象 —— 一个搜索结果 —— web 消费方先在一个 card 值上分支,再在 shape 上分支决定行布局。判别式 shape 让每个变体的字段保持非可选(matches 视图总有 files,paths 视图总有 paths),而不是一个所有形状相关字段都可选的单一接口。

该视图不携带结果文本。早期版本曾把面向模型的 result.content 附到视图上;那对每个消费方都是 no-op(TUI 本就回退到 result.content,web 回退读原始 tool/result 内容),却把整段搜索文本又序列化进持久化视图一遍。视图只承载结构化形状;无 search 卡片的 UI 回退到原始 tool/result 内容。

卡片标签只在结果时存在。搜索调用保持为 GenericCallView(kind: 'search'):pending 状态没有匹配或路径可展示,所以 SearchCallView 能携带的东西不会比 generic 标题更多。这是与 terminal 卡片的不对称之处 —— terminal 的调用视图携带执行前就存在的命令、cwd、description;搜索的结构化内容只在 execute 之后才存在。

packages/fs/tool-fs-search/src/presentation.ts 拥有投影与收窄。grepSearchMeta/globSearchMeta 把 canonical 值投影为每个工具声明为 output.presentationMeta 的 SearchMeta 载荷;presentGrepResult/presentGlobResult 经 searchViewFromMeta 把 result.meta 读回。它们消费与面向模型渲染相同的已保留结果 —— search-core.ts 里的 retainGrepMatches/retainGlobPaths 只跑一次内联上限与每行预览预算,render 与投影都取这份产出 —— 所以文本与卡片对哪些结果幸存永不分歧,也没有第二次保留计算。total 是搜索找到的全部结果(截断前);truncated 在上限丢弃了结果时置位。这是截断诚实点:模型看到的是被截断的内联结果加一个 spill 脚注,所以卡片不能把保留页当作完整结果 —— UI 读 truncated/total 显示截断指示,而非宣称模型从未有过的完整性。

meta 有自己的字节预算。 内联上限约束的是条目数,但一次宽泛搜索保留下来的匹配(数百条长行)仍可序列化到数百 KB,而 meta 会随会话日志持久化并在每次请求时重发。部署的最终输出预算(dsh-spill-policy 的 maxInlineBytes)只缩减结果的 content —— PostToolDecision 没有 meta 通道 —— 所以投影自己负责把 meta 约束住。capMetaBytes 丢弃末尾的文件组/路径,直到序列化 meta 装进 searchMetaMaxBytes(配置,默认 64 KiB),并把结果标记 truncated。单个大到自身都装不下的条目会被保留:不变量是可丢弃处一律有界,绝不产出隐藏了真实结果的空卡片。

searchViewFromMeta 防御性地收窄不透明的 meta,对任何畸形或缺失载荷返回 undefined,使在较旧或手工编辑的回放日志上运行的 presenter 回退到 generic 卡片而非抛错。它确实接受零结果载荷(files: [] / paths: [])为合法的空卡片 —— 这是与被镜像的 diffsFromMeta 的刻意偏离(后者拒绝空 diffs),因为零匹配的 grep 是 UI 展示为「no matches」的合法结果,而非缺失的投影。presentResult 对失败结果、对缺失 meta(嵌套 run_code 分发不计算 presentationMeta)、以及对另一工具的 meta 形状(每个 presenter 收窄到自己的 shape)返回 undefined。

SearchMeta 的成员形状是对象字面量 type 别名,而非视图暴露的 SearchFileMatches/SearchLineMatch 接口,因为只有 type 别名可赋给 presentationMeta 返回的 JsonValue 索引签名;两者结构等价,所以投影值仍读回为 SearchResultView。

TUI(packages/ui/tui/src/components/transcript.ts)不需要专门分支:它的结果视图 switch 显式处理 terminal 与 diff,search 视图落入同一个变暗的 generic body,从 this.result?.content 读取面向模型的文本。因为搜索视图不带自己的 content,而本 PR 之前 grep/glob 返回的是 generic 卡片,所以 TUI 输出与无 search 卡片的回退逐字节一致。渲染结构化 files/paths 形状的 web 前端是另一个后续 PR;本 PR 是后端契约及其两个生产者。

考虑过的备选

一个扁平的 SearchResultView 接口,带可选 files? 与 paths?。 否决:它让两个形状相关字段在每个值上都可选,并允许畸形视图同时带两者或都不带。shape 判别式让每个变体的字段保持必需,并让消费方穷尽分支。

复用 kind 作形状判别子。 否决:同一模块里调用视图上的 kind 已经表示 ToolCallKind(图标类别,取值含 'search')。结果视图上再有一个含义不同的 kind,对任何同时持有两者的桥接层都会冲突。

把面向模型的文本作为视图的 content 附上。 否决:对每个当前消费方是 no-op,且把整段搜索文本第二次序列化进持久化视图。视图是结构化形状;文本回退读原始结果内容。

在 PostToolDecision 上加 meta 通道,让 dsh-spill-policy 像约束 content 那样约束 meta。 本 PR 否决:它为一个工具的载荷改动核心工具决策契约与 spill-policy 插件。投影在配置字节上限处约束自己的 meta 是自包含的,且保持 seam 不变。

镜像 terminal 卡片双侧对称的调用时 SearchCallView。 否决:搜索调用在 execute 前没有匹配或路径,视图只会携带 GenericCallView 已有的标题。

后果

grep 与 glob 现在在每次非嵌套的成功调用上计算 presentationMeta,这是对已保留匹配或路径的一次有界投影 —— 与 render 消费的是同一份保留产出,所以没有第二次保留计算,线上也没有翻倍的搜索文本。序列化 meta 受 searchMetaMaxBytes 约束,所以宽泛搜索不再把无界的结构化副本持久化进会话日志。

无 search 卡片的 UI 渲染原始 tool/result 内容,所以没有消费方退化,TUI 也逐字节一致。渲染结构化形状的 web 消费方读 truncated/total 与按文件分组;因为视图只携带保留的、字节有界的页,想要完整结果的 UI 跟随面向模型文本里的 spill 定位符,与模型的做法完全一致。

测试

packages/fs/tool-fs-search/tests/presentation.spec.ts 钉住纯层:groupMatchesByFile 的首见文件顺序;grepSearchMeta/globSearchMeta 在共享保留产出上的投影,total 报告截断前计数、truncated 被带过;保留过程施加的每行预览预算;序列化 meta 字节上限丢弃末尾组/路径同时保留单个超大条目;以及 searchViewFromMeta 对两种良好形状、零结果空卡片、以及每种畸形情形(非对象/数组 meta、缺失或误型的 truncated/total、未知 shape、畸形 files 条目、非字符串 paths)的收窄。packages/fs/tool-fs-search/tests/tools.spec.ts 钉住经真实工具注册表的接线:被截断的 grep/glob execute 在 result.meta 上产出 SearchMeta,presentResult 构建搜索视图(无 content),嵌套 run_code 分发不计算 meta 故 presentResult 回退,失败或跨形状或畸形结果回退到 generic 卡片。搜索包 src 上保持 per-file 100% 覆盖。

相关