fix(tool-web): align fetch card truncation, drop view content copies, sync card docs
Address the code-review bot findings on the web result card: - web_fetch's card truncated now derives from the shared renderFetchOutput helper, matching the effective truncation the model-facing text reflects (provider cap, source cut, or output cap), instead of the provider-only flag. - Drop the redundant content copy from both web result views; a UI without the web capability falls back to the raw tool/result content. Narrow the TUI transcript view.content access accordingly. - Set the result-state title from the call args (query/url) so a window- truncated replay keeps a title. - Project meta from the seam result types rather than hand-rolled value types. - Sync the card vocabulary across core tools README, docs/core-data-structures, the adding-a-tool cookbook, and the tool-web package README (both languages, re-recorded pairings); regenerate the cordis api-catalog and cordis-inspect snapshot; revise the Agent Note.
This commit is contained in:
@@ -2805,7 +2805,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'WebFetchResultView',
|
||||
declaration: 'export interface WebFetchResultView {\n card: \'web\';\n kind: \'fetch\';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n content?: ContentBlock[];\n}',
|
||||
declaration: 'export interface WebFetchResultView {\n card: \'web\';\n kind: \'fetch\';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebResultView',
|
||||
@@ -2833,7 +2833,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'WebSearchResultView',
|
||||
declaration: 'export interface WebSearchResultView {\n card: \'web\';\n kind: \'search\';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n content?: ContentBlock[];\n}',
|
||||
declaration: 'export interface WebSearchResultView {\n card: \'web\';\n kind: \'search\';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebSearchSource',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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 packages/core/tools/README.md
|
||||
README.md: e5adb153e77d7a2d8c4068b016194ab6abb6473e
|
||||
README.zh.md: c67a2f2ee4ac2a9d587c6efbf2b5c60d14fc58c2
|
||||
README.md: e7f395f8c1d6417db856e590f5267cf6887e4d12
|
||||
README.zh.md: acb4c047bf86e36c828882ff751d4be1f627f99e
|
||||
|
||||
@@ -108,7 +108,7 @@ Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. E
|
||||
Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names:
|
||||
|
||||
- Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`.
|
||||
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, or `{ card: 'diff', title?, diffs }`.
|
||||
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content).
|
||||
|
||||
Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary.
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ ctx.tools.register(defineTool({
|
||||
工具可以选择拥有纯 `presentCall()` 和 `presentResult()` 呈现意图,使 UI 无需特殊处理工具名称:
|
||||
|
||||
- 调用视图为 `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`、`{ card: 'terminal', title, description?, cwd? }` 或 `{ card: 'diff', title, diffs, locations? }`。
|
||||
- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }` 或 `{ card: 'diff', title?, diffs }`。
|
||||
- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }` 或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)。
|
||||
|
||||
返回 `undefined` 会选择通用回退。呈现器只依赖其参数和持久结果,因为 UI 会在实时流式输出和日志回放期间调用它们。`output.presentationMeta(args, value)` 为直接接口调用派生 JSON 元数据;该元数据随 `tool/result` 持久化并传回 `presentResult`,而规范值本身仍只存在于执行局部,绝不会回放。嵌套 Code 分发不会计算元数据。`defineTool` 会软验证较旧的日志参数并回退,而不会使回放崩溃。`dsh-tool-bash` 与 `dsh-tool-fs` 是参考实现;[规范输出 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) 规定值/呈现拆分,[呈现意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) 规定卡片词汇。
|
||||
|
||||
|
||||
@@ -179,11 +179,11 @@ export interface DiffResultView {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* projection of one web-search source. The presentation projection of `dsh-web`'s
|
||||
* `WebSearchSource`: that seam type is the authoritative shape (core cannot depend
|
||||
* on the web seam, so the two are declared separately and MUST evolve together).
|
||||
* A web tool projects this shape through `output.presentationMeta` because the
|
||||
* render text cannot losslessly carry it (see the web-result-card Agent Note); its
|
||||
* `presentResult` reads it back.
|
||||
*/
|
||||
export interface WebSource {
|
||||
@@ -202,18 +202,25 @@ export interface WebSource {
|
||||
* 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.
|
||||
* incapable UI falls back to the raw `tool/result` content (this view carries no
|
||||
* `content` copy — see the web-result-card Agent Note). 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.
|
||||
*
|
||||
* The `kind` field here is this union's own discriminant, NOT a
|
||||
* {@link ToolCallKind}: the two values deliberately match the tools' pending
|
||||
* `ToolCallKind` (`'search'`/`'fetch'`) so a call and its result read as one
|
||||
* category, but a new arm is a union edit plus a consumer branch, not any
|
||||
* arbitrary `ToolCallKind` value.
|
||||
*/
|
||||
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`.
|
||||
* result cap. A capable UI renders the sources as a citation list; a UI without
|
||||
* the `web` capability falls back to the raw `tool/result` content.
|
||||
*/
|
||||
export interface WebSearchResultView {
|
||||
card: 'web'
|
||||
@@ -224,21 +231,15 @@ export interface WebSearchResultView {
|
||||
sources: WebSource[]
|
||||
/** The provider-generated answer text, when any. */
|
||||
answer?: string
|
||||
/** True when the tool cut the source list to its result cap. */
|
||||
/** True when the seam cut the source list to honor the 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`.
|
||||
* raw `tool/result` content, so this card carries only the retrieval summary and
|
||||
* a UI without the `web` capability falls back to that content.
|
||||
*/
|
||||
export interface WebFetchResultView {
|
||||
card: 'web'
|
||||
@@ -249,12 +250,10 @@ export interface WebFetchResultView {
|
||||
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.
|
||||
* True when the provider capped the decoded body, or the output cap or a
|
||||
* pre-conversion source cut trimmed the rendered text (the effective
|
||||
* truncation the model-facing text also reflects).
|
||||
*/
|
||||
content?: ContentBlock[]
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
@@ -502,7 +502,10 @@ export class ToolCardComponent implements Component {
|
||||
// rather than under the dim result-output color.
|
||||
return { prelude: [...hunks, footer], lines: [] }
|
||||
}
|
||||
const content = view.content ?? this.result?.content
|
||||
// The web card carries no `content` copy, so a `web` result view falls back
|
||||
// to the raw result content here (`view.card === 'generic'` narrows the union,
|
||||
// mirroring line 392).
|
||||
const content = (view.card === 'generic' ? view.content : undefined) ?? this.result?.content
|
||||
const prelude: string[] = []
|
||||
const lines: string[] = []
|
||||
// The presenter title headlines the body now that the header is a fixed
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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 packages/web/tool-web/README.md
|
||||
README.md: 9b78920b1b6c611118294421dec1e75e381ed5d6
|
||||
README.zh.md: d36258d3a5bd8af6716e1fd9c3384389e8395e23
|
||||
README.md: 7bee0d2d30fbbcf582fd7b60eb5d9130b6bdf888
|
||||
README.zh.md: 3d708839c9ffbdd89df08678fd6997fc6c45ee07
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and `presentCall`. All web access goes through `ctx.web`; this package never imports a concrete provider. Neither tool exposes a model-facing timeout — each tool's cooperative tool-call budget is declared here via config (`fetchTimeoutMs`/`searchTimeoutMs`, attached as `ToolDefinition.timeoutMs`) and enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) (a `tools/execute` wrapper); each tool just forwards `exec.signal` to the seam.
|
||||
The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and the UI presentation projection — `presentCall`, `presentResult` (a `card: 'web'` result card discriminated by `kind: 'search' | 'fetch'`), and the `output.presentationMeta` that carries the structured search sources or the fetch summary the lossy render text cannot (see the [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md)). All web access goes through `ctx.web`; this package never imports a concrete provider. Neither tool exposes a model-facing timeout — each tool's cooperative tool-call budget is declared here via config (`fetchTimeoutMs`/`searchTimeoutMs`, attached as `ToolDefinition.timeoutMs`) and enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) (a `tools/execute` wrapper); each tool just forwards `exec.signal` to the seam.
|
||||
|
||||
Each tool is registered independently; a product that wants only one disables the other via config (`{ search: false }` / `{ fetch: false }`).
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向模型的 web 工具套件 `web_search` 与 `web_fetch`,构建于 [web 能力 seam](../web/README.md)(`ctx.web`)之上。它只负责面向模型的事项:工具名称、JSON Schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 `presentCall`。所有 web 访问都通过 `ctx.web`;该包(package)绝不导入具体提供方。两个工具都不公开面向模型的超时:每个工具的协作式工具调用超时预算通过配置在此声明(`fetchTimeoutMs`/`searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md)(`tools/execute` 包装层)强制执行;每个工具只把 `exec.signal` 转发给 seam。
|
||||
面向模型的 web 工具套件 `web_search` 与 `web_fetch`,构建于 [web 能力 seam](../web/README.md)(`ctx.web`)之上。它只负责面向模型的事项:工具名称、JSON Schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 UI 呈现投影——`presentCall`、`presentResult`(以 `kind: 'search' | 'fetch'` 区分的 `card: 'web'` 结果卡片),以及承载有损渲染文本无法携带的结构化搜索来源或抓取摘要的 `output.presentationMeta`(见 [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md))。所有 web 访问都通过 `ctx.web`;该包(package)绝不导入具体提供方。两个工具都不公开面向模型的超时:每个工具的协作式工具调用超时预算通过配置在此声明(`fetchTimeoutMs`/`searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md)(`tools/execute` 包装层)强制执行;每个工具只把 `exec.signal` 转发给 seam。
|
||||
|
||||
每个工具独立注册;只需要其中一个工具的产品可以通过配置禁用另一个(`{ search: false }`/`{ fetch: false }`)。
|
||||
|
||||
|
||||
@@ -246,26 +246,52 @@ function renderBody(body: WebFetchBody, maxInputChars: number): RenderedBody {
|
||||
/** The truncation notice appended when the provider or the output cap cut content. */
|
||||
const TRUNCATION_FOOTER = '\n\n(Content truncated. Fetch a more specific URL or section for the full text.)'
|
||||
|
||||
/** A rendered fetch output: the model-facing text and its effective truncation. */
|
||||
interface RenderedFetch {
|
||||
/** The complete bounded output — header, rendered body, and truncation footer. */
|
||||
text: string
|
||||
/**
|
||||
* True when the provider capped the body, a pre-conversion source cut applied,
|
||||
* or the complete output exceeded `maxOutputChars`. This is the effective
|
||||
* truncation the returned text reflects (its footer), wider than the
|
||||
* provider-only `WebFetchResult.truncated`.
|
||||
*/
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a fetch result as one model-facing text block, bounded as a whole.
|
||||
* The same cap limits the source prefix processed synchronously, then applies
|
||||
* again where the complete output — header, rendered body, and footer — is known.
|
||||
* Render a fetch result to its bounded model-facing text and effective
|
||||
* truncation. The single source of both the `render` text and the fetch card's
|
||||
* `truncated`, so the card never disagrees with the text the model saw. The cap
|
||||
* limits the source prefix processed synchronously, then applies again where the
|
||||
* complete output — header, rendered body, and footer — is known.
|
||||
*
|
||||
* @param result - the seam's fetch outcome.
|
||||
* @param maxOutputChars - cap on the complete returned string; a cut body gets
|
||||
* the same fetch-something-narrower notice as provider-side truncation.
|
||||
* @returns a `Fetched <url> (HTTP <status>)` header, the rendered body, and a
|
||||
* truncation notice when the provider or the cap cut the content.
|
||||
* @returns the complete `Fetched <url> (HTTP <status>)`-headed text and whether
|
||||
* the provider, a source cut, or the cap trimmed the content.
|
||||
*/
|
||||
export function formatFetchOutput(result: WebFetchResult, maxOutputChars: number): string {
|
||||
export function renderFetchOutput(result: WebFetchResult, maxOutputChars: number): RenderedFetch {
|
||||
const header = `Fetched ${result.url} (HTTP ${result.statusCode})\n\n`
|
||||
const rendered = renderBody(result.body, maxOutputChars)
|
||||
const prefix = `${header}${rendered.text}`
|
||||
const truncated = result.truncated || rendered.sourceTruncated || prefix.length > maxOutputChars
|
||||
const full = `${prefix}${truncated ? TRUNCATION_FOOTER : ''}`
|
||||
if (full.length <= maxOutputChars) return full
|
||||
if (maxOutputChars < TRUNCATION_FOOTER.length) return full.slice(0, maxOutputChars)
|
||||
return `${prefix.slice(0, maxOutputChars - TRUNCATION_FOOTER.length)}${TRUNCATION_FOOTER}`
|
||||
if (full.length <= maxOutputChars) return { text: full, truncated }
|
||||
if (maxOutputChars < TRUNCATION_FOOTER.length) return { text: full.slice(0, maxOutputChars), truncated }
|
||||
return { text: `${prefix.slice(0, maxOutputChars - TRUNCATION_FOOTER.length)}${TRUNCATION_FOOTER}`, truncated }
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a fetch result as one model-facing text block, bounded as a whole.
|
||||
*
|
||||
* @param result - the seam's fetch outcome.
|
||||
* @param maxOutputChars - cap on the complete returned string.
|
||||
* @returns the complete text from {@link renderFetchOutput}.
|
||||
*/
|
||||
export function formatFetchOutput(result: WebFetchResult, maxOutputChars: number): string {
|
||||
return renderFetchOutput(result, maxOutputChars).text
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -284,33 +310,34 @@ export function presentFetchCall(args: { url: string }): GenericCallView {
|
||||
* 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.
|
||||
* not duplicated here. `truncated` is the effective truncation the render text
|
||||
* reflects, which a client cannot recompute (it does not know the deployment's
|
||||
* `fetchMaxOutputChars`); this is why fetch meta is carried, not derived from the
|
||||
* header line (see the web-result-card Agent Note).
|
||||
*/
|
||||
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
|
||||
/** True when the provider, a source cut, or the output cap trimmed the content. */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a validated `web_fetch` output value into its replayable presentation
|
||||
* meta ({@link WebFetchMeta} as opaque JSON).
|
||||
* meta ({@link WebFetchMeta} as opaque JSON). `truncated` is the effective
|
||||
* truncation the model-facing text reflects (via {@link renderFetchOutput}), not
|
||||
* the provider-only `WebFetchResult.truncated`, so the fetch card never disagrees
|
||||
* with the returned text.
|
||||
*
|
||||
* @param value - the canonical `web_fetch` output value.
|
||||
* @returns the URL, status code, and truncation flag.
|
||||
* @param value - the canonical `web_fetch` output value (the seam's result shape).
|
||||
* @param maxOutputChars - the deployment's output cap, the same one
|
||||
* {@link formatFetchOutput} applies to the render text.
|
||||
* @returns the URL, status code, and effective truncation flag.
|
||||
*/
|
||||
export function fetchMetaFromValue(value: WebFetchValue): JsonValue {
|
||||
return { url: value.url, statusCode: value.statusCode, truncated: value.truncated }
|
||||
export function fetchMetaFromValue(value: WebFetchResult, maxOutputChars: number): JsonValue {
|
||||
return { url: value.url, statusCode: value.statusCode, truncated: renderFetchOutput(value, maxOutputChars).truncated }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,23 +357,27 @@ export function fetchMetaFromResult(meta: unknown): WebFetchMeta | undefined {
|
||||
|
||||
/**
|
||||
* Completed-call presentation: a `web` fetch card carrying the retrieval summary
|
||||
* from `meta` alongside the already-markdown body as fallback content.
|
||||
* from `meta`. It sets no `content` copy — a UI without the `web` capability
|
||||
* falls back to the raw `tool/result` content, the already-markdown body (see the
|
||||
* web-result-card Agent Note).
|
||||
*
|
||||
* @param args - the raw tool arguments; `url` becomes the result-state title so a
|
||||
* window-truncated replay that dropped the call head still has one.
|
||||
* @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 {
|
||||
export function presentFetchResult(args: { url: string }, result: ToolResult): WebFetchResultView | undefined {
|
||||
if (result.isError) return undefined
|
||||
const meta = fetchMetaFromResult(result.meta)
|
||||
if (meta === undefined) return undefined
|
||||
return {
|
||||
card: 'web',
|
||||
kind: 'fetch',
|
||||
title: args.url,
|
||||
url: meta.url,
|
||||
statusCode: meta.statusCode,
|
||||
truncated: meta.truncated,
|
||||
content: result.content,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,7 +436,7 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number, maxOutputChar
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value, maxOutputChars) }],
|
||||
presentationMeta: (_args, value) => fetchMetaFromValue(value),
|
||||
presentationMeta: (_args, value) => fetchMetaFromValue(value, maxOutputChars),
|
||||
},
|
||||
timeoutMs,
|
||||
// Provider reads do not mutate parent-agent state.
|
||||
@@ -424,6 +455,6 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number, maxOutputChar
|
||||
}
|
||||
},
|
||||
presentCall: presentFetchCall,
|
||||
presentResult: (_args, result) => presentFetchResult(result),
|
||||
presentResult: (args, result) => presentFetchResult(args, result),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { applyWebFetchTool } 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 { applyWebFetchTool, formatFetchOutput, renderFetchOutput, parseFetchArgs, presentFetchCall, presentFetchResult, fetchMetaFromValue, fetchMetaFromResult } from './fetch.ts'
|
||||
export type { WebFetchMeta } from './fetch.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
|
||||
@@ -88,36 +88,27 @@ export function presentSearchCall(args: { query: string }): GenericCallView {
|
||||
* 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.
|
||||
* log, so `presentResult` reproduces the search card on replay. This projection
|
||||
* is the only faithful route to the per-source fields, which the lossy render
|
||||
* text cannot carry (the owning rationale is the web-result-card Agent Note).
|
||||
*/
|
||||
export interface WebSearchMeta {
|
||||
/** The faithful structured sources, in result order. */
|
||||
sources: WebSource[]
|
||||
/** True when the tool cut the source list to its result cap. */
|
||||
/** True when the seam cut the source list to honor the 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.
|
||||
* @param value - the canonical `web_search` output value (the seam's result shape).
|
||||
* @returns the structured sources, the truncation flag, and the answer when present.
|
||||
*/
|
||||
export function searchMetaFromValue(value: WebSearchValue): JsonValue {
|
||||
export function searchMetaFromValue(value: WebSearchResult): JsonValue {
|
||||
return {
|
||||
sources: value.sources.map(source => ({
|
||||
url: source.url,
|
||||
@@ -163,24 +154,27 @@ export function searchMetaFromResult(meta: unknown): WebSearchMeta | undefined {
|
||||
|
||||
/**
|
||||
* Completed-call presentation: a `web` search card carrying the faithful
|
||||
* structured sources from `meta` alongside the model-facing text as fallback
|
||||
* content.
|
||||
* structured sources from `meta`. It sets no `content` copy — a UI without the
|
||||
* `web` capability falls back to the raw `tool/result` content, which is the
|
||||
* same text (see the web-result-card Agent Note).
|
||||
*
|
||||
* @param args - the raw tool arguments; `query` becomes the result-state title so
|
||||
* a window-truncated replay that dropped the call head still has one.
|
||||
* @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 {
|
||||
export function presentSearchResult(args: { query: string }, result: ToolResult): WebSearchResultView | undefined {
|
||||
if (result.isError) return undefined
|
||||
const meta = searchMetaFromResult(result.meta)
|
||||
if (meta === undefined) return undefined
|
||||
return {
|
||||
card: 'web',
|
||||
kind: 'search',
|
||||
title: args.query,
|
||||
sources: meta.sources,
|
||||
truncated: meta.truncated,
|
||||
...meta.answer !== undefined ? { answer: meta.answer } : {},
|
||||
content: result.content,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +248,6 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs:
|
||||
}
|
||||
},
|
||||
presentCall: presentSearchCall,
|
||||
presentResult: (_args, result) => presentSearchResult(result),
|
||||
presentResult: (args, result) => presentSearchResult(args, result),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -140,35 +140,36 @@ describe('web_search presentation meta and result view', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('presents a completed search as a web/search card carrying the structured sources and fallback content', () => {
|
||||
it('presents a completed search as a web/search card carrying the structured sources, titled by the query', () => {
|
||||
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({
|
||||
expect(presentSearchResult({ query: 'q' }, toolResult(meta, 'rendered'))).toEqual({
|
||||
card: 'web',
|
||||
kind: 'search',
|
||||
title: 'q',
|
||||
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))
|
||||
const view = presentSearchResult({ query: 'q' }, toolResult(meta))
|
||||
expect(view).toBeDefined()
|
||||
expect(view && 'answer' in view).toBe(false)
|
||||
expect(view && 'content' 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()
|
||||
expect(presentSearchResult({ query: 'q' }, toolResult(meta, 'body', true))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to the generic card on absent or malformed meta', () => {
|
||||
expect(presentSearchResult(toolResult(undefined))).toBeUndefined()
|
||||
expect(presentSearchResult({ query: 'q' }, toolResult(undefined))).toBeUndefined()
|
||||
expect(searchMetaFromResult(undefined)).toBeUndefined()
|
||||
expect(searchMetaFromResult(null)).toBeUndefined()
|
||||
expect(searchMetaFromResult('nope')).toBeUndefined()
|
||||
@@ -358,30 +359,54 @@ 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 }))
|
||||
const NO_CAP = 1_000_000
|
||||
|
||||
it('projects url, status, and the provider truncation into meta', () => {
|
||||
expect(fetchMetaFromValue({ url: 'https://a.test', statusCode: 404, truncated: true, body: { kind: 'text', content: 'x' } }, NO_CAP))
|
||||
.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({
|
||||
it('projects truncated: true when the output cap cut a body the provider did not, matching the render footer', () => {
|
||||
// The provider reports truncated: false, but conversion outgrows the cap, so
|
||||
// the render text carries the truncation footer. The meta must agree.
|
||||
const value = {
|
||||
url: 'https://a.test', statusCode: 200, truncated: false,
|
||||
body: { kind: 'html' as const, content: `<p>${'_'.repeat(1000)}</p>` },
|
||||
}
|
||||
const meta = fetchMetaFromValue(value, 500) as { truncated: boolean }
|
||||
expect(meta.truncated).toBe(true)
|
||||
expect(formatFetchOutput(value, 500)).toContain('Content truncated')
|
||||
})
|
||||
|
||||
it('projects truncated: false when neither the provider nor the cap cut the body', () => {
|
||||
const value = {
|
||||
url: 'https://a.test', statusCode: 200, truncated: false,
|
||||
body: { kind: 'text' as const, content: 'short' },
|
||||
}
|
||||
const meta = fetchMetaFromValue(value, NO_CAP) as { truncated: boolean }
|
||||
expect(meta.truncated).toBe(false)
|
||||
expect(formatFetchOutput(value, NO_CAP)).not.toContain('Content truncated')
|
||||
})
|
||||
|
||||
it('presents a completed fetch as a web/fetch card carrying the summary, titled by the url, without content', () => {
|
||||
const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false, body: { kind: 'text', content: '# Title' } }, NO_CAP)
|
||||
expect(presentFetchResult({ url: 'https://a.test' }, toolResult(meta, '# Title'))).toEqual({
|
||||
card: 'web',
|
||||
kind: 'fetch',
|
||||
title: 'https://a.test',
|
||||
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()
|
||||
const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false, body: { kind: 'text', content: 'ok' } }, NO_CAP)
|
||||
expect(presentFetchResult({ url: 'https://a.test' }, toolResult(meta, 'body', true))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to the generic card on absent or malformed meta', () => {
|
||||
expect(presentFetchResult(toolResult(undefined))).toBeUndefined()
|
||||
expect(presentFetchResult({ url: 'https://a.test' }, toolResult(undefined))).toBeUndefined()
|
||||
expect(fetchMetaFromResult(undefined)).toBeUndefined()
|
||||
expect(fetchMetaFromResult(null)).toBeUndefined()
|
||||
expect(fetchMetaFromResult('nope')).toBeUndefined()
|
||||
|
||||
Reference in New Issue
Block a user