Merge remote-tracking branch 'origin/master' into simpl-c-web-observation
# Conflicts: # docs/architecture.md # packages/web/web/src/index.ts
This commit is contained in:
@@ -8,7 +8,7 @@ Each tool is registered independently; a product that wants only one disables th
|
||||
|
||||
| Tool | Args | Behavior |
|
||||
|---|---|---|
|
||||
| `web_search` | `query` (string) | Discovery. Returns an optional answer plus source URLs. `max_results` is **not** model-facing — the tool sets the bound (`WEB_SEARCH_MAX_RESULTS = 8`) and passes it to the seam. |
|
||||
| `web_search` | `query` (string) | Discovery. Returns an optional answer plus source URLs. `max_results` is **not** model-facing — the tool sets the bound (the `searchMaxResults` config, default 8) and passes it to the seam. |
|
||||
| `web_fetch` | `url` (string), `timeout_ms` (number, optional) | Retrieves a specific URL. HTML bodies are rendered to markdown-ish text; text bodies pass through. A non-2xx status is reported, not an error. |
|
||||
|
||||
## Config
|
||||
@@ -17,6 +17,7 @@ Each tool is registered independently; a product that wants only one disables th
|
||||
|---|---|---|
|
||||
| `search` | `true` | Register `web_search`. |
|
||||
| `fetch` | `true` | Register `web_fetch`. |
|
||||
| `searchMaxResults` | `8` | Upper bound on sources returned by one `web_search` call (the seam truncates a longer provider list and flags it). |
|
||||
|
||||
```yaml
|
||||
- id: tool-web
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-web'
|
||||
import { applyWebSearchTool } from './search.ts'
|
||||
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'
|
||||
@@ -38,13 +38,26 @@ export interface Config {
|
||||
search?: boolean
|
||||
/** Register `web_fetch`. Defaults to true. */
|
||||
fetch?: boolean
|
||||
/** Upper bound on sources returned by one `web_search` call. */
|
||||
searchMaxResults?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
search: z.boolean().default(true),
|
||||
fetch: z.boolean().default(true),
|
||||
searchMaxResults: z.number().default(WEB_SEARCH_MAX_RESULTS),
|
||||
})
|
||||
|
||||
/** The shape after schemastery applies its defaults to every field. */
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/** The result cap must be a positive integer (it bounds a provider's source list). */
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`tool-web: ${name} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the enabled web tools. `search`/`fetch` default to true; a product
|
||||
* that wants only one disables the other in config. The tools' disposers are
|
||||
@@ -52,6 +65,9 @@ export const Config: z<Config> = z.object({
|
||||
* teardown is needed.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
if (config.search !== false) applyWebSearchTool(ctx)
|
||||
if (config.fetch !== false) applyWebFetchTool(ctx)
|
||||
// schemastery (Config) has already filled every defaulted field.
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveInteger('searchMaxResults', resolved.searchMaxResults)
|
||||
if (resolved.search) applyWebSearchTool(ctx, resolved.searchMaxResults)
|
||||
if (resolved.fetch) applyWebFetchTool(ctx)
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@ import type { WebSearchResult } from '@deepseek-ai/dsh-web'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
/**
|
||||
* Default upper bound on returned sources. Owned by the consumer (not the
|
||||
* provider or model), mirroring `dsh-tool-fs`'s `READ_LIMIT`/`GREP_LIMIT`. The
|
||||
* model just asks a question; the product controls how much context returns.
|
||||
* The default `8` aligns with OpenCode's Exa default.
|
||||
* 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.
|
||||
*/
|
||||
export const WEB_SEARCH_MAX_RESULTS = 8
|
||||
|
||||
@@ -67,8 +67,8 @@ export function presentSearchCall(args: { query: string }): GenericCallView {
|
||||
return { card: 'generic', title: args.query, kind: 'search', rawInput: args.query }
|
||||
}
|
||||
|
||||
/** Register the `web_search` tool and its system-prompt guidance. */
|
||||
export function applyWebSearchTool(ctx: Context): void {
|
||||
/** Register the `web_search` tool and its system-prompt guidance. `maxResults` is the deployment's source cap. */
|
||||
export function applyWebSearchTool(ctx: Context, maxResults: number): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:web_search',
|
||||
order: 110,
|
||||
@@ -84,7 +84,7 @@ export function applyWebSearchTool(ctx: Context): void {
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseSearchArgs(args)
|
||||
const result = await ctx.web.search(
|
||||
{ query: input.query, maxResults: WEB_SEARCH_MAX_RESULTS },
|
||||
{ query: input.query, maxResults },
|
||||
exec.signal ? { signal: exec.signal } : undefined,
|
||||
)
|
||||
return [{ type: 'text', text: formatSearchOutput(result) }]
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
presentFetchCall,
|
||||
renderBody,
|
||||
htmlToMarkdown,
|
||||
WEB_SEARCH_MAX_RESULTS,
|
||||
} from '@deepseek-ai/dsh-tool-web'
|
||||
|
||||
const available: WebProviderStatus = { available: true }
|
||||
@@ -282,3 +283,48 @@ describe('tool-web execution through the real registry', () => {
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchMaxResults is plugin config', () => {
|
||||
it('forwards the default cap to the seam when unconfigured', async () => {
|
||||
const seen: { maxResults?: number | undefined } = {}
|
||||
const provider: WebSearchProvider = {
|
||||
id: 'stub-search',
|
||||
status: () => available,
|
||||
search: (request) => { seen.maxResults = request.maxResults; return Promise.resolve({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) },
|
||||
}
|
||||
const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
|
||||
await call('web_search', { query: 'q' })
|
||||
expect(seen.maxResults).toBe(WEB_SEARCH_MAX_RESULTS)
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards a configured cap to the seam, which enforces it', async () => {
|
||||
const sources = Array.from({ length: 5 }, (_, i) => ({ url: `https://s${i}.test` }))
|
||||
const provider: WebSearchProvider = {
|
||||
id: 'stub-search',
|
||||
status: () => available,
|
||||
search: request => Promise.resolve({ providerId: 'stub-search', query: request.query, sources, truncated: false }),
|
||||
}
|
||||
const { fiber, call } = await mountTools({ config: { searchMaxResults: 2 }, webConfig: { searchProvider: 'stub-search' }, search: provider })
|
||||
const out = await call('web_search', { query: 'q' })
|
||||
expect(out.isError).toBe(false)
|
||||
const body = out.content.map(b => b.text).join('')
|
||||
expect(body).toContain('https://s1.test')
|
||||
expect(body).not.toContain('https://s2.test')
|
||||
expect(body).toContain('Showing the first 2 sources.')
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['zero', 0],
|
||||
['negative', -3],
|
||||
['fractional', 1.5],
|
||||
])('rejects a %s searchMaxResults at load', async (_label, value) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(WebService, {})
|
||||
await expect(ctx.plugin(ToolWeb, { searchMaxResults: value }))
|
||||
.rejects.toThrow(/tool-web: searchMaxResults must be a positive integer/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
|
||||
/**
|
||||
* Real-API smoke for the DeepSeek search provider. Self-skips without
|
||||
* `$DEEPSEEK_API_KEY`, per the with-key e2e policy in AGENTS.md § Secrets. This
|
||||
* `$DEEPSEEK_API_KEY`, per the with-key e2e policy in docs/testing.md. This
|
||||
* is the only test that proves DeepSeek's Anthropic-compatible endpoint actually
|
||||
* triggers native `web_search` and returns the structured result blocks the
|
||||
* provider parses — a mock cannot confirm the wire shape is real.
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ExaSearchProvider, EXA_DEFAULT_BASE_URL, EXA_DEFAULT_HIGHLIGHTS_PER_RES
|
||||
|
||||
/**
|
||||
* Real-API smoke for the Exa search provider. Self-skips without `$EXA_API_KEY`
|
||||
* (CI has no secrets), per the with-key e2e policy in AGENTS.md § Secrets.
|
||||
* (CI has no secrets), per the with-key e2e policy in docs/testing.md.
|
||||
*/
|
||||
const apiKey = process.env.EXA_API_KEY
|
||||
const maybe = apiKey !== undefined && apiKey.length > 0 ? describe : describe.skip
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAU
|
||||
|
||||
/**
|
||||
* Real-API smoke for the Perplexity search provider. Self-skips without
|
||||
* `$PERPLEXITY_API_KEY`, per the with-key e2e policy in AGENTS.md § Secrets.
|
||||
* `$PERPLEXITY_API_KEY`, per the with-key e2e policy in docs/testing.md.
|
||||
*/
|
||||
const apiKey = process.env.PERPLEXITY_API_KEY
|
||||
const maybe = apiKey !== undefined && apiKey.length > 0 ? describe : describe.skip
|
||||
|
||||
@@ -110,6 +110,8 @@ export class WebService extends Service {
|
||||
* Register a search provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER`
|
||||
* if its id is already registered for search. Returns a disposer; disposed
|
||||
* with the calling fiber.
|
||||
* @param provider - the provider; its `id` is the registry key.
|
||||
* @returns the disposer that unregisters the provider.
|
||||
*/
|
||||
registerSearchProvider(provider: WebSearchProvider): () => void {
|
||||
return this.registerProvider(this.searchProviders, provider)
|
||||
@@ -119,6 +121,8 @@ export class WebService extends Service {
|
||||
* Register a fetch provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER`
|
||||
* if its id is already registered for fetch. Returns a disposer; disposed
|
||||
* with the calling fiber.
|
||||
* @param provider - the provider; its `id` is the registry key.
|
||||
* @returns the disposer that unregisters the provider.
|
||||
*/
|
||||
registerFetchProvider(provider: WebFetchProvider): () => void {
|
||||
return this.registerProvider(this.fetchProviders, provider)
|
||||
@@ -142,6 +146,9 @@ export class WebService extends Service {
|
||||
* time with the selection rules above; throws {@link WebError} when the
|
||||
* capability cannot run. The seam enforces `request.maxResults` on the result:
|
||||
* if the provider over-returns, `sources[]` is truncated and `truncated` set.
|
||||
* @param request - the query plus result-shaping options.
|
||||
* @param exec - the tool-execution context, forwarded to the provider.
|
||||
* @returns the provider's results, capped to `request.maxResults`.
|
||||
*/
|
||||
async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult> {
|
||||
const provider = resolveProvider({
|
||||
@@ -156,6 +163,9 @@ export class WebService extends Service {
|
||||
* Retrieve one URL through the selected provider. Resolves the provider at
|
||||
* call time with the selection rules above; throws {@link WebError} when the
|
||||
* capability cannot run. A non-2xx response is a result, not a throw.
|
||||
* @param request - the URL plus retrieval options.
|
||||
* @param exec - the tool-execution context, forwarded to the provider.
|
||||
* @returns the retrieval outcome; non-2xx responses resolve descriptively.
|
||||
*/
|
||||
async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult> {
|
||||
const provider = resolveProvider({
|
||||
|
||||
Reference in New Issue
Block a user