From ba0757223d6cc27bb4dc718d9d210ebc2141185a Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 17:04:09 +0800 Subject: [PATCH] feat(web): add a web render-intent card for web_search and web_fetch results web_search and web_fetch returned only model-facing text, whose markdown source list is lossy (title-or-hostname label, snippet and date concatenated), so a client could not recover the structured sources. Add a card:'web' result view with a kind discriminant ('search' carrying structured sources + answer + truncated, 'fetch' carrying url + statusCode + truncated), projected through each tool's output.presentationMeta and read back in presentResult. A UI without the web card falls back to content; the TUI is unchanged. The web consumer is a follow-up. --- .../2026-07-30-web-result-card.i18n.yaml | 6 + .../feature/2026-07-30-web-result-card.md | 46 +++++ .../feature/2026-07-30-web-result-card.zh.md | 45 +++++ packages/core/tools/src/index.ts | 4 + packages/core/tools/src/presentation.ts | 84 ++++++++- packages/web/tool-web/src/fetch.ts | 76 +++++++- packages/web/tool-web/src/index.ts | 6 +- packages/web/tool-web/src/search.ts | 104 ++++++++++- packages/web/tool-web/tests/tool-web.spec.ts | 166 ++++++++++++++++++ 9 files changed, 532 insertions(+), 5 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-result-card.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml new file mode 100644 index 0000000000..498f6557f8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-result-card.md +2026-07-30-web-result-card.md: 675c93ebfda0d74b2809e5d12fb55df85020646e +2026-07-30-web-result-card.zh.md: be02cbbfa272590c43b088d194dda0dbfab7adc0 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card.md new file mode 100644 index 0000000000..675c93ebfd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.md @@ -0,0 +1,46 @@ +# Agent Note: Web result card — a structured render intent for web_search and web_fetch + +Status: implemented + +English | [中文](2026-07-30-web-result-card.zh.md) + +## Problem + +The `web_search` and `web_fetch` tools each declared a generic pending card (`presentCall`, `kind: 'search'`/`'fetch'`) but no `presentResult`, so a completed web call reached a UI only as the model-facing render text. For a web frontend that wants to render a citation list or a fetch summary, that text is lossy: `web_search`'s render collapses each source's `title`, `snippet`, and `publishedAt` into one free-text markdown line labelled by title OR hostname (`formatSearchOutput` in `packages/web/tool-web/src/search.ts`), so reparsing the render cannot recover the per-source fields; and `web_fetch`'s render carries `url` and `statusCode` only in a header line. The render-intent contract ([tagged union](../architecture/2026-07-02-tool-render-intent-union.md)) had no arm a web tool could declare to carry a structured result. + +## Decision + +Add one `card: 'web'` result arm to `ToolResultView` (`packages/core/tools/src/presentation.ts`), a union `WebResultView = WebSearchResultView | WebFetchResultView` discriminated by a `kind: 'search' | 'fetch'` field, plus a `WebSource` shape for one citeable source. Both tools now declare `presentResult`. + +One tag with a `kind` discriminant, not two tags. Both calls are web retrieval and a web frontend renders them with one component family (a retrieval card whose body differs by kind), so a shared `card` keeps every card consumer's switch to one added arm and lets the frontend branch on `kind` inside it. Two tags would force every present and future consumer to add two arms for what is one visual family. The `kind` values match the two tools' existing generic call-view `kind`s, so a call and its result read as the same category. + +`presentationMeta` is mandatory here, not a convenience. The structured result object a tool returns from `execute` does NOT reach a client over the wire — only the model-facing `render` text and, when declared, the `output.presentationMeta` JSON projected onto the `tool/result` event's `meta` do. Because the render text is lossy for `web_search`'s sources, projecting the sources through `presentationMeta` is the only faithful route to `{url, title?, snippet?, publishedAt?}` at the consumer. This mirrors the write/edit diff template (`packages/fs/tool-fs/src/diff.ts`): a `*MetaFromValue` projector feeds `output.presentationMeta`, and a `*MetaFromResult` narrower reads `result.meta` back with a defensive fallback to the generic card. `web_fetch`'s meta carries `url`/`statusCode`/`truncated` only; its body is already markdown in the result content, so it is not duplicated into meta. + +Each result view carries an optional `content?: ContentBlock[]` set to the model-facing result content. A UI without the `web` capability — including the TUI, whose transcript renderer has no `web` arm — renders that content through its existing generic/default path (`packages/ui/tui/src/components/transcript.ts`, `renderBody`'s `view.content ?? this.result?.content`), so the new tag needs no dedicated TUI arm and the TUI keeps compiling and rendering the text. + +`presentResult` returns `undefined` (the generic card) on an error result and on absent or malformed `meta`, because presentation runs on replay of arbitrary logged results (possibly from an older schema) and must never throw. The narrowers validate every field defensively; an empty source list is valid meta, not malformed. + +## Consequences + +The web frontend consumer is a separate later PR: this PR adds the contract arm and makes the two tools emit it, with no client-side rendering. Any existing `ToolResultView` consumer that switches exhaustively must add a `web` arm; the TUI does not switch exhaustively and needs none. `apiproxy`'s session schema already accepts any `card` string (`packages/host/apiproxy/src/api/sessions.schema.ts`), so the new view crosses the wire without a schema change. + +A future web tool that wants this card declares `presentResult` returning a `card: 'web'` view with its own `kind`; adding a third `kind` is a union edit plus the frontend's branch, not a new card tag. + +## Alternatives considered + +**Two card tags (`web-search`, `web-fetch`).** Rejected: it doubles the arm count at every card consumer for one visual family, and the two shapes already share enough (a titled retrieval card with fallback content) that a `kind` discriminant expresses the difference without a second tag. + +**Reparse the render text in `presentResult` instead of projecting meta.** Rejected for `web_search`: the render's source list is lossy (title-or-hostname label, snippet and date concatenated into free text), so reparsing cannot faithfully recover the structured fields. `presentationMeta` is the only route that preserves them. + +**Carry the fetch body in meta too.** Rejected: the body is already the model-facing markdown in the result content, and duplicating it into meta would double the persisted payload for no gain; the view points a UI at the existing content. + +## Testing + +`packages/web/tool-web/tests/tool-web.spec.ts` covers, per-file to the 100% gate: `searchMetaFromValue`/`fetchMetaFromValue` projection including omission of absent optional fields; `searchMetaFromResult`/`fetchMetaFromResult` narrowing with a round-trip and every malformed-shape rejection (non-object, wrong field types, a malformed source entry) plus the empty-source-list accept; `presentSearchResult`/`presentFetchResult` typed views including the truncated signal, the error-result fallback, and the malformed-meta fallback; and two real-registry executions asserting the tool projects the meta onto `result.meta` and its registered `presentResult` derives the `card: 'web'` view. + +## Related + +- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this extends with the `web` arm. +- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent that carried the bash `terminal` render intent to the browser; the web frontend consumer of this arm is its analogue, deferred to a later PR. + + diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md new file mode 100644 index 0000000000..be02cbbfa2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md @@ -0,0 +1,45 @@ +# Agent Note: Web result card — a structured render intent for web_search and web_fetch + +Status: implemented + +[English](2026-07-30-web-result-card.md) | 中文 + +## Problem + +`web_search` 与 `web_fetch` 工具各自声明了一个 generic 待定卡片(`presentCall`,`kind: 'search'`/`'fetch'`),但没有 `presentResult`,因此一个已完成的 web 调用抵达 UI 时只剩下面向模型的 render 文本。对于想渲染引用列表或抓取摘要的 web 前端而言,该文本是有损的:`web_search` 的 render 把每个来源的 `title`、`snippet`、`publishedAt` 压进一行以 title 或 hostname 标注的自由文本 markdown(`packages/web/tool-web/src/search.ts` 中的 `formatSearchOutput`),因此重新解析 render 无法恢复各来源字段;`web_fetch` 的 render 也仅在一行 header 里携带 `url` 与 `statusCode`。渲染意图契约([标签联合类型](../architecture/2026-07-02-tool-render-intent-union.md))此前没有一个可供 web 工具声明、用以携带结构化结果的分支。 + +## Decision + +向 `ToolResultView`(`packages/core/tools/src/presentation.ts`)新增一个 `card: 'web'` 结果分支,它是以 `kind: 'search' | 'fetch'` 字段作判别的联合 `WebResultView = WebSearchResultView | WebFetchResultView`,并附一个表示单个可引用来源的 `WebSource` 形状。两个工具现在都声明 `presentResult`。 + +采用一个标签加 `kind` 判别,而非两个标签。两个调用都是 web 检索,web 前端会用同一族组件渲染它们(一个检索卡片,正文按 kind 不同),因此共用一个 `card` 让每个 card 消费者的 switch 只需新增一个分支,并让前端在其内部按 `kind` 分岔。两个标签会迫使当前及未来每个消费者为本属同一视觉族的东西添加两个分支。这两个 `kind` 取值与两个工具既有的 generic 调用视图 `kind` 一致,因此一个调用与它的结果读起来是同一类别。 + +`presentationMeta` 在这里是必需的,而非便利手段。工具从 `execute` 返回的结构化结果对象**不会**经由 wire 抵达客户端——只有面向模型的 `render` 文本,以及(声明时)投影到 `tool/result` 事件 `meta` 上的 `output.presentationMeta` JSON 会。由于 render 文本对 `web_search` 的来源是有损的,经 `presentationMeta` 投影来源,是在消费端得到忠实 `{url, title?, snippet?, publishedAt?}` 的唯一途径。这照搬 write/edit 的 diff 模板(`packages/fs/tool-fs/src/diff.ts`):一个 `*MetaFromValue` 投影器喂给 `output.presentationMeta`,一个 `*MetaFromResult` 收窄器读回 `result.meta`,并在失败时防御性回退到 generic 卡片。`web_fetch` 的 meta 只携带 `url`/`statusCode`/`truncated`;其正文已是结果内容中的 markdown,因此不重复写入 meta。 + +每个结果视图携带一个可选的 `content?: ContentBlock[]`,设为面向模型的结果内容。不具备 `web` 能力的 UI——包括其 transcript 渲染器没有 `web` 分支的 TUI——经由既有的 generic/默认路径渲染该内容(`packages/ui/tui/src/components/transcript.ts` 中 `renderBody` 的 `view.content ?? this.result?.content`),因此新标签无需专门的 TUI 分支,TUI 继续编译并渲染文本。 + +`presentResult` 在错误结果、以及 `meta` 缺失或畸形时返回 `undefined`(即 generic 卡片),因为 presentation 会在对任意已记录结果(可能来自旧 schema)的重放中运行,绝不能抛错。收窄器防御性地校验每个字段;空来源列表是有效 meta,而非畸形。 + +## Consequences + +web 前端消费者是一个独立的后续 PR:本 PR 新增契约分支并让两个工具发出它,不含客户端渲染。任何做穷尽 switch 的现有 `ToolResultView` 消费者都必须新增一个 `web` 分支;TUI 并不穷尽 switch,无需新增。`apiproxy` 的会话 schema 已接受任意 `card` 字符串(`packages/host/apiproxy/src/api/sessions.schema.ts`),因此新视图无需 schema 变更即可跨 wire。 + +未来想用此卡片的 web 工具,声明一个返回带自有 `kind` 的 `card: 'web'` 视图的 `presentResult`;新增第三个 `kind` 是一次联合类型编辑加前端的分岔,而非一个新的 card 标签。 + +## Alternatives considered + +**两个 card 标签(`web-search`、`web-fetch`)。** 否决:它在每个 card 消费者处为一个视觉族翻倍分支数,而两个形状已共享得够多(一个带回退内容的带标题检索卡片),`kind` 判别无需第二个标签即可表达差异。 + +**在 `presentResult` 里重新解析 render 文本,而非投影 meta。** 对 `web_search` 否决:render 的来源列表是有损的(title 或 hostname 标签,snippet 与日期拼进自由文本),因此重新解析无法忠实恢复结构化字段。`presentationMeta` 是唯一保留它们的途径。 + +**把抓取正文也放进 meta。** 否决:正文已是结果内容中面向模型的 markdown,把它复制进 meta 会为无收益的目的翻倍持久化载荷;视图让 UI 指向既有内容。 + +## Testing + +`packages/web/tool-web/tests/tool-web.spec.ts` 覆盖以下内容,满足按文件 100% 的门禁:`searchMetaFromValue`/`fetchMetaFromValue` 投影,含对缺席可选字段的省略;`searchMetaFromResult`/`fetchMetaFromResult` 收窄,含一次往返与每种畸形形状的拒绝(非对象、字段类型错误、畸形来源条目)以及空来源列表的接受;`presentSearchResult`/`presentFetchResult` 类型化视图,含 truncated 信号、错误结果回退与畸形 meta 回退;以及两次真实注册表执行,断言工具把 meta 投影到 `result.meta` 上,其注册的 `presentResult` 推导出 `card: 'web'` 视图。 + +## Related + +- [标签化的工具调用渲染意图联合类型](../architecture/2026-07-02-tool-render-intent-union.md) —— 本卡片以 `web` 分支扩展的 `card` 标签词汇表。 +- [Web terminal card](2026-07-28-web-terminal-card.md) —— 把 bash `terminal` 渲染意图带到浏览器的先例;本分支的 web 前端消费者是它的对应物,推迟到后续 PR。 + diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 2caaaa8276..e825a1a0dc 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -82,6 +82,10 @@ export type { GenericResultView, TerminalResultView, DiffResultView, + WebResultView, + WebSearchResultView, + WebFetchResultView, + WebSource, } from './presentation.ts' declare module 'cordis' { diff --git a/packages/core/tools/src/presentation.ts b/packages/core/tools/src/presentation.ts index 17b88b822f..f73ddb06d2 100644 --- a/packages/core/tools/src/presentation.ts +++ b/packages/core/tools/src/presentation.ts @@ -125,7 +125,7 @@ export interface DiffCallView { * `ToolDefinition.presentResult`; omitting the method keeps the pending * title and renders the raw result content. */ -export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView +export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView /** * The default completed card: an optional replacement title and reformatted @@ -176,3 +176,85 @@ export interface DiffResultView { /** The change to show, in file order — applied contextual hunks, or a whole-file diff when there is no before-image. */ diffs: FileDiff[] } + +/** + * One citeable source in a completed {@link WebSearchResultView}, the faithful + * projection of one web-search source. The render text a web tool returns is + * lossy — its markdown list collapses `title`/`snippet`/`publishedAt` into one + * free-text line and labels a source by title OR hostname — so a UI cannot + * reliably recover these fields by reparsing that text. A tool therefore + * projects this structured shape through `output.presentationMeta`, and its + * `presentResult` reads it back. + */ +export interface WebSource { + /** The source URL. */ + url: string + /** The source title, when the provider returned one. */ + title?: string + /** A short excerpt or summary, when the provider returned one. */ + snippet?: string + /** Publication/crawl timestamp as a provider-supplied ISO-8601 string, when present. */ + publishedAt?: string +} + +/** + * A completed web retrieval rendered as a structured card by a capable UI. Set + * by a web tool whose call retrieves from the web (`web_search`, `web_fetch`). + * One `kind`-tagged union carries both shapes because both are web retrieval and + * a UI renders them with one component family; a UI switches on `kind`. An + * incapable UI falls back to `content` (the reformatted model-facing text). This + * is the result-time analogue of the `web_search`/`web_fetch` calls' generic + * call views (`kind: 'search'`/`'fetch'`); those tools keep their generic + * pending card and add only this completed card. + */ +export type WebResultView = WebSearchResultView | WebFetchResultView + +/** + * The completed state of a `web_search` call: the structured sources the model + * cited, an optional provider answer, and whether the source list was cut to the + * result cap. A capable UI renders the sources as a citation list; an incapable + * UI renders `content`. + */ +export interface WebSearchResultView { + card: 'web' + kind: 'search' + /** Replacement title for the completed call. Omit to keep the pending-state title. */ + title?: string + /** The faithful, structured sources — the field render text cannot losslessly carry. */ + sources: WebSource[] + /** The provider-generated answer text, when any. */ + answer?: string + /** True when the tool cut the source list to its result cap. */ + truncated: boolean + /** + * UI-facing fallback content (harness {@link ContentBlock}s), reformatted from + * the model-facing result. A UI without the `web` capability renders this. + * Omit to let the UI render the raw result content. + */ + content?: ContentBlock[] +} + +/** + * The completed state of a `web_fetch` call: the fetched URL, its HTTP status, + * and whether the content was cut. The body itself is already markdown in the + * result content, so this card carries the retrieval summary and leaves the body + * to `content`. + */ +export interface WebFetchResultView { + card: 'web' + kind: 'fetch' + /** Replacement title for the completed call. Omit to keep the pending-state title. */ + title?: string + /** The final URL after allowed redirects. */ + url: string + /** HTTP status code of the fetched response. */ + statusCode: number + /** True when the provider or the output cap cut the content. */ + truncated: boolean + /** + * UI-facing fallback content (harness {@link ContentBlock}s): the already-markdown + * body. A UI without the `web` capability renders this. Omit to let the UI + * render the raw result content. + */ + content?: ContentBlock[] +} diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts index 75108f663c..246505adb5 100644 --- a/packages/web/tool-web/src/fetch.ts +++ b/packages/web/tool-web/src/fetch.ts @@ -9,7 +9,7 @@ import type { Context } from 'cordis' import TurndownService from 'turndown' import { gfm } from '@joplin/turndown-plugin-gfm' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { GenericCallView } from '@deepseek-ai/dsh-tools' +import type { GenericCallView, JsonValue, ToolResult, WebFetchResultView } from '@deepseek-ai/dsh-tools' import type { WebFetchBody, WebFetchResult } from '@deepseek-ai/dsh-web' import { assertNever } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -278,6 +278,78 @@ export function presentFetchCall(args: { url: string }): GenericCallView { return { card: 'generic', title: args.url, kind: 'fetch', rawInput: args.url } } +/** + * The `web_fetch` tool's private `tool/result` `meta` payload: the fetch summary + * a UI cannot recover from the model-facing render text without reparsing its + * header line. Attached opaquely (as `JsonValue`) on the tool result and + * persisted with the session log, so `presentResult` reproduces the fetch card + * on replay. The body itself is already markdown in the result content, so it is + * not duplicated here. + */ +export interface WebFetchMeta { + /** The final URL after allowed redirects. */ + url: string + /** HTTP status code of the fetched response. */ + statusCode: number + /** True when the provider or the output cap cut the content. */ + truncated: boolean +} + +/** The `web_fetch` canonical output value projected into presentation meta. */ +type WebFetchValue = { + url: string + statusCode: number + truncated: boolean +} + +/** + * Project a validated `web_fetch` output value into its replayable presentation + * meta ({@link WebFetchMeta} as opaque JSON). + * + * @param value - the canonical `web_fetch` output value. + * @returns the URL, status code, and truncation flag. + */ +export function fetchMetaFromValue(value: WebFetchValue): JsonValue { + return { url: value.url, statusCode: value.statusCode, truncated: value.truncated } +} + +/** + * Narrow opaque live or replayed result metadata to a {@link WebFetchMeta}. + * Malformed metadata returns `undefined` so presentation can fall back to the + * generic card instead of throwing during replay. + * + * @param meta - result metadata. + * @returns the validated fetch meta, or `undefined` for absent or malformed data. + */ +export function fetchMetaFromResult(meta: unknown): WebFetchMeta | undefined { + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined + const { url, statusCode, truncated } = meta as Record + if (typeof url !== 'string' || typeof statusCode !== 'number' || typeof truncated !== 'boolean') return undefined + return { url, statusCode, truncated } +} + +/** + * Completed-call presentation: a `web` fetch card carrying the retrieval summary + * from `meta` alongside the already-markdown body as fallback content. + * + * @param result - the final model-facing tool result; `meta` carries the summary. + * @returns the fetch result view, or `undefined` (generic card) on failure or + * malformed meta. + */ +export function presentFetchResult(result: ToolResult): WebFetchResultView | undefined { + if (result.isError) return undefined + const meta = fetchMetaFromResult(result.meta) + if (meta === undefined) return undefined + return { + card: 'web', + kind: 'fetch', + url: meta.url, + statusCode: meta.statusCode, + truncated: meta.truncated, + content: result.content, + } +} + /** * Register the `web_fetch` tool and its system-prompt guidance. * @@ -333,6 +405,7 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number, maxOutputChar }, }, render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value, maxOutputChars) }], + presentationMeta: (_args, value) => fetchMetaFromValue(value), }, timeoutMs, // Provider reads do not mutate parent-agent state. @@ -351,5 +424,6 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number, maxOutputChar } }, presentCall: presentFetchCall, + presentResult: (_args, result) => presentFetchResult(result), })) } diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts index 74585b3cab..397e2bf7bb 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -12,8 +12,10 @@ import type {} from '@deepseek-ai/dsh-web' import { applyWebSearchTool, WEB_SEARCH_MAX_RESULTS } from './search.ts' import { applyWebFetchTool } from './fetch.ts' -export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall } from './search.ts' -export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall } from './fetch.ts' +export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall, presentSearchResult, searchMetaFromValue, searchMetaFromResult } from './search.ts' +export type { WebSearchMeta } from './search.ts' +export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall, presentFetchResult, fetchMetaFromValue, fetchMetaFromResult } from './fetch.ts' +export type { WebFetchMeta } from './fetch.ts' /** Cordis plugin name used by loader diagnostics. */ export const name = 'tool-web' diff --git a/packages/web/tool-web/src/search.ts b/packages/web/tool-web/src/search.ts index 816650e4b0..792adcc5e3 100644 --- a/packages/web/tool-web/src/search.ts +++ b/packages/web/tool-web/src/search.ts @@ -7,7 +7,7 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { GenericCallView } from '@deepseek-ai/dsh-tools' +import type { GenericCallView, JsonValue, ToolResult, WebSearchResultView, WebSource } from '@deepseek-ai/dsh-tools' import type { WebSearchResult } from '@deepseek-ai/dsh-web' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -84,6 +84,106 @@ export function presentSearchCall(args: { query: string }): GenericCallView { return { card: 'generic', title: args.query, kind: 'search', rawInput: args.query } } +/** + * The `web_search` tool's private `tool/result` `meta` payload: the structured + * sources, the optional provider answer, and the truncation flag. Attached + * opaquely (as `JsonValue`) on the tool result and persisted with the session + * log, so `presentResult` reproduces the search card on replay. The render text + * is lossy — its markdown source list collapses each source's title, snippet, + * and date into one free-text line labelled by title OR hostname — so reparsing + * that text cannot recover the per-source fields; this projection is the only + * faithful route to them. + */ +export interface WebSearchMeta { + /** The faithful structured sources, in result order. */ + sources: WebSource[] + /** True when the tool cut the source list to its result cap. */ + truncated: boolean + /** The provider-generated answer text, when any. */ + answer?: string +} + +/** The `web_search` canonical output value projected into presentation meta. */ +type WebSearchValue = { + content?: string + sources: readonly WebSource[] + truncated: boolean +} + +/** + * Project a validated `web_search` output value into its replayable + * presentation meta ({@link WebSearchMeta} as opaque JSON). + * + * @param value - the canonical `web_search` output value. + * @returns the structured sources, the truncation flag, and the answer when present. + */ +export function searchMetaFromValue(value: WebSearchValue): JsonValue { + return { + sources: value.sources.map(source => ({ + url: source.url, + ...source.title !== undefined ? { title: source.title } : {}, + ...source.snippet !== undefined ? { snippet: source.snippet } : {}, + ...source.publishedAt !== undefined ? { publishedAt: source.publishedAt } : {}, + })), + truncated: value.truncated, + ...value.content !== undefined ? { answer: value.content } : {}, + } +} + +/** Whether `value` is a valid {@link WebSource} (defensive narrowing from opaque `meta`). */ +function isWebSource(value: unknown): value is WebSource { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const { url, title, snippet, publishedAt } = value as Record + return typeof url === 'string' + && (title === undefined || typeof title === 'string') + && (snippet === undefined || typeof snippet === 'string') + && (publishedAt === undefined || typeof publishedAt === 'string') +} + +/** + * Narrow opaque live or replayed result metadata to a {@link WebSearchMeta}. + * Malformed metadata returns `undefined` so presentation can fall back to the + * generic card instead of throwing during replay. + * + * @param meta - result metadata. + * @returns the validated search meta, or `undefined` for absent or malformed data. + */ +export function searchMetaFromResult(meta: unknown): WebSearchMeta | undefined { + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined + const { sources, truncated, answer } = meta as Record + if (!Array.isArray(sources) || !sources.every(isWebSource)) return undefined + if (typeof truncated !== 'boolean') return undefined + if (answer !== undefined && typeof answer !== 'string') return undefined + return { + sources, + truncated, + ...answer !== undefined ? { answer } : {}, + } +} + +/** + * Completed-call presentation: a `web` search card carrying the faithful + * structured sources from `meta` alongside the model-facing text as fallback + * content. + * + * @param result - the final model-facing tool result; `meta` carries the sources. + * @returns the search result view, or `undefined` (generic card) on failure or + * malformed meta. + */ +export function presentSearchResult(result: ToolResult): WebSearchResultView | undefined { + if (result.isError) return undefined + const meta = searchMetaFromResult(result.meta) + if (meta === undefined) return undefined + return { + card: 'web', + kind: 'search', + sources: meta.sources, + truncated: meta.truncated, + ...meta.answer !== undefined ? { answer: meta.answer } : {}, + content: result.content, + } +} + /** * Register the `web_search` tool and its system-prompt guidance. * @@ -131,6 +231,7 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs: }, }, render: (_args, value) => [{ type: 'text', text: formatSearchOutput(value) }], + presentationMeta: (_args, value) => searchMetaFromValue(value), }, timeoutMs, // Provider reads do not mutate parent-agent state. @@ -153,5 +254,6 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs: } }, presentCall: presentSearchCall, + presentResult: (_args, result) => presentSearchResult(result), })) } diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index 2324922046..9fc90396a8 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -14,8 +14,16 @@ import { parseFetchArgs, presentSearchCall, presentFetchCall, + presentSearchResult, + presentFetchResult, + searchMetaFromValue, + searchMetaFromResult, + fetchMetaFromValue, + fetchMetaFromResult, WEB_SEARCH_MAX_RESULTS, } from '@deepseek-ai/dsh-tool-web' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { ToolResult } from '@deepseek-ai/dsh-tools' const testToolSignal = new AbortController().signal @@ -91,6 +99,96 @@ describe('search formatting', () => { }) }) +/** Build a completed non-error tool result with the given meta and text content. */ +function toolResult(meta: unknown, text = 'body', isError = false): ToolResult { + const content: ContentBlock[] = [{ type: 'text', text }] + return { content, isError, ...meta !== undefined ? { meta: meta as never } : {} } +} + +describe('web_search presentation meta and result view', () => { + it('projects sources, answer, and truncation into meta, omitting absent optional fields', () => { + const meta = searchMetaFromValue({ + content: 'an answer', truncated: true, + sources: [ + { url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' }, + { url: 'https://b.test/y' }, + ], + }) + expect(meta).toEqual({ + answer: 'an answer', + truncated: true, + sources: [ + { url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' }, + { url: 'https://b.test/y' }, + ], + }) + }) + + it('omits answer from meta when the provider returned none', () => { + const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] }) + expect(meta).toEqual({ truncated: false, sources: [{ url: 'https://a.test' }] }) + }) + + it('round-trips projected meta back to a typed search meta', () => { + const value = { + content: 'ans', truncated: false, + sources: [{ url: 'https://a.test', title: 'A', snippet: 's', publishedAt: '2026-01-01' }], + } + expect(searchMetaFromResult(searchMetaFromValue(value))).toEqual({ + answer: 'ans', truncated: false, + sources: [{ url: 'https://a.test', title: 'A', snippet: 's', publishedAt: '2026-01-01' }], + }) + }) + + it('presents a completed search as a web/search card carrying the structured sources and fallback content', () => { + const meta = searchMetaFromValue({ + content: 'an answer', truncated: true, + sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }], + }) + expect(presentSearchResult(toolResult(meta, 'rendered'))).toEqual({ + card: 'web', + kind: 'search', + answer: 'an answer', + truncated: true, + sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }], + content: [{ type: 'text', text: 'rendered' }], + }) + }) + + it('omits the answer from the view when meta carries none', () => { + const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] }) + const view = presentSearchResult(toolResult(meta)) + expect(view).toBeDefined() + expect(view && 'answer' in view).toBe(false) + }) + + it('falls back to the generic card on an error result', () => { + const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] }) + expect(presentSearchResult(toolResult(meta, 'body', true))).toBeUndefined() + }) + + it('falls back to the generic card on absent or malformed meta', () => { + expect(presentSearchResult(toolResult(undefined))).toBeUndefined() + expect(searchMetaFromResult(undefined)).toBeUndefined() + expect(searchMetaFromResult(null)).toBeUndefined() + expect(searchMetaFromResult('nope')).toBeUndefined() + expect(searchMetaFromResult([])).toBeUndefined() + expect(searchMetaFromResult({})).toBeUndefined() + expect(searchMetaFromResult({ sources: 'x', truncated: false })).toBeUndefined() + expect(searchMetaFromResult({ sources: [], truncated: 'no' })).toBeUndefined() + expect(searchMetaFromResult({ sources: [], truncated: false, answer: 1 })).toBeUndefined() + expect(searchMetaFromResult({ sources: [null], truncated: false })).toBeUndefined() + expect(searchMetaFromResult({ sources: [{ url: 1 }], truncated: false })).toBeUndefined() + expect(searchMetaFromResult({ sources: [{ url: 'u', title: 2 }], truncated: false })).toBeUndefined() + expect(searchMetaFromResult({ sources: [{ url: 'u', snippet: 2 }], truncated: false })).toBeUndefined() + expect(searchMetaFromResult({ sources: [{ url: 'u', publishedAt: 2 }], truncated: false })).toBeUndefined() + }) + + it('accepts an empty source list as valid meta', () => { + expect(searchMetaFromResult({ sources: [], truncated: false })).toEqual({ sources: [], truncated: false }) + }) +}) + describe('fetch formatting', () => { const NO_CAP = 1_000_000 const HEADER = 'Fetched https://a.test (HTTP 200)\n\n' @@ -259,6 +357,42 @@ describe('fetch formatting', () => { }) }) +describe('web_fetch presentation meta and result view', () => { + it('projects url, status, and truncation into meta', () => { + expect(fetchMetaFromValue({ url: 'https://a.test', statusCode: 404, truncated: true })) + .toEqual({ url: 'https://a.test', statusCode: 404, truncated: true }) + }) + + it('presents a completed fetch as a web/fetch card carrying the summary and the markdown body as fallback content', () => { + const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false }) + expect(presentFetchResult(toolResult(meta, '# Title'))).toEqual({ + card: 'web', + kind: 'fetch', + url: 'https://a.test', + statusCode: 200, + truncated: false, + content: [{ type: 'text', text: '# Title' }], + }) + }) + + it('falls back to the generic card on an error result', () => { + const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false }) + expect(presentFetchResult(toolResult(meta, 'body', true))).toBeUndefined() + }) + + it('falls back to the generic card on absent or malformed meta', () => { + expect(presentFetchResult(toolResult(undefined))).toBeUndefined() + expect(fetchMetaFromResult(undefined)).toBeUndefined() + expect(fetchMetaFromResult(null)).toBeUndefined() + expect(fetchMetaFromResult('nope')).toBeUndefined() + expect(fetchMetaFromResult([])).toBeUndefined() + expect(fetchMetaFromResult({})).toBeUndefined() + expect(fetchMetaFromResult({ url: 1, statusCode: 200, truncated: false })).toBeUndefined() + expect(fetchMetaFromResult({ url: 'u', statusCode: 'x', truncated: false })).toBeUndefined() + expect(fetchMetaFromResult({ url: 'u', statusCode: 200, truncated: 'no' })).toBeUndefined() + }) +}) + describe('tool-web registration', () => { it('registers both tools by default', async () => { const { fiber, ctx } = await mountTools() @@ -323,6 +457,38 @@ describe('tool-web execution through the real registry', () => { await fiber.dispose() }) + it('projects the search sources into the tool result meta and derives its web/search view', async () => { + const result: WebSearchResult = { + content: 'answer', truncated: true, + sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }], + } + const { ctx, fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider(result) }) + const out = await call('web_search', { query: 'q' }) + expect(out.meta).toEqual({ + answer: 'answer', truncated: true, + sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }], + }) + const view = ctx.tools.get('web_search')?.presentResult?.({ query: 'q' }, { content: out.content, isError: out.isError, ...out.meta !== undefined ? { meta: out.meta } : {} }) + expect(view).toMatchObject({ card: 'web', kind: 'search', truncated: true, answer: 'answer' }) + await fiber.dispose() + }) + + it('projects the fetch summary into the tool result meta and derives its web/fetch view', async () => { + const fetchProvider = { + id: 'stub-fetch', + available: () => available, + fetch: (request: { url: string }) => Promise.resolve({ + url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: true, + }), + } + const { ctx, fiber, call } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider }) + const out = await call('web_fetch', { url: 'https://a.test' }) + expect(out.meta).toEqual({ url: 'https://a.test', statusCode: 200, truncated: true }) + const view = ctx.tools.get('web_fetch')?.presentResult?.({ url: 'https://a.test' }, { content: out.content, isError: out.isError, ...out.meta !== undefined ? { meta: out.meta } : {} }) + expect(view).toMatchObject({ card: 'web', kind: 'fetch', url: 'https://a.test', statusCode: 200, truncated: true }) + await fiber.dispose() + }) + it('surfaces a structured WebError when no provider is available', async () => { const { fiber, call } = await mountTools() const out = await call('web_search', { query: 'q' })