Kaynağa Gözat

fix(web): require queries array for web search

Dudu-0223 1 ay önce
ebeveyn
işleme
d57c2d19db

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml

@@ -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 .agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md
-2026-07-07-tool-call-timeout-policy.md: 3d5425b3caed97f0faca01ff656cc35811374b77
-2026-07-07-tool-call-timeout-policy.zh.md: 9c2323d235158986c72eef253473876c765dd867
+2026-07-07-tool-call-timeout-policy.md: 92618cc8c761b38d7e516c9d00eb3de1c37831a8
+2026-07-07-tool-call-timeout-policy.zh.md: cc633ceaa3840331826f6475e603e78a98f0afd2

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md

@@ -77,7 +77,7 @@ No new session event is needed for reconstructability: `TOOL_TIMEOUT` is the fin
 
 ### Existing tool adaptation
 
-`web_fetch` and `web_search` are migrated. `dsh-tool-web` keeps ownership of their model-facing schemas, and those schemas expose no timeout knob: `web_fetch` has no `timeout_ms` parameter, while `web_search` accepts `query` or `queries` without a timeout argument. The tool bodies do not import `@deepseek-ai/dsh-timeout`; they forward `exec.signal` to `ctx.web`.
+`web_fetch` and `web_search` are migrated. `dsh-tool-web` keeps ownership of their model-facing schemas, and those schemas expose no timeout knob: `web_fetch` has no `timeout_ms` parameter, while `web_search` accepts a required `queries` array without a timeout argument. The tool bodies do not import `@deepseek-ai/dsh-timeout`; they forward `exec.signal` to `ctx.web`.
 
 `dsh-web-fetch-http` keeps one configured provider-level `timeoutMs` as a large resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments; it owns no model-facing timeout. When a `TOOL_TIMEOUT` signal reaches the fetch provider first, provider-scoped classification treats it as upstream `WEB_ABORTED`, and the outer `tools/execute` wrapper replaces the final tool result with `TOOL_TIMEOUT`. A shipped web-tool deployment configures the provider backstop above the `timeout-policy` budget so the tool-call policy normally wins for model calls.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md

