2026-06-25 15:04:12 +08:00
/**
* The model-facing `web_search` tool: discover current information on the web.
* Execution goes through `ctx.web` — this module owns only the model-facing
* schema, argument validation, the result-count bound, and result formatting,
* never provider selection or network access.
*/
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
2026-07-03 22:52:18 +08:00
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
2026-06-25 15:04:12 +08:00
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { WebSearchResult } from '@deepseek-ai/dsh-web'
import type { } from '@deepseek-ai/dsh-system-prompt'
/**
2026-07-04 17:37:23 +08:00
* Default upper bound on returned sources (the `searchMaxResults` config).
* Owned by the consumer (not the provider or model), mirroring `dsh-tool-fs`'s
* `READ_LIMIT`. The model just asks a question; the product controls how much
* context returns. The default `8` aligns with OpenCode's Exa default.
2026-06-25 15:04:12 +08:00
*/
export const WEB_SEARCH_MAX_RESULTS = 8
2026-07-06 22:09:30 +08:00
/**
* Validate value constraints the schema DSL can't express: a non-blank
* `query`. Throws a plain `Error` otherwise.
*
* @param args - the schema-validated `web_search` arguments.
* @returns the accepted arguments, passed through unchanged.
*/
2026-06-25 15:04:12 +08:00
export function parseSearchArgs ( args : { query : string } ) : { query : string } {
if ( args . query . trim ( ) . length === 0 ) throw new Error ( 'query must be a non-empty string' )
return { query : args.query }
}
/** Display label for a source: its title, else its hostname. */
function sourceLabel ( url : string , title : string | undefined ) : string {
if ( title !== undefined && title . length > 0 ) return title
try {
return new URL ( url ) . hostname
} catch {
// A provider should return a valid URL, but never let a malformed one throw
// out of pure formatting — fall back to the raw string.
return url
}
}
2026-07-06 22:09:30 +08:00
/**
* Format a search result as one model-facing text block.
*
* @param result - the seam's search outcome.
* @returns the provider answer (when any), a markdown source list with snippet
* and date metadata (or `No results found.`), a refine-the-query note when
* truncated, and a standing cite-your-sources instruction.
*/
2026-06-25 15:04:12 +08:00
export function formatSearchOutput ( result : WebSearchResult ) : string {
const parts : string [ ] = [ ]
if ( result . content !== undefined && result . content . length > 0 ) parts . push ( result . content )
if ( result . sources . length > 0 ) {
const lines = result . sources . map ( ( source ) = > {
const label = sourceLabel ( source . url , source . title )
const meta : string [ ] = [ ]
if ( source . snippet !== undefined && source . snippet . length > 0 ) meta . push ( source . snippet )
if ( source . publishedAt !== undefined && source . publishedAt . length > 0 ) meta . push ( ` ( ${ source . publishedAt } ) ` )
const suffix = meta . length > 0 ? ` — ${ meta . join ( ' ' ) } ` : ''
return ` - [ ${ label } ]( ${ source . url } ) ${ suffix } `
} )
parts . push ( ` Sources: \ n ${ lines . join ( '\n' ) } ` )
} else if ( result . content === undefined || result . content . length === 0 ) {
parts . push ( 'No results found.' )
}
if ( result . truncated ) parts . push ( ` (Showing the first ${ result . sources . length } sources. Refine the query for more.) ` )
parts . push ( 'Cite the relevant URLs above as markdown links in your answer.' )
return parts . join ( '\n\n' )
}
2026-07-06 22:09:30 +08:00
/**
* Pending-call presentation: a search card titled by the query.
*
* @param args - the raw tool arguments; only `query` feeds the view.
* @returns the generic card view (`kind: 'search'`) shown while the call runs.
*/
2026-07-03 22:52:18 +08:00
export function presentSearchCall ( args : { query : string } ) : GenericCallView {
return { card : 'generic' , title : args.query , kind : 'search' , rawInput : args.query }
2026-06-25 15:04:12 +08:00
}
2026-07-06 22:09:30 +08:00
/**
* Register the `web_search` tool and its system-prompt guidance.
*
* @param ctx - context whose `tools` and `systemPrompt` registries receive the
* registrations; both are effect-scoped and unregister on plugin dispose.
* @param maxResults - the deployment's source cap, sent as every seam
* request's `maxResults`.
2026-07-08 14:40:14 +08:00
* @param timeoutMs - the cooperative tool-call budget (ms) attached as the tool's
* `ToolDefinition.timeoutMs` for `@deepseek-ai/dsh-timeout-policy` to enforce.
2026-07-06 22:09:30 +08:00
*/
2026-07-08 14:40:14 +08:00
export function applyWebSearchTool ( ctx : Context , maxResults : number , timeoutMs : number ) : void {
2026-06-25 15:04:12 +08:00
ctx . systemPrompt . section ( {
name : 'tool:web_search' ,
order : 110 ,
text : 'Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.' ,
} )
ctx . tools . register ( defineTool ( {
name : 'web_search' ,
description : 'Search the web for current information. Returns an optional summary answer and a list of source URLs.' ,
parameters : {
query : { type : 'string' , required : true , description : 'The search query.' } ,
} ,
2026-07-08 14:40:14 +08:00
timeoutMs ,
2026-07-18 14:59:26 +08:00
// Provider reads do not mutate parent-agent state.
2026-07-13 11:02:21 +08:00
isConcurrencySafe : ( ) = > true ,
2026-06-25 15:04:12 +08:00
async execute ( args , exec ) : Promise < ContentBlock [ ] > {
const input = parseSearchArgs ( args )
const result = await ctx . web . search (
2026-07-04 17:37:23 +08:00
{ query : input.query , maxResults } ,
2026-07-14 04:17:38 +08:00
exec . signal ,
2026-06-25 15:04:12 +08:00
)
return [ { type : 'text' , text : formatSearchOutput ( result ) } ]
} ,
presentCall : presentSearchCall ,
} ) )
}