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.
This commit is contained in:
Chinesezjc
2026-07-30 17:04:09 +08:00
parent 69214b4708
commit ba0757223d
9 changed files with 532 additions and 5 deletions

View File

@@ -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<string, unknown>
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),
}))
}

View File

@@ -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'

View File

@@ -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<string, unknown>
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<string, unknown>
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),
}))
}

View File

@@ -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' })