@@ -77,7 +77,7 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult {
 
 ### 现有工具适配
 
-`web_fetch` 和 `web_search` 已迁移。`dsh-tool-web` 保留对其面向模型 schema 的所有权,这些 schema 不暴露超时旋钮:`web_fetch` 没有 `timeout_ms` 参数,`web_search` 接受 `query` 或 `queries`,但不接受超时参数。工具体不导入 `@deepseek-ai/dsh-timeout`;它们将 `exec.signal` 转发给 `ctx.web`。
+`web_fetch` 和 `web_search` 已迁移。`dsh-tool-web` 保留对其面向模型 schema 的所有权,这些 schema 不暴露超时旋钮:`web_fetch` 没有 `timeout_ms` 参数,`web_search` 接受必填的 `queries` 数组,但不接受超时参数。工具体不导入 `@deepseek-ai/dsh-timeout`;它们将 `exec.signal` 转发给 `ctx.web`。
 
 `dsh-web-fetch-http` 保留一个在提供方层面配置的 `timeoutMs`,作为较大的资源兜底值,服务于直接调用 `ctx.web.fetch()` 的调用方和配置错误的部署;它不拥有面向模型的超时。当 `TOOL_TIMEOUT` 信号先到达 fetch 提供方时,提供方作用域的分类将其视为上游 `WEB_ABORTED`,而外层 `tools/execute` 包装器将最终工具结果替换为 `TOOL_TIMEOUT`。一个已发布的 web 工具部署将提供方兜底配置为高于 `timeout-policy` 预算,使工具调用策略在模型调用中通常胜出。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-17-web-search-multiple-queries.i18n.yaml

@@ -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 .agents/notes/implemented/feature/2026-08-17-web-search-multiple-queries.md
-2026-08-17-web-search-multiple-queries.md: bed2af83fc2a8dd7b4faa960b46bd1978ef2ab06
-2026-08-17-web-search-multiple-queries.zh.md: 2236dc7674f179bb02e3219e8f315ce58ba4fde2
+2026-08-17-web-search-multiple-queries.md: f0a8bf3d69763231ee2c1f441ed52e2e0b7a6060
+2026-08-17-web-search-multiple-queries.zh.md: 5c6be5e20fdb7ea14fcc8ef3e1275f57a008152f

+ 5 - 3
.agents/notes/implemented/feature/2026-08-17-web-search-multiple-queries.md

@@ -10,9 +10,9 @@ The model-facing `web_search` tool accepted only one `query`. In deployments whe
 
 ## Decision
 
-`web_search` accepts either the existing `query` string or a `queries` string array, but not both. `searchMaxQueries` bounds the array and provider fan-out, defaults to four, and appears in the system-prompt guidance and tool descriptions. Validation rejects an oversized array before any provider call starts, then exact duplicate strings are removed while preserving their first position.
+`web_search` accepts one required `queries` string array. A one-item array performs a single search. `searchMaxQueries` bounds the array and provider fan-out, defaults to four, and appears in the system-prompt guidance and tool descriptions. Validation rejects an oversized array before any provider call starts, then exact duplicate strings are removed while preserving their first position.
 
-When `queries` has multiple distinct entries, `dsh-tool-web` runs them concurrently through `ctx.web.search`, labels provider answers with their originating query, and deduplicates sources by URL. It takes one source at each rank from every query before advancing to the next rank, then caps the combined list to `searchMaxResults`; this prevents one query's lower-ranked sources from displacing every source from later queries. If any search fails, the tool aborts its siblings, waits for every started search to settle, discards successful results, and returns the first failure. The single-query path remains unchanged.
+When `queries` has multiple distinct entries, `dsh-tool-web` runs them concurrently through `ctx.web.search`, labels provider answers with their originating query, and deduplicates sources by URL. It takes one source at each rank from every query before advancing to the next rank, then caps the combined list to `searchMaxResults`; this prevents one query's lower-ranked sources from displacing every source from later queries. If any search fails, the tool aborts its siblings, waits for every started search to settle, discards successful results, and returns the first failure. A one-item array returns the provider's result without multi-query formatting.
 
 The multi-query orchestration lives in the tool consumer, not in the web seam or providers, because `WebSearchProvider.search` remains a single-query contract and the seam stays provider-neutral.
 
@@ -20,6 +20,8 @@ The multi-query orchestration lives in the tool consumer, not in the web seam or
 
 **Rely on the existing parallel tool-call support.** Rejected: the model still sees a one-query schema and must decide to emit multiple `web_search` calls, which is exactly the friction that pushed it toward the MCP interface.
 
+**Accept both `query` and `queries`.** Rejected: two optional fields make the model choose between equivalent representations and move the required exactly-one rule into prose and runtime validation. One required array represents both one and many searches with fewer invalid states.
+
 **Add a multi-query request type to `WebSearchRequest`.** Rejected: providers are single-query backends, and changing the shared seam would force every provider to implement a feature only the model-facing consumer needs.
 
 **Accept an unbounded `queries` array.** Rejected: one model action could start an arbitrary number of provider requests and concatenate an arbitrary number of provider answers. A deployment-owned bound keeps the model schema focused on search input while controlling cost and output growth.
@@ -28,7 +30,7 @@ The multi-query orchestration lives in the tool consumer, not in the web seam or
 
 ## Consequences
 
-Models can batch several distinct searches into one native `web_search` call, reducing the incentive to switch to MCP search. The default query cap of four matches Codex `web.run`'s model-facing batch size while bounding concurrent provider calls; deployments can choose another positive integer independently of the source cap. Exact duplicate strings consume the input-array bound but cause only one provider call. Combined sources remain bounded by `searchMaxResults` and preserve each query's result ranking through round-robin merge. Provider answers in a multi-query result are prefixed with `### <query>` headings so the model can tell which answer came from which search. `query` is optional in the schema; runtime validation requires exactly one of `query` or `queries`.
+Models pass one required `queries` array for every native `web_search` call and can batch several distinct searches without switching to MCP search. The default query cap of four matches Codex `web.run`'s model-facing batch size while bounding concurrent provider calls; deployments can choose another positive integer independently of the source cap. Exact duplicate strings consume the input-array bound but cause only one provider call. Combined sources remain bounded by `searchMaxResults` and preserve each query's result ranking through round-robin merge. Provider answers in a multi-query result are prefixed with `### <query>` headings so the model can tell which answer came from which search.
 
 Multi-query failure is all-or-nothing: a successful provider result is discarded if another query fails, and the call does not return until sibling cancellation reaches quiescence. `searchMaxQueries` and provider-owned controls are independently configurable and together form the search budget. A provider may perform several native searches inside one `ctx.web.search` call, so a model-backed provider with its own `maxUses` can permit up to `searchMaxQueries × maxUses` native searches; `searchMaxResults` bounds only the combined sources returned to the caller. The provider-neutral seam deliberately does not define an overall native-search counter.
 

+ 5 - 3
.agents/notes/implemented/feature/2026-08-17-web-search-multiple-queries.zh.md

@@ -10,9 +10,9 @@ Status: implemented
 
 ## 决定
 
-`web_search` 接受原有的 `query` 字符串,或 `queries` 字符串数组,但不能同时传两者。`searchMaxQueries` 限制数组大小与提供方请求扇出,默认值为 4,并出现在系统提示词指引与工具描述中。校验会在任何提供方调用开始前拒绝超限数组,随后移除完全相同的重复字符串,并保留它们首次出现的位置。
+`web_search` 接受一个必填的 `queries` 字符串数组。单元素数组执行一次搜索。`searchMaxQueries` 限制数组大小与提供方请求扇出,默认值为 4,并出现在系统提示词指引与工具描述中。校验会在任何提供方调用开始前拒绝超限数组,随后移除完全相同的重复字符串,并保留它们首次出现的位置。
 
-当 `queries` 包含多个不同条目时,`dsh-tool-web` 会通过 `ctx.web.search` 并发执行这些搜索,用来源查询标注提供方答案,并按 URL 对来源去重。它从每个查询取得同一排名的一条来源后再推进至下一排名,然后把组合列表限制在 `searchMaxResults` 上限内;这样,一个查询排名较低的来源不会挤掉后续查询的所有来源。任何搜索失败时,工具会中止其余搜索,等待所有已启动搜索结算,丢弃成功结果,并返回首次失败。单查询路径保持不变。
+当 `queries` 包含多个不同条目时,`dsh-tool-web` 会通过 `ctx.web.search` 并发执行这些搜索,用来源查询标注提供方答案,并按 URL 对来源去重。它从每个查询取得同一排名的一条来源后再推进至下一排名,然后把组合列表限制在 `searchMaxResults` 上限内;这样,一个查询排名较低的来源不会挤掉后续查询的所有来源。任何搜索失败时,工具会中止其余搜索,等待所有已启动搜索结算,丢弃成功结果,并返回首次失败。单元素数组直接返回提供方结果,不添加多查询格式。
 
 多查询编排放在工具消费方,而不是 web seam 或提供方,因为 `WebSearchProvider.search` 仍是单查询契约,seam 也保持提供方无关。
 
@@ -20,6 +20,8 @@ Status: implemented
 
 **依赖现有的并行工具调用能力。** 不采用:模型看到的仍然是单查询 schema,必须自行决定发起多次 `web_search` 调用,这正是把它推向 MCP 接口的摩擦点。
 
+**同时接受 `query` 与 `queries`。** 不采用:两个可选字段会让模型在等价表示之间选择,并把必填且二选一的规则移入说明文本与运行时校验。一个必填数组用更少的无效状态同时表示一次与多次搜索。
+
 **给 `WebSearchRequest` 增加多查询请求类型。** 不采用:提供方都是单查询后端,而且修改共享 seam 会迫使每个提供方实现只有模型侧消费方才需要的功能。
 
 **接受无上限的 `queries` 数组。** 不采用:一次模型操作可以启动任意数量的提供方请求,并拼接任意数量的提供方答案。由部署拥有的上限既让模型 schema 聚焦搜索输入,也能控制成本与输出增长。
@@ -28,7 +30,7 @@ Status: implemented
 
 ## 结果
 
-模型可以把多个不同搜索合并到一次原生 `web_search` 调用中,减少转向 MCP 搜索的动机。默认查询上限 4 与 Codex `web.run` 面向模型的批量大小一致,同时限制并发提供方调用;部署可以独立于来源上限选择另一个正整数。完全相同的重复字符串会占用输入数组上限,但只会触发一次提供方调用。组合来源仍受 `searchMaxResults` 限制,并通过轮询合并保留每个查询的结果排名。多查询结果中的提供方答案会以 `### <query>` 标题标注,便于模型区分答案来自哪个搜索。`query` 在 schema 中是可选字段;运行时校验要求 `query` 与 `queries` 二选一。
+模型在每次原生 `web_search` 调用中都传入一个必填的 `queries` 数组,并可在不转向 MCP 搜索的情况下批量执行多个不同搜索。默认查询上限 4 与 Codex `web.run` 面向模型的批量大小一致,同时限制并发提供方调用;部署可以独立于来源上限选择另一个正整数。完全相同的重复字符串会占用输入数组上限,但只会触发一次提供方调用。组合来源仍受 `searchMaxResults` 限制,并通过轮询合并保留每个查询的结果排名。多查询结果中的提供方答案会以 `### <query>` 标题标注,便于模型区分答案来自哪个搜索。
 
 多查询失败采用全有或全无语义:如果另一个查询失败,成功的提供方结果也会被丢弃;在同批取消达到静默状态前,调用不会返回。`searchMaxQueries` 与提供方自有的控制项可以独立配置,并共同构成搜索预算。提供方可以在一次 `ctx.web.search` 调用内执行多次原生搜索,因此拥有自身 `maxUses` 的模型型提供方最多可以执行 `searchMaxQueries × maxUses` 次原生搜索;`searchMaxResults` 只限制返回给调用方的组合来源。提供方中立的 seam 有意不定义原生搜索总计数器。
 

+ 2 - 2
docs/subsystems/web.i18n.yaml

@@ -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 docs/subsystems/web.md
-web.md: 6726c9604fd04eeac3d26aaa52f4516c111a5096
-web.zh.md: c389e070388a1e4d1fc4e905b35997dcebd21241
+web.md: 7a5a95e0c465924b06696ff83204a82af574f593
+web.zh.md: 1eb5915b46fa01f9e79437bccee786939358b4db

+ 1 - 1
docs/subsystems/web.md

@@ -12,7 +12,7 @@ Search and fetch share no request schema and no business logic, but they are del
 
 ## Search request and result
 
-Each seam request carries exactly one `query`. The `dsh-tool-web` consumer accepts either one `query` or a `queries` array and fans the array out into separate seam requests. `maxResults` is a consumer-owned bound (`dsh-tool-web`'s `searchMaxResults` config, default `8`) passed through the seam and enforced on the way back — if a provider over-returns, the seam truncates `sources[]` and sets `truncated`.
+Each seam request carries exactly one `query`. The `dsh-tool-web` consumer accepts a required `queries` array and fans it out into separate seam requests; a one-item array performs one search. `maxResults` is a consumer-owned bound (`dsh-tool-web`'s `searchMaxResults` config, default `8`) passed through the seam and enforced on the way back — if a provider over-returns, the seam truncates `sources[]` and sets `truncated`.
 
 ```ts type-equiv
 /**

+ 1 - 1
docs/subsystems/web.zh.md

@@ -12,7 +12,7 @@ Web 访问 seam 是一个[能力 seam](../../.agents/notes/implemented/architect
 
 ## 搜索请求与结果
 
-每个 seam 请求只携带一个 `query`。消费方 `dsh-tool-web` 接受单个 `query` 或 `queries` 数组,并把数组扇出为多个独立 seam 请求。`maxResults` 是消费方自有的上限(`dsh-tool-web` 的 `searchMaxResults` 配置,默认 `8`),通过 seam 传递并在返回时强制执行——如果提供方返回超量,seam 截断 `sources[]` 并设置 `truncated`。
+每个 seam 请求只携带一个 `query`。消费方 `dsh-tool-web` 接受必填的 `queries` 数组,并把它扇出为多个独立 seam 请求;单元素数组执行一次搜索。`maxResults` 是消费方自有的上限(`dsh-tool-web` 的 `searchMaxResults` 配置,默认 `8`),通过 seam 传递并在返回时强制执行——如果提供方返回超量,seam 截断 `sources[]` 并设置 `truncated`。
 
 ```ts type-equiv
 /**

+ 2 - 2
docs/tool-catalog.i18n.yaml

@@ -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 docs/tool-catalog.md
-tool-catalog.md: a82874cdc799dd13773ef010072a863677df9ea9
-tool-catalog.zh.md: 894e9f0509fa9ad2525826120094c6410644a608
+tool-catalog.md: 3ffc2f1c4211b93812e240d2a02a23d70b7ef5fe
+tool-catalog.zh.md: e5751ae05d019b88f26cd65a9588052b7c73c390

+ 6 - 7
docs/tool-catalog.md

@@ -2168,24 +2168,23 @@ Source: [`packages/web/tool-web/src/index.ts`](../packages/web/tool-web/src/inde
 
 ### `web_search`
 
-Search the web for current information. Pass one query or up to 4 queries to search several topics at once. Returns an optional summary answer and a list of source URLs.
+Search the web for current information. Provide 1–4 queries in the required queries array. Returns an optional summary answer and a list of source URLs.
 
 ```json
 {
   "type": "object",
   "properties": {
-    "query": {
-      "type": "string",
-      "description": "The search query. Provide either this or queries."
-    },
     "queries": {
       "type": "array",
-      "description": "Up to 4 search queries to run concurrently and merge into one result. Provide either this or query.",
+      "description": "Required search queries; accepts 1–4 items and merges their results.",
       "items": {
         "type": "string"
       }
     }
-  }
+  },
+  "required": [
+    "queries"
+  ]
 }
 ```
 

+ 6 - 7
docs/tool-catalog.zh.md

@@ -2172,24 +2172,23 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
 
 ### `web_search`
 
-在 Web 上搜索最新信息。可传入一个查询或至多 4 个查询,同时搜索多个主题。返回可选的摘要答案和源 URL 列表。
+在 Web 上搜索最新信息。在必填的 `queries` 数组中提供 1–4 个查询。返回可选的摘要答案和来源 URL 列表。
 
 ```json
 {
   "type": "object",
   "properties": {
-    "query": {
-      "type": "string",
-      "description": "The search query. Provide either this or queries."
-    },
     "queries": {
       "type": "array",
-      "description": "Up to 4 search queries to run concurrently and merge into one result. Provide either this or query.",
+      "description": "Required search queries; accepts 1–4 items and merges their results.",
       "items": {
         "type": "string"
       }
     }
-  }
+  },
+  "required": [
+    "queries"
+  ]
 }
 ```
 

+ 2 - 2
packages/client/connection/src/client/fixture.ts

@@ -545,7 +545,7 @@ function buildAlphaLog(): SessionEvent[] {
   // the real tools so they hit the keyed WebRow registration. Ordered BEFORE
   // the todo turn for the same reason turn 66 is: the standing plan retires at
   // the next turn/start, so a turn after it would empty the dock's plan strip.
-  toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
+  toolTurn(70, 'web_search', '{"queries":["deepseek harness architecture"]}', 'Search results for deepseek harness architecture.')
   toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
 
   // Turn 72: max-tokens sample — the provider ends the turn at its output cap
@@ -662,7 +662,7 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
     // kind matches the result kind so a call and its result read as one category.
     case 'web_search': {
       const queries = Array.isArray(args.queries) ? args.queries.filter((query): query is string => typeof query === 'string' && query !== '') : []
-      const title = queries.length > 0 ? queries.join(', ') : str(args.query)
+      const title = queries.join(', ')
       return { card: 'generic', title: `Search ${title}`, kind: 'search', rawInput: args }
     }
     case 'web_fetch':

+ 2 - 2
packages/web/tool-web/README.i18n.yaml

@@ -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: 78c49d59406b6e0874676ca1110cf14d2883da32
-README.zh.md: eeebc0723340289550ae8c29ecd835834b06e367
+README.md: 787b70a5070f48a3bac6435d5d7e8b64c01e0341
+README.zh.md: bce95dbc0eea1bebe59b0b80cafae2a4eb948aa6

+ 4 - 4
packages/web/tool-web/README.md

@@ -10,7 +10,7 @@ Each tool is registered independently; a product that wants only one disables th
 
 | Tool | Args | Behavior |
 |---|---|---|
-| `web_search` | `query` (string) or `queries` (string[]) | Discovery. Returns an optional answer plus source URLs. `queries` runs up to `searchMaxQueries` distinct searches concurrently and merges their sources in round-robin order before applying the combined `searchMaxResults` cap. Exact duplicate queries run once. Any failed search aborts the remaining batch, which settles before the call returns an error. Neither bound is model-facing. |
+| `web_search` | `queries` (required string[]) | Discovery. Returns an optional answer plus source URLs. It runs one to `searchMaxQueries` distinct searches concurrently and merges their sources in round-robin order before applying the combined `searchMaxResults` cap. A one-item array performs one search. Exact duplicate queries run once. Any failed search aborts the remaining batch, which settles before the call returns an error. Neither bound is model-facing. |
 | `web_fetch` | `url` (string) | Retrieves a specific URL. HTML bodies are rendered to markdown (turndown with GFM tables/strikethrough); text bodies pass through. A non-2xx status is reported, not an error. The tool-call timeout is deployment policy (`dsh-tool-call-timeout-policy`), not a model argument. |
 
 Both tools opt into concurrent scheduling because provider reads return content without mutating parent-agent state.
@@ -53,13 +53,13 @@ Search and fetch contribute the web-search and web-fetch guidance below. Search
 ##### Web search guidance with fetch enabled
 
 ```markdown
-Use the web_search tool to discover current information on the web. You can pass up to 4 queries in one call via the queries parameter when you need several distinct searches. 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.
+Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. 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.
 ```
 
 ##### Web search-only guidance
 
 ```markdown
-Use the web_search tool to discover current information on the web. You can pass up to 4 queries in one call via the queries parameter when you need several distinct searches. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.
+Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.
 ```
 
 ##### Web fetch guidance
@@ -136,7 +136,7 @@ Append-only; newly visible content follows the reusable request prefix and does
 
 #### What the model sees
 
-Invalid inputs become exactly `Error: provide either query or queries`, `Error: provide either query or queries, not both`, `Error: query must be a non-empty string`, `Error: queries must contain at least one query`, `Error: queries must contain at most 1 query` when the configured cap is one, `Error: queries must contain at most <count> queries` for larger caps, `Error: each query must be a non-empty string`, or `Error: url must be a non-empty string`.
+Schema validation rejects an absent or non-array `queries` field and non-string array elements before execution. Value errors become exactly `Error: queries must contain at least one query`, `Error: queries must contain at most 1 query` when the configured cap is one, `Error: queries must contain at most <count> queries` for larger caps, `Error: each query must be a non-empty string`, or `Error: url must be a non-empty string`.
 
 #### Token effect
 

+ 4 - 4
packages/web/tool-web/README.zh.md

@@ -10,7 +10,7 @@
 
 | 工具 | 参数 | 行为 |
 |---|---|---|
-| `web_search` | `query`(string)或 `queries`(string[]) | 用于发现信息。返回可选答案与来源 URL。`queries` 会并发执行至多 `searchMaxQueries` 个不同搜索,按轮询顺序合并来源,再应用组合后的 `searchMaxResults` 上限。完全相同的查询只执行一次。任何搜索失败都会中止批次中的其余搜索;批次结算完毕后调用才返回错误。两个上限都不面向模型。 |
+| `web_search` | `queries`(必填 string[]) | 用于发现信息。返回可选答案与来源 URL。它会并发执行 1 至 `searchMaxQueries` 个不同搜索,按轮询顺序合并来源,再应用组合后的 `searchMaxResults` 上限。单元素数组执行一次搜索。完全相同的查询只执行一次。任何搜索失败都会中止批次中的其余搜索;批次结算完毕后调用才返回错误。两个上限都不面向模型。 |
 | `web_fetch` | `url`(string) | 获取特定 URL。HTML 主体渲染为 markdown(turndown,带 GFM 表格/删除线);文本主体原样通过。非 2xx 状态会报告,而非报错。工具调用超时是部署策略(`dsh-tool-call-timeout-policy`),不是模型参数。 |
 
 两个工具都选择并发调度,因为提供方读取会返回内容,不会修改父 agent(智能体)的状态。
@@ -53,13 +53,13 @@
 ##### 启用抓取时的 Web 搜索指引
 
 ```markdown
-Use the web_search tool to discover current information on the web. You can pass up to 4 queries in one call via the queries parameter when you need several distinct searches. 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.
+Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. 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.
 ```
 
 ##### 仅搜索时的 Web 搜索指引
 
 ```markdown
-Use the web_search tool to discover current information on the web. You can pass up to 4 queries in one call via the queries parameter when you need several distinct searches. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.
+Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.
 ```
 
 ##### Web 抓取指引
@@ -136,7 +136,7 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex
 
 #### 模型看到的内容
 
-无效输入精确地变为 `Error: provide either query or queries`、`Error: provide either query or queries, not both`、`Error: query must be a non-empty string`、`Error: queries must contain at least one query`、配置上限为 1 时的 `Error: queries must contain at most 1 query`、上限更大时的 `Error: queries must contain at most <count> queries`、`Error: each query must be a non-empty string` 或 `Error: url must be a non-empty string`。
+schema 校验会在执行前拒绝缺失或非数组的 `queries` 字段以及非字符串数组元素。值错误精确地变为 `Error: queries must contain at least one query`、配置上限为 1 时的 `Error: queries must contain at most 1 query`、上限更大时的 `Error: queries must contain at most <count> queries`、`Error: each query must be a non-empty string` 或 `Error: url must be a non-empty string`。
 
 #### Token 影响
 

+ 22 - 52
packages/web/tool-web/src/search.ts

@@ -19,41 +19,28 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
  */
 export const WEB_SEARCH_MAX_RESULTS = 8
 
-/** Default upper bound on concurrent searches in one `queries` call. */
+/** Default upper bound on concurrent searches in one tool call. */
 export const WEB_SEARCH_MAX_QUERIES = 4
 
-/**
- * Model-facing `web_search` arguments. `query` preserves the single-query
- * form; `queries` accepts multiple queries in one call.
- */
+/** Model-facing `web_search` arguments. */
 interface WebSearchArgs {
-  query?: string
-  queries?: string[]
+  queries: string[]
 }
 
 /**
- * Validate value constraints the schema DSL can't express: a non-blank
- * `query` or a non-empty `queries` array of non-blank strings, but not both.
- * `queries` must also fit the deployment's query-count bound. Exact duplicate
- * query strings are collapsed after the bound check. Throws a plain `Error`
- * otherwise.
+ * Validate value constraints the schema DSL can't express: `queries` is
+ * non-empty, contains only non-blank strings, and fits the deployment's
+ * query-count bound. Exact duplicate strings are collapsed after the bound
+ * check. Throws a plain `Error` otherwise.
  *
  * @param args - the schema-validated `web_search` arguments.
  * @param maxQueries - the deployment's upper bound on queries in one call.
- * @returns the accepted query, or the accepted queries for a multi-query call.
+ * @returns the accepted queries in their first-occurrence order.
  */
 export function parseSearchArgs(
   args: WebSearchArgs,
   maxQueries: number,
-): { query: string } | { queries: string[] } {
-  if (args.query !== undefined && args.queries !== undefined) {
-    throw new Error('provide either query or queries, not both')
-  }
-  if (args.query !== undefined) {
-    if (args.query.trim().length === 0) throw new Error('query must be a non-empty string')
-    return { query: args.query }
-  }
-  if (args.queries === undefined) throw new Error('provide either query or queries')
+): string[] {
   const queries = args.queries
   if (queries.length === 0) throw new Error('queries must contain at least one query')
   if (queries.length > maxQueries) {
@@ -61,7 +48,7 @@ export function parseSearchArgs(
     throw new Error(`queries must contain at most ${maxQueries} ${noun}`)
   }
   if (queries.some(query => query.trim().length === 0)) throw new Error('each query must be a non-empty string')
-  return { queries: [...new Set(queries)] }
+  return [...new Set(queries)]
 }
 
 /** Display label for a source: its title, else its hostname. */
@@ -108,24 +95,13 @@ export function formatSearchOutput(result: WebSearchResult): string {
 }
 
 /**
- * Derive a display title from either the single query or the query list.
- *
- * @param args - the raw tool arguments.
- * @returns a comma-joined title for the search card.
- */
-function searchTitle(args: WebSearchArgs): string {
-  const queries = args.queries ?? (args.query !== undefined ? [args.query] : [])
-  return queries.join(', ')
-}
-
-/**
- * Pending-call presentation: a search card titled by the query or queries.
+ * Pending-call presentation: a search card titled by the query list.
  *
  * @param args - the raw tool arguments; only the query text feeds the view.
  * @returns the generic card view (`kind: 'search'`) shown while the call runs.
  */
 export function presentSearchCall(args: WebSearchArgs): GenericCallView {
-  const title = searchTitle(args)
+  const title = args.queries.join(', ')
   return { card: 'generic', title, kind: 'search', rawInput: title }
 }
 
@@ -220,9 +196,8 @@ export function searchMetaFromResult(meta: unknown): WebSearchMeta | undefined {
  * `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; the query or queries become the
- *   result-state title so a window-truncated replay that dropped the call head
- *   still has one.
+ * @param args - the raw tool arguments; the queries become 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.
@@ -234,18 +209,13 @@ export function presentSearchResult(args: WebSearchArgs, result: ToolResult): We
   return {
     card: 'web',
     kind: 'search',
-    title: searchTitle(args),
+    title: args.queries.join(', '),
     sources: meta.sources,
     truncated: meta.truncated,
     ...meta.answer !== undefined ? { answer: meta.answer } : {},
   }
 }
 
-/** Normalize parsed single- or multi-query arguments into a query list. */
-function queriesFromSearchArgs(input: { query: string } | { queries: string[] }): string[] {
-  return 'query' in input ? [input.query] : input.queries
-}
-
 /**
  * Run one or more searches through the web seam. A single query keeps the
  * provider's exact result; multiple queries run concurrently and are merged
@@ -347,19 +317,19 @@ export function applyWebSearchTool(
     name: 'tool:web_search',
     order: 110,
     text: fetchEnabled
-      ? `Use the web_search tool to discover current information on the web. You can pass up to ${maxQueries} queries in one call via the queries parameter when you need several distinct searches. 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.`
-      : `Use the web_search tool to discover current information on the web. You can pass up to ${maxQueries} queries in one call via the queries parameter when you need several distinct searches. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.`,
+      ? `Use the web_search tool to discover current information on the web. The required queries array accepts 1–${maxQueries} non-empty search queries; use a one-item array for a single search. 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.`
+      : `Use the web_search tool to discover current information on the web. The required queries array accepts 1–${maxQueries} non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.`,
   })
 
   ctx.tools.register(defineTool({
     name: 'web_search',
-    description: `Search the web for current information. Pass one query or up to ${maxQueries} queries to search several topics at once. Returns an optional summary answer and a list of source URLs.`,
+    description: `Search the web for current information. Provide 1–${maxQueries} queries in the required queries array. Returns an optional summary answer and a list of source URLs.`,
     parameters: {
-      query: { type: 'string', description: 'The search query. Provide either this or queries.' },
       queries: {
         type: 'array',
+        required: true,
         items: { type: 'string' },
-        description: `Up to ${maxQueries} search queries to run concurrently and merge into one result. Provide either this or query.`,
+        description: `Required search queries; accepts 1–${maxQueries} items and merges their results.`,
       },
     },
     output: {
@@ -392,8 +362,8 @@ export function applyWebSearchTool(
     // Provider reads do not mutate parent-agent state.
     isConcurrencySafe: () => true,
     async execute(args, exec) {
-      const input = parseSearchArgs(args, maxQueries)
-      const result = await runSearchQueries(ctx, queriesFromSearchArgs(input), maxResults, exec.signal)
+      const queries = parseSearchArgs(args, maxQueries)
+      const result = await runSearchQueries(ctx, queries, maxResults, exec.signal)
       return {
         ...result.content !== undefined ? { content: result.content } : {},
         sources: result.sources.map(projectSource),

+ 4 - 3
packages/web/tool-web/tests/integration.spec.ts

@@ -97,7 +97,7 @@ describe('web_search integration over the real Exa provider', () => {
       JSON.stringify({ results: [{ url: 'https://result.test', title: 'Result', highlights: ['a highlight'] }] }),
       { status: 200, headers: { 'content-type': 'application/json' } },
     )))
-    const out = await call('web_search', { query: 'deepseek-official' })
+    const out = await call('web_search', { queries: ['deepseek-official'] })
     expect(out.isError).toBe(false)
     expect(out.content.map(b => b.type === 'text' ? b.text : '').join('')).toContain('[Result](https://result.test)')
   })
@@ -107,10 +107,11 @@ describe('tool-call timeout policy over the migrated web tools', () => {
   it('neither model schema exposes a timeout parameter after the migration', () => {
     const byName = new Map(ctx.tools.schemas().map(s => [s.name, s]))
     const fetchParams = byName.get('web_fetch')!.parameters as { properties: Record<string, unknown> }
-    const searchParams = byName.get('web_search')!.parameters as { properties: Record<string, unknown> }
+    const searchParams = byName.get('web_search')!.parameters as { properties: Record<string, unknown>; required?: string[] }
     expect(Object.keys(fetchParams.properties)).toEqual(['url'])
     expect('timeout_ms' in fetchParams.properties).toBe(false)
-    expect(Object.keys(searchParams.properties)).toEqual(['query', 'queries'])
+    expect(Object.keys(searchParams.properties)).toEqual(['queries'])
+    expect(searchParams.required).toEqual(['queries'])
   })
 })
 

+ 23 - 37
packages/web/tool-web/tests/tool-web.spec.ts

@@ -85,20 +85,14 @@ describe('search formatting', () => {
     expect(out).toContain('Showing the first 1 sources')
   })
 
-  it('validates the query', () => {
-    expect(() => parseSearchArgs({ query: '   ' }, WEB_SEARCH_MAX_QUERIES)).toThrow('non-empty')
-    expect(parseSearchArgs({ query: 'hi' }, WEB_SEARCH_MAX_QUERIES)).toEqual({ query: 'hi' })
-  })
-
-  it('validates multiple queries', () => {
+  it('validates queries', () => {
+    expect(parseSearchArgs({ queries: ['hi'] }, WEB_SEARCH_MAX_QUERIES)).toEqual(['hi'])
     expect(parseSearchArgs({ queries: ['one', 'one', ' two '] }, WEB_SEARCH_MAX_QUERIES))
-      .toEqual({ queries: ['one', ' two '] })
-    expect(() => parseSearchArgs({}, WEB_SEARCH_MAX_QUERIES)).toThrow('provide either query or queries')
+      .toEqual(['one', ' two '])
     expect(() => parseSearchArgs({ queries: [] }, WEB_SEARCH_MAX_QUERIES)).toThrow('at least one query')
     expect(() => parseSearchArgs({ queries: ['one', 'two'] }, 1)).toThrow('at most 1 query')
     expect(() => parseSearchArgs({ queries: ['one', 'two', 'three'] }, 2)).toThrow('at most 2 queries')
     expect(() => parseSearchArgs({ queries: ['ok', ' '] }, WEB_SEARCH_MAX_QUERIES)).toThrow('each query must be a non-empty string')
-    expect(() => parseSearchArgs({ query: 'one', queries: ['two'] }, WEB_SEARCH_MAX_QUERIES)).toThrow('not both')
   })
 
   it('falls back to the raw URL as a source label when the URL is unparseable', () => {
@@ -106,17 +100,9 @@ describe('search formatting', () => {
     expect(out).toContain('[not a url](not a url)')
   })
 
-  it('presents a search call as a search-kind card titled by the query', () => {
-    expect(presentSearchCall({ query: 'find me' })).toEqual({ card: 'generic', title: 'find me', kind: 'search', rawInput: 'find me' })
-  })
-
-  it('presents a multi-query search call with a joined title', () => {
+  it('presents a search call with a joined query title', () => {
     expect(presentSearchCall({ queries: ['one', 'two'] })).toEqual({ card: 'generic', title: 'one, two', kind: 'search', rawInput: 'one, two' })
   })
-
-  it('presents malformed search arguments with an empty title', () => {
-    expect(presentSearchCall({})).toEqual({ card: 'generic', title: '', kind: 'search', rawInput: '' })
-  })
 })
 
 /** Build a completed non-error tool result with the given meta and text content. */
@@ -165,7 +151,7 @@ describe('web_search presentation meta and result view', () => {
       content: 'an answer', truncated: true,
       sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
     })
-    expect(presentSearchResult({ query: 'q' }, toolResult(meta, 'rendered'))).toEqual({
+    expect(presentSearchResult({ queries: ['q'] }, toolResult(meta, 'rendered'))).toEqual({
       card: 'web',
       kind: 'search',
       title: 'q',
@@ -177,7 +163,7 @@ describe('web_search presentation meta and result view', () => {
 
   it('omits the answer from the view when meta carries none', () => {
     const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] })
-    const view = presentSearchResult({ query: 'q' }, toolResult(meta))
+    const view = presentSearchResult({ queries: ['q'] }, toolResult(meta))
     expect(view).toBeDefined()
     expect(view && 'answer' in view).toBe(false)
     expect(view && 'content' in view).toBe(false)
@@ -185,11 +171,11 @@ describe('web_search presentation meta and result view', () => {
 
   it('falls back to the generic card on an error result', () => {
     const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] })
-    expect(presentSearchResult({ query: 'q' }, toolResult(meta, 'body', true))).toBeUndefined()
+    expect(presentSearchResult({ queries: ['q'] }, toolResult(meta, 'body', true))).toBeUndefined()
   })
 
   it('falls back to the generic card on absent or malformed meta', () => {
-    expect(presentSearchResult({ query: 'q' }, toolResult(undefined))).toBeUndefined()
+    expect(presentSearchResult({ queries: ['q'] }, toolResult(undefined))).toBeUndefined()
     expect(searchMetaFromResult(undefined)).toBeUndefined()
     expect(searchMetaFromResult(null)).toBeUndefined()
     expect(searchMetaFromResult('nope')).toBeUndefined()
@@ -465,7 +451,7 @@ describe('tool-web registration', () => {
     const names = ctx.tools.schemas().map(s => s.name)
     expect(names).toContain('web_search')
     expect(names).toContain('web_fetch')
-    expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('search-safe'), name: 'web_search', arguments: { query: 'q' } }))
+    expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('search-safe'), name: 'web_search', arguments: { queries: ['q'] } }))
       .toEqual({ kind: 'parallel' })
     expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('fetch-safe'), name: 'web_fetch', arguments: { url: 'https://a.test' } }))
       .toEqual({ kind: 'parallel' })
@@ -494,7 +480,7 @@ describe('tool-web registration', () => {
     expect(ctx.tools.schemas().map(s => s.name)).toContain('web_search')
     // No provider is registered: the schema stays visible and execution reports
     // the structured unavailability instead.
-    const out = await call('web_search', { query: 'q' })
+    const out = await call('web_search', { queries: ['q'] })
     expect(out.error?.info?.code).toBe('WEB_PROVIDER_UNAVAILABLE')
     await fiber.dispose()
   })
@@ -503,7 +489,7 @@ describe('tool-web registration', () => {
     const { fiber, ctx } = await mountTools()
     const prompt = await ctx.systemPrompt.assemble()
     const text = prompt.sections.map(s => s.text).join('\n')
-    expect(text).toContain(`Use the web_search tool to discover current information on the web. You can pass up to ${WEB_SEARCH_MAX_QUERIES} queries in one call via the queries parameter when you need several distinct searches. 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.`)
+    expect(text).toContain(`Use the web_search tool to discover current information on the web. The required queries array accepts 1–${WEB_SEARCH_MAX_QUERIES} non-empty search queries; use a one-item array for a single search. 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.`)
     expect(text).toContain('Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL')
     await fiber.dispose()
   })
@@ -525,7 +511,7 @@ describe('tool-web execution through the real registry', () => {
       sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
     }
     const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider(result) })
-    const out = await call('web_search', { query: 'q' })
+    const out = await call('web_search', { queries: ['q'] })
     expect(out.isError).toBe(false)
     expect(out.value).toEqual(result)
     expect(out.content.map(b => b.type === 'text' ? b.text : '').join('')).toContain('[A](https://a.test)')
@@ -669,12 +655,12 @@ describe('tool-web execution through the real registry', () => {
       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' })
+    const out = await call('web_search', { queries: ['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 } : {} })
+    const view = ctx.tools.get('web_search')?.presentResult?.({ queries: ['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()
   })
@@ -697,7 +683,7 @@ describe('tool-web execution through the real registry', () => {
 
   it('surfaces a structured WebError when no provider is available', async () => {
     const { fiber, call } = await mountTools()
-    const out = await call('web_search', { query: 'q' })
+    const out = await call('web_search', { queries: ['q'] })
     expect(out.isError).toBe(true)
     expect(out.error?.info?.code).toBe('WEB_PROVIDER_UNAVAILABLE')
     await fiber.dispose()
@@ -706,15 +692,15 @@ describe('tool-web execution through the real registry', () => {
   it('surfaces WEB_PROVIDER_AMBIGUOUS for multiple unconfigured providers', async () => {
     const { ctx, fiber, call } = await mountTools({ search: searchProvider({ sources: [], truncated: false }) })
     ctx.web.registerSearchProvider({ id: 'other', available: () => available, search: () => Promise.resolve({ sources: [], truncated: false }) })
-    const out = await call('web_search', { query: 'q' })
+    const out = await call('web_search', { queries: ['q'] })
     expect(out.isError).toBe(true)
     expect(out.error?.info?.code).toBe('WEB_PROVIDER_AMBIGUOUS')
     await fiber.dispose()
   })
 
-  it('rejects invalid arguments with a structured INVALID_ARGS error', async () => {
+  it.each([{}, { queries: [123] }])('rejects absent or wrongly typed queries with a structured INVALID_ARGS error', async (args) => {
     const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider({ sources: [], truncated: false }) })
-    const out = await call('web_search', { query: 123 })
+    const out = await call('web_search', args)
     expect(out.isError).toBe(true)
     expect(out.error?.info?.code).toBe('INVALID_ARGS')
     await fiber.dispose()
@@ -786,7 +772,7 @@ describe('tool-web execution through the real registry', () => {
     }
     const { ctx, fiber } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
     const controller = new AbortController()
-    await ctx.tools.execute({ callId: CallId('search-1'), name: 'web_search', arguments: { query: 'q' }, signal: controller.signal })
+    await ctx.tools.execute({ callId: CallId('search-1'), name: 'web_search', arguments: { queries: ['q'] }, signal: controller.signal })
     expect(seen.signal).toBe(controller.signal)
     await fiber.dispose()
   })
@@ -825,7 +811,7 @@ describe('searchMaxResults is plugin config', () => {
       search: (request) => { seen.maxResults = request.maxResults; return Promise.resolve({ sources: [], truncated: false }) },
     }
     const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
-    await call('web_search', { query: 'q' })
+    await call('web_search', { queries: ['q'] })
     expect(seen.maxResults).toBe(WEB_SEARCH_MAX_RESULTS)
     await fiber.dispose()
   })
@@ -838,7 +824,7 @@ describe('searchMaxResults is plugin config', () => {
       search: () => Promise.resolve({ 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' })
+    const out = await call('web_search', { queries: ['q'] })
     expect(out.isError).toBe(false)
     const body = out.content.map(b => b.type === 'text' ? b.text : '').join('')
     expect(body).toContain('https://s1.test')
@@ -878,9 +864,9 @@ describe('searchMaxQueries is plugin config', () => {
       search: provider,
     })
     const schema = ctx.tools.schemas().find(item => item.name === 'web_search')
-    expect(schema?.description).toContain('up to 2 queries')
+    expect(schema?.description).toContain('1–2 queries')
     const prompt = await ctx.systemPrompt.assemble()
-    expect(prompt.sections.map(section => section.text).join('\n')).toContain('pass up to 2 queries')
+    expect(prompt.sections.map(section => section.text).join('\n')).toContain('accepts 1–2 non-empty search queries')
     const out = await call('web_search', { queries: ['one', 'two', 'three'] })
     expect(out.isError).toBe(true)
     expect(out.content).toEqual([{ type: 'text', text: 'Error: queries must contain at most 2 queries' }])