Ver Fonte

support bounded multi-query web search

Dudu-0223 há 3 semanas atrás
pai
commit
b06722e2d4
28 ficheiros alterados com 525 adições e 145 exclusões
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml
  5. 5 5
      .agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md
  6. 5 5
      .agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.zh.md
  7. 6 0
      .agents/notes/implemented/feature/2026-08-17-web-search-multiple-queries.i18n.yaml
  8. 33 0
      .agents/notes/implemented/feature/2026-08-17-web-search-multiple-queries.md
  9. 33 0
      .agents/notes/implemented/feature/2026-08-17-web-search-multiple-queries.zh.md
  10. 3 3
      apps/web/tests/snapshots/web-search-round/session.jsonl
  11. 4 4
      apps/web/tests/snapshots/web-search-round/ui.expected.md
  12. 64 46
      apps/web/tests/web-search-round.e2e.ts
  13. 2 2
      docs/config-catalog.i18n.yaml
  14. 3 1
      docs/config-catalog.md
  15. 3 1
      docs/config-catalog.zh.md
  16. 2 2
      docs/tool-catalog.i18n.yaml
  17. 10 6
      docs/tool-catalog.md
  18. 10 6
      docs/tool-catalog.zh.md
  19. 5 2
      packages/client/connection/src/client/fixture.ts
  20. 4 0
      packages/client/ui-tool/src/client/tool/models/tool-call-model.ts
  21. 7 0
      packages/client/ui-tool/tests/tool-row.client.spec.tsx
  22. 2 2
      packages/web/tool-web/README.i18n.yaml
  23. 14 12
      packages/web/tool-web/README.md
  24. 14 12
      packages/web/tool-web/README.zh.md
  25. 9 5
      packages/web/tool-web/src/index.ts
  26. 135 23
      packages/web/tool-web/src/search.ts
  27. 1 1
      packages/web/tool-web/tests/integration.spec.ts
  28. 145 1
      packages/web/tool-web/tests/tool-web.spec.ts

+ 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: ce414e541f8e374dd48e46d68cb00121e0004247
-2026-07-07-tool-call-timeout-policy.zh.md: 6fe3c979a3c4e7b7a6ed803a47af45ad32d52cce
+2026-07-07-tool-call-timeout-policy.md: 3d5425b3caed97f0faca01ff656cc35811374b77
+2026-07-07-tool-call-timeout-policy.zh.md: 9c2323d235158986c72eef253473876c765dd867

+ 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` dropped its `timeout_ms` parameter to match the reference-agent shape, and `web_search` stays query-only. 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 `query` or `queries` 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` 参数以匹配参考 agent(智能体)的形状,`web_search` 保持仅查询。工具体不导入 `@deepseek-ai/dsh-timeout`;它们将 `exec.signal` 转发给 `ctx.web`。
+`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`。
 
 `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-03-web-search-source-scroll.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-03-web-search-source-scroll.md
-2026-08-03-web-search-source-scroll.md: 3402f519e1974b99e1f5a87dcd53b4d94a1a8374
-2026-08-03-web-search-source-scroll.zh.md: 8ac1158d054f739bb1f76c87570ac1e75b77e05d
+2026-08-03-web-search-source-scroll.md: 6fe532e2a2989e834b926cf48d531ae60a32f58b
+2026-08-03-web-search-source-scroll.zh.md: bc1abb5215c618809e79f56d1f9bd6c1ee9dbf15

+ 5 - 5
.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md

@@ -8,13 +8,13 @@ English | [中文](2026-08-03-web-search-source-scroll.zh.md)
 
 The `web_search` result card (`WebBlock`, `packages/client/ui-primitives/src/WebBlock.tsx`) rendered its source list with a head/tail collapse: past a `maxSources` count (16 in the details panel, 8 in the chat row via `CHAT_WEB_MAX_SOURCES`) it drew the first `ceil(max/2)` sources, an `… 其余 N 条来源` expand button, then the last `max - ceil(max/2)`, mirroring `TerminalBlock`'s output cap. A user reading the card saw `来源列表已截断` and assumed the frontend had dropped sources it was holding.
 
-It had not. The seam (`capSources`, `packages/web/web/src/index.ts`) cuts the provider's sources to the tool's `searchMaxResults` bound (default 8) and sets `truncated`, and that one capped list feeds both the model-facing render text and the card's `presentationMeta`. The card never holds more sources than that one cut produced. So the collapse was hiding sources the user was entitled to see in full — and, with the default bound at 8 and the panel cap at 16, it almost never even triggered, leaving only the `truncated` note with no way to reveal anything.
+It had not. The seam (`capSources`, `packages/web/web/src/index.ts`) cuts each provider result to the tool's `searchMaxResults` bound (default 8); a multi-query call then deduplicates, interleaves, and caps the combined sources at the same bound. The final capped list feeds both the model-facing render text and the card's `presentationMeta`, so the card never holds more sources than the tool returned. The collapse was hiding sources the user was entitled to see in full — and, with the default bound at 8 and the panel cap at 16, it almost never even triggered, leaving only the `truncated` note with no way to reveal anything.
 
 ## Decision
 
 `WebBlock`'s search arm renders every source it receives in one `<ol className={css.sources}>`, with no head/tail slicing, no expand button, and no `maxSources` prop. `.sources` (`WebBlock.module.css`) gets a fixed `max-height` and `overflow-y: auto`, so a list longer than the card height scrolls in place rather than growing the card or hiding rows. The height is a design constant of the card geometry, so it lives in CSS, not a plugin config field.
 
-The model side is unchanged: the seam still caps sources at `searchMaxResults`, the model-facing render text is untouched, and the `truncated` flag and its `来源列表已截断` indicator stay. The card draws the list the seam produced, in full and scrollable, instead of collapsing its middle.
+The model side remains capped at `searchMaxResults`: the seam caps each provider result, the multi-query consumer caps a combined list, and the `truncated` flag and its `来源列表已截断` indicator stay. The card draws the final tool source list in full and scrollable, instead of collapsing its middle.
 
 That list is the one the model reads as long as nothing downstream of the tool rewrites the result content alone. A deployment mounting `dsh-spill-policy` breaks that correspondence for an oversized result: `tools/post-execute` replaces the model-facing `content` with a preview plus a spill locator and leaves `presentationMeta` whole, so the card still draws every source while the model reads a bounded excerpt. The card's contract is therefore the view it receives, not the model's context.
 
@@ -36,11 +36,11 @@ Every source the tool returned is always in the DOM, so no source the view carri
 
 ## Testing
 
-`packages/client/ui-primitives/tests/web-block.client.spec.tsx` drops the collapse cases (head/tail slice, expand-on-click, collapsed-tail numbering, expander-out-of-numbering, head-alone, default cap) and adds: a 30-source card renders all 30 `<li>` with no `[aria-expanded]` and no `<button>`, every `<ol>` child is a source `<li>`, and `<li value>` numbers 1..N contiguously. `packages/client/ui-tool/tests/web-card.client.spec.tsx` drops the `CHAT_WEB_MAX_SOURCES` cap assertion; the WebRow expansion test still asserts the card shows every source field. The `packages/web/tool-web` tests are unchanged — the model side did not move.
+`packages/client/ui-primitives/tests/web-block.client.spec.tsx` drops the collapse cases (head/tail slice, expand-on-click, collapsed-tail numbering, expander-out-of-numbering, head-alone, default cap) and adds: a 30-source card renders all 30 `<li>` with no `[aria-expanded]` and no `<button>`, every `<ol>` child is a source `<li>`, and `<li value>` numbers 1..N contiguously. `packages/client/ui-tool/tests/web-card.client.spec.tsx` drops the `CHAT_WEB_MAX_SOURCES` cap assertion; the WebRow expansion test still asserts the card shows every source field. `packages/web/tool-web` independently pins the single- and multi-query model-side caps.
 
-jsdom resolves no CSS Modules layout, so it reports `scrollHeight === clientHeight` for every element and cannot witness the scroll at all. The geometry is pinned in the assembled browser instead, by `apps/web/tests/web-search-round.e2e.ts`: its deterministic search double returns 12 provider results, each with a title, a citation snippet, and a date. That first pins the seam's cap end to end in a real composition — the shipped `searchMaxResults` keeps 8, the model-visible render text carries the 8 kept titles and none of the 4 dropped URLs plus `(Showing the first 8 sources. Refine the query for more.)`, and `meta.truncated` is true. A case after the aria golden then expands the `web_search` row and asserts on the card's `<ol>`: 8 `<li>`, no `<button>` anywhere in the card, the `来源列表已截断` indicator visible, and computed `max-height: 320px` with `overflow-y: auto` over `scrollHeight` 574 against `clientHeight` 320. A further case measures a `999. ` marker in the list's own inherited font and requires the computed `padding-left` to be at least that wide, so the marker room the scroll container cannot clip back is pinned against the widest marker rather than against one fixture's source count. Neither the recorded stream nor the aria golden moved: replay is a positional cursor over the fixture's `assistant/chunk` entries and the search double is a separate local endpoint the provider reaches by `fetch`, while the card is collapsed at capture time so its `<ol>` is out of the DOM and the summary row carries no source count.
+jsdom resolves no CSS Modules layout, so it reports `scrollHeight === clientHeight` for every element and cannot witness the scroll at all. The geometry is pinned in the assembled browser instead, by `apps/web/tests/web-search-round.e2e.ts`: its deterministic search double returns 6 results for each of two queries, each with a title, a citation snippet, and a date. The real composition observes both provider requests and pins the tool's round-robin combined cap — the shipped `searchMaxResults` keeps 8 sources representing both queries, the model-visible render text omits the 4 dropped URLs and includes `(Showing the first 8 sources. Refine the query for more.)`, and `meta.truncated` is true. A case after the aria golden then expands the `web_search` row and asserts on the card's `<ol>`: 8 `<li>`, no `<button>` anywhere in the card, the `来源列表已截断` indicator visible, and computed `max-height: 320px` with `overflow-y: auto` over a taller scroll body. A further case measures a `999. ` marker in the list's own inherited font and requires the computed `padding-left` to be at least that wide, so the marker room the scroll container cannot clip back is pinned against the widest marker rather than against one fixture's source count. Replay is a positional cursor over the fixture's `assistant/chunk` entries and the search double is a separate local endpoint the provider reaches by `fetch`.
 
 ## Related
 
-- [Web result card](2026-07-30-web-result-card.md) — the `card: 'web'` render-intent arm and `presentationMeta` route this card consumes; the source of the capped-once list.
+- [Web result card](2026-07-30-web-result-card.md) — the `card: 'web'` render-intent arm and `presentationMeta` route this card consumes; the source of the final capped list.
 - [Web result card frontend](2026-07-30-web-result-card-frontend.md) — owns `WebBlock`, the single `web-card-model` derivation, and the render sites that draw the card; this note replaces the source-list collapse it specified, and its other decisions (one component for both kinds, the http(s) link allowlist, the single derivation, the resident posture) stand.

+ 5 - 5
.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.zh.md

@@ -8,13 +8,13 @@ Status: implemented
 
 `web_search` 结果卡片(`WebBlock`,`packages/client/ui-primitives/src/WebBlock.tsx`)此前用首尾折叠渲染它的来源列表:超过 `maxSources` 数量(详情面板为 16,聊天行经由 `CHAT_WEB_MAX_SOURCES` 为 8)时,它画出前 `ceil(max/2)` 条来源、一个 `… 其余 N 条来源` 展开按钮,再画出末尾 `max - ceil(max/2)` 条,仿照 `TerminalBlock` 的输出上限机制。用户阅读该卡片时看到 `来源列表已截断`,会以为前端丢弃了它正持有的来源。
 
-其实并没有。seam(`capSources`,`packages/web/web/src/index.ts`)把 provider 的来源裁剪到工具的 `searchMaxResults` 上限(默认 8)并置位 `truncated`,而这一份被裁剪过一次的列表同时喂给面向模型的 render 文本与卡片的 `presentationMeta`。卡片持有的来源绝不会多于这一次裁剪的产物。因此这个折叠隐藏的正是用户本有权完整查看的来源——并且在默认上限为 8、面板上限为 16 时,它几乎从不触发,只留下 `truncated` 提示,却无从展开任何内容。
+其实并没有。seam(`capSources`,`packages/web/web/src/index.ts`)把每个提供方结果裁剪到工具的 `searchMaxResults` 上限(默认 8);多查询调用随后对组合来源去重、交错并限制在同一个上限内。最终的有界列表同时喂给面向模型的 render 文本与卡片的 `presentationMeta`,因此卡片持有的来源绝不会多于工具返回的来源。这个折叠隐藏的正是用户本有权完整查看的来源——并且在默认上限为 8、面板上限为 16 时,它几乎从不触发,只留下 `truncated` 提示,却无从展开任何内容。
 
 ## 决策
 
 `WebBlock` 的 search 分支把它收到的每一条来源都渲染进单个 `<ol className={css.sources}>`,不做首尾切片、不设展开按钮、也不带 `maxSources` prop。`.sources`(`WebBlock.module.css`)获得一个固定的 `max-height` 与 `overflow-y: auto`,因此长于卡片高度的列表在原地滚动,而非撑大卡片或隐藏行。该高度是卡片几何形状的一个设计常量,因此放在 CSS 里,而非插件配置字段。
 
-模型侧不变:seam 仍在 `searchMaxResults` 处封顶来源,面向模型的 render 文本未动,`truncated` 标志及其 `来源列表已截断` 指示保留。卡片完整且可滚动地画出 seam 产出的这份列表,而非折叠其中段。
+模型侧仍受 `searchMaxResults` 限制:seam 限制每个提供方结果,多查询消费方限制组合列表,`truncated` 标志及其 `来源列表已截断` 指示保留。卡片完整且可滚动地画出最终工具来源列表,而非折叠其中段。
 
 只要工具下游没有单独改写结果 content,这份列表就是模型读到的那份。挂载了 `dsh-spill-policy` 的部署会对超限结果打破这一对应:`tools/post-execute` 把面向模型的 `content` 替换为预览加 spill 定位符,而 `presentationMeta` 原样保留,因此卡片仍画出全部来源,模型读到的却是一段有界摘录。所以卡片的约定是它收到的 view,不是模型的上下文。
 
@@ -36,11 +36,11 @@ Status: implemented
 
 ## 测试
 
-`packages/client/ui-primitives/tests/web-block.client.spec.tsx` 删去折叠相关用例(首尾切片、点击展开、折叠尾部编号、展开器不计入编号、仅首部、默认上限),并新增:一张含 30 条来源的卡片渲染出全部 30 个 `<li>`,无 `[aria-expanded]`、无 `<button>`,每个 `<ol>` 子元素都是一条来源 `<li>`,且 `<li value>` 从 1 到 N 连续编号。`packages/client/ui-tool/tests/web-card.client.spec.tsx` 删去 `CHAT_WEB_MAX_SOURCES` 上限断言;WebRow 展开测试仍断言卡片展示每一个来源字段。`packages/web/tool-web` 的测试不变——模型侧没有改动
+`packages/client/ui-primitives/tests/web-block.client.spec.tsx` 删去折叠相关用例(首尾切片、点击展开、折叠尾部编号、展开器不计入编号、仅首部、默认上限),并新增:一张含 30 条来源的卡片渲染出全部 30 个 `<li>`,无 `[aria-expanded]`、无 `<button>`,每个 `<ol>` 子元素都是一条来源 `<li>`,且 `<li value>` 从 1 到 N 连续编号。`packages/client/ui-tool/tests/web-card.client.spec.tsx` 删去 `CHAT_WEB_MAX_SOURCES` 上限断言;WebRow 展开测试仍断言卡片展示每一个来源字段。`packages/web/tool-web` 独立固定单查询与多查询的模型侧上限
 
-jsdom 不解析 CSS Modules 布局,对任何元素都报 `scrollHeight === clientHeight`,因此它根本无从见证这次滚动。几何改由组装态浏览器钉住,位于 `apps/web/tests/web-search-round.e2e.ts`:其确定性 search double 返回 12 条提供方结果,每条带标题、引用摘录与日期。这首先在真实组合里端到端钉住 seam 的裁剪——出厂 `searchMaxResults` 保留 8 条,面向模型的 render 文本含这 8 条标题、不含被丢弃的 4 条 URL,并含 `(Showing the first 8 sources. Refine the query for more.)`,`meta.truncated` 为 true。随后位于 aria golden 之后的一个用例展开 `web_search` 行,对卡片的 `<ol>` 断言:8 个 `<li>`、卡片内任何位置都没有 `<button>`、`来源列表已截断` 指示可见,以及计算样式 `max-height: 320px` 与 `overflow-y: auto`,`scrollHeight` 为 574、`clientHeight` 为 320。再后一个用例在列表自身继承的字体下量出 `999. ` 序号的宽度,要求计算后的 `padding-left` 不小于该宽度,从而把滚动容器无从滚回的那段序号空间钉在最宽序号上,而非钉在某一份 fixture(测试前置数据)的来源条数上。录制的模型流与 aria golden 都未变动:回放是对 fixture 中 `assistant/chunk` 条目的位置游标,而 search double 是提供方经 `fetch` 抵达的另一个本地端点;捕获时卡片处于折叠状态,其 `<ol>` 不在 DOM 中,摘要行也不携带来源数量
+jsdom 不解析 CSS Modules 布局,对任何元素都报 `scrollHeight === clientHeight`,因此它根本无从见证这次滚动。几何改由组装态浏览器钉住,位于 `apps/web/tests/web-search-round.e2e.ts`:其确定性 search double 为两个查询分别返回 6 条结果,每条带标题、引用摘录与日期。真实组合会观察两次提供方请求,并固定工具的轮询组合上限——出厂 `searchMaxResults` 保留代表两个查询的 8 条来源,面向模型的 render 文本不含被丢弃的 4 条 URL,并含 `(Showing the first 8 sources. Refine the query for more.)`,`meta.truncated` 为 true。随后位于 aria golden 之后的一个用例展开 `web_search` 行,对卡片的 `<ol>` 断言:8 个 `<li>`、卡片内任何位置都没有 `<button>`、`来源列表已截断` 指示可见,以及计算样式 `max-height: 320px` 与 `overflow-y: auto`,滚动主体高于容器。再后一个用例在列表自身继承的字体下量出 `999. ` 序号的宽度,要求计算后的 `padding-left` 不小于该宽度,从而把滚动容器无从滚回的那段序号空间钉在最宽序号上,而非钉在某一份 fixture(测试前置数据)的来源条数上。回放是对 fixture 中 `assistant/chunk` 条目的位置游标,而 search double 是提供方经 `fetch` 抵达的另一个本地端点。
 
 ## 相关文档
 
-- [Web result card](2026-07-30-web-result-card.md) —— 本卡片消费的 `card: 'web'` 渲染意图分支与 `presentationMeta` 路由;那份裁剪过一次的列表的来源。
+- [Web result card](2026-07-30-web-result-card.md) —— 本卡片消费的 `card: 'web'` 渲染意图分支与 `presentationMeta` 路由;最终有界列表的来源。
 - [Web result 卡片前端](2026-07-30-web-result-card-frontend.md) —— `WebBlock`、唯一的 `web-card-model` 派生,以及绘制该卡片的各渲染点由它拥有;本笔记替换掉它所规定的来源列表折叠,它的其余决策(一个组件绘制两种 kind、http(s) 链接 allowlist、单一派生、常驻姿态)依然成立。

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

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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: c5307d2bdbdf3f0d5ea4375b4dd19c6018414b5b
+2026-08-17-web-search-multiple-queries.zh.md: c2fc9cb677c9e14d1e24a59345ce0f28e9a91b56

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

@@ -0,0 +1,33 @@
+# Agent Note: web_search accepts multiple queries in one call
+
+Status: implemented
+
+English | [中文](2026-08-17-web-search-multiple-queries.zh.md)
+
+## Problem
+
+The model-facing `web_search` tool accepted only one `query`. In deployments where an internal search backend was also exposed as MCP, models preferred the MCP search tool because it could take multiple keywords in one call, and they often followed a native `web_search` with a second MCP search when the first result felt insufficient.
+
+## 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.
+
+When `queries` has multiple 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. The single-query path remains unchanged.
+
+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.
+
+## Alternatives considered
+
+**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.
+
+**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.
+
+## 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. 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. The schema no longer marks `query` as required; runtime validation requires exactly one of `query` or `queries`.
+
+`searchMaxQueries` is not a total native-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. Issue #2602 records the decision still required before this implementation is ready for review: whether independently configurable bounds are sufficient or the provider contract needs an overall budget.
+
+The real Web composition snapshot issues one `queries` call through the DeepSeek search provider, observes two auxiliary provider requests, and pins the round-robin combined result, durable metadata, and joined search-card title. Package tests separately prove overlap before the first provider promise settles, query-cap rejection before provider dispatch, deduplication, truncation, and cancellation propagation.

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

@@ -0,0 +1,33 @@
+# Agent Note: web_search 支持一次传入多个查询
+
+Status: implemented
+
+[English](2026-08-17-web-search-multiple-queries.md) | 中文
+
+## 问题
+
+面向模型的 `web_search` 工具原来只接受单个 `query`。在同时把内部搜索后端以 MCP 方式暴露的部署中,模型更倾向于使用 MCP 搜索工具,因为它能一次传入多个关键词;模型也常常在调用原生 `web_search` 后觉得结果不够,再补一次 MCP 搜索。
+
+## 决定
+
+`web_search` 接受原有的 `query` 字符串,或 `queries` 字符串数组,但不能同时传两者。`searchMaxQueries` 限制数组大小与提供方请求扇出,默认值为 4,并出现在系统提示词指引与工具描述中。校验会在任何提供方调用开始前拒绝超限数组。
+
+当 `queries` 包含多个条目时,`dsh-tool-web` 会通过 `ctx.web.search` 并发执行这些搜索,用来源查询标注提供方答案,并按 URL 对来源去重。它从每个查询取得同一排名的一条来源后再推进至下一排名,然后把组合列表限制在 `searchMaxResults` 上限内;这样,一个查询排名较低的来源不会挤掉后续查询的所有来源。单查询路径保持不变。
+
+多查询编排放在工具消费方,而不是 web seam 或提供方,因为 `WebSearchProvider.search` 仍是单查询契约,seam 也保持提供方无关。
+
+## 备选方案
+
+**依赖现有的并行工具调用能力。** 不采用:模型看到的仍然是单查询 schema,必须自行决定发起多次 `web_search` 调用,这正是把它推向 MCP 接口的摩擦点。
+
+**给 `WebSearchRequest` 增加多查询请求类型。** 不采用:提供方都是单查询后端,而且修改共享 seam 会迫使每个提供方实现只有模型侧消费方才需要的功能。
+
+**接受无上限的 `queries` 数组。** 不采用:一次模型操作可以启动任意数量的提供方请求,并拼接任意数量的提供方答案。由部署拥有的上限既让模型 schema 聚焦搜索输入,也能控制成本与输出增长。
+
+## 结果
+
+模型可以把多个不同搜索合并到一次原生 `web_search` 调用中,减少转向 MCP 搜索的动机。默认查询上限 4 与 Codex `web.run` 面向模型的批量大小一致,同时限制并发提供方调用;部署可以独立于来源上限选择另一个正整数。组合来源仍受 `searchMaxResults` 限制,并通过轮询合并保留每个查询的结果排名。多查询结果中的提供方答案会以 `### <query>` 标题标注,便于模型区分答案来自哪个搜索。schema 不再把 `query` 标记为必填;运行时校验要求 `query` 与 `queries` 二选一。
+
+`searchMaxQueries` 不是原生搜索总预算。提供方可以在一次 `ctx.web.search` 调用内执行多次原生搜索,因此拥有自身 `maxUses` 的模型型提供方最多可以执行 `searchMaxQueries × maxUses` 次原生搜索。`searchMaxResults` 只限制返回给调用方的组合来源。Issue #2602 记录了本实现进入正式审查前仍需完成的决策:独立配置的上限是否足够,还是提供方契约需要原生搜索总预算。
+
+真实 Web 组合快照通过 DeepSeek 搜索提供方发起一次 `queries` 调用,观察两次辅助提供方请求,并固定轮询组合结果、持久化元数据和拼接后的搜索卡片标题。包测试另行证明:第一个提供方 promise 结算前已经发起重叠调用;查询上限会在提供方分发前拒绝请求;去重、截断与取消信号传播保持正确。

+ 3 - 3
apps/web/tests/snapshots/web-search-round/session.jsonl

@@ -1,8 +1,8 @@
 {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785456000000,"cwd":"{{cwd}}"}
-{"type":"user/message","seq":0,"time":1785456000001,"data":{"content":[{"type":"text","text":"Use web_search to search exactly \"DeepSeek Harness snapshot search\". Then reply exactly SEARCH_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"user/message","seq":0,"time":1785456000001,"data":{"content":[{"type":"text","text":"Use web_search once with queries [\"DeepSeek Harness snapshot search\",\"DeepSeek Harness multi-query search\"]. Then reply exactly SEARCH_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
 {"type":"assistant/chunk","seq":1,"time":1785456000002,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
-{"type":"assistant/chunk","seq":2,"time":1785456000003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_web_search","name":"web_search","argumentsDelta":"{\"query\":\"DeepSeek Harness snapshot search\"}"}}}
-{"type":"assistant/chunk","seq":3,"time":1785456000004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_web_search","name":"web_search","arguments":"{\"query\":\"DeepSeek Harness snapshot search\"}"}}}}
+{"type":"assistant/chunk","seq":2,"time":1785456000003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_web_search","name":"web_search","argumentsDelta":"{\"queries\":[\"DeepSeek Harness snapshot search\",\"DeepSeek Harness multi-query search\"]}"}}}
+{"type":"assistant/chunk","seq":3,"time":1785456000004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_web_search","name":"web_search","arguments":"{\"queries\":[\"DeepSeek Harness snapshot search\",\"DeepSeek Harness multi-query search\"]}"}}}}
 {"type":"assistant/chunk","seq":4,"time":1785456000005,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
 {"type":"assistant/chunk","seq":5,"time":1785456000006,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/chunk","seq":6,"time":1785456000007,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}

+ 4 - 4
apps/web/tests/snapshots/web-search-round/ui.expected.md

@@ -1,6 +1,6 @@
 - banner:
   - navigation "Session hierarchy":
-    - button "Use web_search to search exactly" [disabled]
+    - button "Use web_search once with queries" [disabled]
   - img
   - text: Standard mode
   - button "Session log":
@@ -9,17 +9,17 @@
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"
-- text: Use web_search to search exactly "DeepSeek Harness snapshot search". Then reply exactly SEARCH_DONE and stop. {{clock}}
+- text: Use web_search once with queries ["DeepSeek Harness snapshot search","DeepSeek Harness multi-query search"]. Then reply exactly SEARCH_DONE and stop. {{clock}}
 - button "Copy":
   - img
 - button "Context injection @deepseek-ai/dsh-system-prompt":
   - img
   - img
   - text: Context injection @deepseek-ai/dsh-system-prompt
-- button "Search DeepSeek Harness snapshot search":
+- button "Search DeepSeek Harness snapshot search, DeepSeek Harness multi-query search":
   - img
   - img
-  - text: Search DeepSeek Harness snapshot search
+  - text: Search DeepSeek Harness snapshot search, DeepSeek Harness multi-query search
 - paragraph: SEARCH_DONE
 - button "Copy":
   - img

+ 64 - 46
apps/web/tests/web-search-round.e2e.ts

@@ -22,32 +22,32 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/web-search-round', impor
 const FIXTURE = fileURLToPath(new URL('./snapshots/web-search-round/session.jsonl', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('./snapshots/web-search-round/ui.expected.md', import.meta.url))
 const MODE = webSnapshotMode()
-const QUERY = 'DeepSeek Harness snapshot search'
-const PROMPT = `Use web_search to search exactly "${QUERY}". Then reply exactly SEARCH_DONE and stop.`
+const QUERIES = ['DeepSeek Harness snapshot search', 'DeepSeek Harness multi-query search'] as const
+const PROMPT = `Use web_search once with queries ${JSON.stringify(QUERIES)}. Then reply exactly SEARCH_DONE and stop.`
 const SEARCH_CREDENTIAL_REF = credentialRef('DSH_WEB_SEARCH_E2E_KEY')
 const SEARCH_CREDENTIAL = 'snapshot-search-key'
 
 /**
- * Provider results the double returns, exceeding the shipped `searchMaxResults`
- * so the seam's cap and the card's scroll container are both exercised. Each row
- * carries a title, a snippet, and a date, so 8 kept rows exceed the `.sources`
- * 320px max-height.
+ * Provider results the double returns per query. The combined result exceeds
+ * the shipped `searchMaxResults`, so the tool's round-robin cap and the card's
+ * scroll container are both exercised. Each row carries a title, a snippet,
+ * and a date, so 8 kept rows exceed the `.sources` 320px max-height.
  */
-const PROVIDER_RESULT_COUNT = 12
+const PROVIDER_RESULT_COUNT = 6
 
 /** One provider result's URL, by 1-based provider order. */
-function resultUrl(ordinal: number): string {
-  return `https://docs.example.test/search/${ordinal}`
+function resultUrl(queryIndex: number, ordinal: number): string {
+  return `https://docs.example.test/search/${queryIndex + 1}/${ordinal}`
 }
 
 /** One provider result's title, by 1-based provider order. */
-function resultTitle(ordinal: number): string {
-  return `Snapshot Search Result ${ordinal}`
+function resultTitle(queryIndex: number, ordinal: number): string {
+  return `Snapshot Search ${queryIndex + 1} Result ${ordinal}`
 }
 
 /** One provider result's citation excerpt, by 1-based provider order. */
-function resultSnippet(ordinal: number): string {
-  return `Snapshot search excerpt ${ordinal}: the harness replays this source list from a local endpoint.`
+function resultSnippet(queryIndex: number, ordinal: number): string {
+  return `Snapshot search ${queryIndex + 1} excerpt ${ordinal}: the harness replays this source list from a local endpoint.`
 }
 
 /** One provider result's `page_age`, by 1-based provider order (July 2026 days 01..12). */
@@ -58,6 +58,19 @@ function resultPageAge(ordinal: number): string {
 /** The 1-based provider ordinals, in provider order. */
 const RESULT_ORDINALS = Array.from({ length: PROVIDER_RESULT_COUNT }, (_value, index) => index + 1)
 
+/** Sources kept after round-robin merging reaches the shipped combined cap. */
+const KEPT_SOURCES = RESULT_ORDINALS.flatMap(ordinal => QUERIES.map((_query, queryIndex) => ({
+  url: resultUrl(queryIndex, ordinal),
+  title: resultTitle(queryIndex, ordinal),
+  snippet: resultSnippet(queryIndex, ordinal),
+  publishedAt: resultPageAge(ordinal),
+}))).slice(0, WEB_SEARCH_MAX_RESULTS)
+
+/** URLs omitted after the combined source cap is reached. */
+const DROPPED_SOURCE_URLS = RESULT_ORDINALS.flatMap(ordinal => QUERIES.map(
+  (_query, queryIndex) => resultUrl(queryIndex, ordinal),
+)).slice(WEB_SEARCH_MAX_RESULTS)
+
 interface CapturedSearchRequest {
   path: string
   apiKey: string | undefined
@@ -71,11 +84,19 @@ async function startSearchServer(captured: CapturedSearchRequest[]): Promise<{ s
     request.setEncoding('utf8')
     request.on('data', (chunk: string) => { body += chunk })
     request.on('end', () => {
+      const parsedBody = JSON.parse(body) as unknown
       captured.push({
         path: request.url ?? '',
         apiKey: typeof request.headers['x-api-key'] === 'string' ? request.headers['x-api-key'] : undefined,
-        body: JSON.parse(body) as unknown,
+        body: parsedBody,
       })
+      const serializedBody = JSON.stringify(parsedBody)
+      const queryIndex = QUERIES.findIndex(query => serializedBody.includes(`Perform a web search for the query: ${query}`))
+      if (queryIndex < 0) {
+        response.writeHead(400, { 'content-type': 'application/json' })
+        response.end(JSON.stringify({ error: 'unknown fixture query' }))
+        return
+      }
       response.writeHead(200, { 'content-type': 'application/json' })
       response.end(JSON.stringify({
         content: [
@@ -84,16 +105,16 @@ async function startSearchServer(captured: CapturedSearchRequest[]): Promise<{ s
             text: `Found ${PROVIDER_RESULT_COUNT} sources.`,
             citations: RESULT_ORDINALS.map(ordinal => ({
               type: 'web_search_result_location',
-              url: resultUrl(ordinal),
-              cited_text: resultSnippet(ordinal),
+              url: resultUrl(queryIndex, ordinal),
+              cited_text: resultSnippet(queryIndex, ordinal),
             })),
           },
           {
             type: 'web_search_tool_result',
             content: RESULT_ORDINALS.map(ordinal => ({
               type: 'web_search_result',
-              url: resultUrl(ordinal),
-              title: resultTitle(ordinal),
+              url: resultUrl(queryIndex, ordinal),
+              title: resultTitle(queryIndex, ordinal),
               page_age: resultPageAge(ordinal),
             })),
           },
@@ -173,28 +194,32 @@ describe('web e2e: shipped default web search', () => {
   }, 200_000)
 
   it.skipIf(MODE === 'record')('uses the real provider and persists the capped structured result', () => {
-    expect(searchRequests).toHaveLength(1)
-    expect(searchRequests[0]).toMatchObject({
-      path: '/messages',
-      apiKey: SEARCH_CREDENTIAL,
-      body: {
+    expect(searchRequests).toHaveLength(QUERIES.length)
+    for (const query of QUERIES) {
+      const request = searchRequests.find(candidate => JSON.stringify(candidate.body).includes(query))
+      if (request === undefined) throw new Error(`missing provider request for query: ${query}`)
+      expect(request).toMatchObject({ path: '/messages', apiKey: SEARCH_CREDENTIAL })
+      expect(request.body).toMatchObject({
         messages: [{
           role: 'user',
-          content: [{ type: 'text', text: `Perform a web search for the query: ${QUERY}` }],
+          content: [{ type: 'text', text: `Perform a web search for the query: ${query}` }],
         }],
-        tools: [{ type: 'web_search_20250305', name: 'web_search' }],
-      },
-    })
+      })
+      const tools = (request.body as { tools?: unknown }).tools
+      expect(tools).toHaveLength(1)
+      expect((tools as unknown[])[0]).toMatchObject({ type: 'web_search_20250305', name: 'web_search' })
+    }
 
-    const auxiliaryRequest = sessionEvents.find(
+    const auxiliaryRequests = sessionEvents.filter(
       (event): event is Extract<SessionEvent, { type: 'web/deepseek-search-llm-request' }> =>
         event.type === 'web/deepseek-search-llm-request',
     )
-    expect(auxiliaryRequest?.data).toEqual({
+    expect(auxiliaryRequests).toHaveLength(QUERIES.length)
+    expect(auxiliaryRequests.map(event => event.data)).toEqual(searchRequests.map(request => ({
       endpoint: `${searchBaseURL}/messages`,
       apiVersion: '2023-06-01',
-      body: searchRequests[0]?.body,
-    })
+      body: request.body,
+    })))
 
     const searchCall = sessionEvents.find(
       (event): event is Extract<SessionEvent, { type: 'tool/call' }> =>
@@ -209,25 +234,19 @@ describe('web e2e: shipped default web search', () => {
     const content = searchResult.data.message.content[0]
     expect(content.isError).toBe(false)
     const rendered = content.content.filter(block => block.type === 'text').map(block => block.text).join('')
-    // The seam caps the provider's list at the shipped searchMaxResults before
-    // the tool renders it, so the kept prefix is model-visible and the dropped
-    // suffix is not.
-    for (const ordinal of RESULT_ORDINALS.slice(0, WEB_SEARCH_MAX_RESULTS)) {
-      expect(rendered).toContain(`[${resultTitle(ordinal)}](${resultUrl(ordinal)})`)
+    // The tool interleaves sources from both seam results before applying the
+    // combined cap, so each query remains represented in model-visible output.
+    for (const source of KEPT_SOURCES) {
+      expect(rendered).toContain(`[${source.title}](${source.url})`)
     }
-    for (const ordinal of RESULT_ORDINALS.slice(WEB_SEARCH_MAX_RESULTS)) {
-      expect(rendered).not.toContain(resultUrl(ordinal))
+    for (const url of DROPPED_SOURCE_URLS) {
+      expect(rendered).not.toContain(url)
     }
     expect(rendered).toContain(
       `(Showing the first ${WEB_SEARCH_MAX_RESULTS} sources. Refine the query for more.)`,
     )
     expect(searchResult.data.meta).toMatchObject({
-      sources: RESULT_ORDINALS.slice(0, WEB_SEARCH_MAX_RESULTS).map(ordinal => ({
-        url: resultUrl(ordinal),
-        title: resultTitle(ordinal),
-        snippet: resultSnippet(ordinal),
-        publishedAt: resultPageAge(ordinal),
-      })),
+      sources: KEPT_SOURCES,
       truncated: true,
     })
   })
@@ -250,8 +269,7 @@ describe('web e2e: shipped default web search', () => {
     const card = page.locator('[data-web="search"]')
     const sources = card.locator('ol')
     await sources.waitFor({ timeout: 10_000 })
-    // The card draws exactly the sources the model saw: the seam's cap, not the
-    // provider's list length.
+    // The card draws exactly the sources the model saw after the combined cap.
     expect(await sources.locator('li').count()).toBe(WEB_SEARCH_MAX_RESULTS)
     // The list is complete in the DOM, so the card carries no expand control.
     expect(await card.locator('button').count()).toBe(0)

+ 2 - 2
docs/config-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/config-catalog.md
-config-catalog.md: c379a7a49e4aa670aac3aa203e216b2be8e1955d
-config-catalog.zh.md: e897f5d25a485133d4929061dce0b398edfa8c04
+config-catalog.md: c03f1088745a5746a59b2fbddf60d7b270951371
+config-catalog.zh.md: 4c1f62f7c899dd6bf7c55c33e802859be9e79e5e

+ 3 - 1
docs/config-catalog.md

@@ -2796,7 +2796,7 @@ Source: [`packages/todo/tool-todo/src/index.ts:29`](../packages/todo/tool-todo/s
 Requires: `tools` · `web` · `systemPrompt`
 
 ```ts config-catalog
-/** Plugin config: which web tools to register, the source cap, per-tool budgets, and the fetch output cap. */
+/** Plugin config: which web tools to register, search bounds, per-tool budgets, and the fetch output cap. */
 export interface Config {
   /** Register `web_search`. Defaults to true. */
   search?: boolean
@@ -2804,6 +2804,8 @@ export interface Config {
   fetch?: boolean
   /** Upper bound on sources returned by one `web_search` call. */
   searchMaxResults?: number
+  /** Upper bound on queries accepted by one `web_search` call. */
+  searchMaxQueries?: number
   /** Cooperative timeout budget (ms) for `web_fetch`. Defaults to 30000. */
   fetchTimeoutMs?: number
   /** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */

+ 3 - 1
docs/config-catalog.zh.md

@@ -2800,7 +2800,7 @@ export interface Config {
 需要:`tools` · `web` · `systemPrompt`
 
 ```ts config-catalog
-/** Plugin config: which web tools to register, the source cap, per-tool budgets, and the fetch output cap. */
+/** Plugin config: which web tools to register, search bounds, per-tool budgets, and the fetch output cap. */
 export interface Config {
   /** Register `web_search`. Defaults to true. */
   search?: boolean
@@ -2808,6 +2808,8 @@ export interface Config {
   fetch?: boolean
   /** Upper bound on sources returned by one `web_search` call. */
   searchMaxResults?: number
+  /** Upper bound on queries accepted by one `web_search` call. */
+  searchMaxQueries?: number
   /** Cooperative timeout budget (ms) for `web_fetch`. Defaults to 30000. */
   fetchTimeoutMs?: number
   /** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */

+ 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: b3f59ed76ad1a26a4da207c52bf0e64c40148a94
-tool-catalog.zh.md: 27ae60554393fc390386b7b0aef64f6d0758cf28
+tool-catalog.md: a82874cdc799dd13773ef010072a863677df9ea9
+tool-catalog.zh.md: 894e9f0509fa9ad2525826120094c6410644a608

+ 10 - 6
docs/tool-catalog.md

@@ -2168,7 +2168,7 @@ Source: [`packages/web/tool-web/src/index.ts`](../packages/web/tool-web/src/inde
 
 ### `web_search`
 
-Search the web for current information. Returns an optional summary answer and a list of source URLs.
+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.
 
 ```json
 {
@@ -2176,12 +2176,16 @@ Search the web for current information. Returns an optional summary answer and a
   "properties": {
     "query": {
       "type": "string",
-      "description": "The search query."
+      "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.",
+      "items": {
+        "type": "string"
+      }
     }
-  },
-  "required": [
-    "query"
-  ]
+  }
 }
 ```
 

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

@@ -2172,7 +2172,7 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
 
 ### `web_search`
 
-在 Web 上搜索最新信息。返回可选的摘要答案和源 URL 列表。
+在 Web 上搜索最新信息。可传入一个查询或至多 4 个查询,同时搜索多个主题。返回可选的摘要答案和源 URL 列表。
 
 ```json
 {
@@ -2180,12 +2180,16 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
   "properties": {
     "query": {
       "type": "string",
-      "description": "The search query."
+      "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.",
+      "items": {
+        "type": "string"
+      }
     }
-  },
-  "required": [
-    "query"
-  ]
+  }
 }
 ```
 

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

@@ -660,8 +660,11 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
     // The web tools keep a GENERIC pending card and add the `web` result card
     // only at result time (the contract's result-only web shape); their pending
     // kind matches the result kind so a call and its result read as one category.
-    case 'web_search':
-      return { card: 'generic', title: `Search ${str(args.query)}`, kind: 'search', rawInput: args }
+    case 'web_search': {
+      const queries = Array.isArray(args.queries) ? args.queries.filter((query): query is string => typeof query === 'string') : []
+      const title = queries.length > 0 ? queries.join(', ') : str(args.query)
+      return { card: 'generic', title: `Search ${title}`, kind: 'search', rawInput: args }
+    }
     case 'web_fetch':
       return { card: 'generic', title: `Fetch ${str(args.url)}`, kind: 'fetch', rawInput: args }
     default:

+ 4 - 0
packages/client/ui-tool/src/client/tool/models/tool-call-model.ts

@@ -166,6 +166,10 @@ function deriveSummary(variant: ToolRowVariant, argsRaw: string): string {
   const parsed = parseArgs(argsRaw)
   if (typeof parsed !== 'object' || parsed === null) return firstLine(argsRaw)
   const args = parsed as Record<string, unknown>
+  if (variant === 'search' && Array.isArray(args.queries)) {
+    const queries = args.queries.filter((query): query is string => typeof query === 'string' && query !== '')
+    if (queries.length > 0) return queries.map(firstLine).join(', ')
+  }
   const picked = pickString(args, SUMMARY_KEYS[variant])
   if (picked !== undefined) return firstLine(picked)
   for (const v of Object.values(args)) {

+ 7 - 0
packages/client/ui-tool/tests/tool-row.client.spec.tsx

@@ -111,6 +111,13 @@ describe('tool-call-model', () => {
     expect(toolRowModel('', running({ argsRaw: '' })).summary).toBe('c1')
   })
 
+  it('joins multi-query web search arguments in the summary', () => {
+    expect(toolRowModel('web_search', running({
+      name: 'web_search',
+      argsRaw: '{"queries":["first query","second\\nquery"]}',
+    })).summary).toBe('first query, second')
+  })
+
   it('exposes filePath for path/file_path args and skips URL-only reads', () => {
     expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
     expect(toolRowModel('write', running({ name: 'write', argsRaw: '{"file_path":"src/a.ts"}' })).filePath).toBe('src/a.ts')

+ 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: 81dc4d3f3dbf9788e68b86513bfa09271fb99c84
-README.zh.md: 8e976f23f72347e12c6aea35f9b99c5d8ad31d85
+README.md: 2446d8d23d0569a507e1c4cb227d808bd05de062
+README.zh.md: c3891fad003789d107003141c02a1552c48980b5

+ 14 - 12
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) | 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_search` | `query` (string) or `queries` (string[]) | Discovery. Returns an optional answer plus source URLs. `queries` runs up to `searchMaxQueries` searches concurrently and merges their sources in round-robin order before applying the combined `searchMaxResults` cap. 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.
@@ -23,12 +23,13 @@ The normalized service results are also the canonical tool values: `WebSearchRes
 |---|---|---|
 | `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). |
+| `searchMaxResults` | `8` | Upper bound on sources returned by one `web_search` call (the seam truncates each provider list; the tool also caps a combined multi-query list). |
+| `searchMaxQueries` | `4` | Upper bound on queries accepted by one `web_search` call. The configured value appears in its prompt guidance and schema descriptions. |
 | `fetchTimeoutMs` | `30000` | Cooperative tool-call timeout budget (ms) for `web_fetch`. |
 | `searchTimeoutMs` | `30000` | Cooperative tool-call timeout budget (ms) for `web_search`. |
 | `fetchMaxOutputChars` | `200000` | Cap on source characters converted synchronously and on one complete `web_fetch` output (header, rendered body, and footer); a cut body gets the truncation notice when it fits. |
 
-`fetchTimeoutMs`/`searchTimeoutMs` declare each tool's cooperative timeout budget (attached as `ToolDefinition.timeoutMs`), enforced by [`@deepseek-ai/dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.md); the model-facing schema exposes no timeout argument. `fetchMaxOutputChars` bounds both synchronous conversion work and the complete rendered result: only that many source characters are converted, and the header, converted prefix, and truncation notice are then capped together. The default leaves headroom above the local provider's 100,000-character body cap, but rendered expansion can still make the final bound truncate the result.
+`searchMaxQueries` bounds provider fan-out and combined provider-answer growth; validation rejects an oversized array before any search starts. `fetchTimeoutMs`/`searchTimeoutMs` declare each tool's cooperative timeout budget (attached as `ToolDefinition.timeoutMs`), enforced by [`@deepseek-ai/dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.md); the model-facing schema exposes no timeout argument. `fetchMaxOutputChars` bounds both synchronous conversion work and the complete rendered result: only that many source characters are converted, and the header, converted prefix, and truncation notice are then capped together. The default leaves headroom above the local provider's 100,000-character body cap, but rendered expansion can still make the final bound truncate the result.
 
 ```yaml
 - id: tool-web
@@ -52,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. 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 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.
 ```
 
 ##### Web search-only guidance
 
 ```markdown
-Use the web_search tool to discover current information on the web. 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. 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.
 ```
 
 ##### Web fetch guidance
@@ -69,11 +70,11 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex
 
 #### Token effect
 
-Fixed guidance cost per request for each config-enabled tool, even when a restriction hides its schema. Toggling fetch changes the search guidance as well as registering or removing the fetch section.
+Fixed guidance cost per request for each config-enabled tool, even when a restriction hides its schema. Toggling fetch or changing `searchMaxQueries` changes the search guidance; toggling fetch also registers or removes the fetch section.
 
 #### KV Cache effect
 
-Prefix-stable while enabled tools, scope, and guidance text are unchanged. Config enablement—including toggling fetch's search-guidance branch—or plugin lifecycle may invalidate reuse from the first changed prompt section; scoped schema restrictions do not remove it.
+Prefix-stable while enabled tools, scope, and guidance text are unchanged. Config enablement—including toggling fetch's search-guidance branch—changing `searchMaxQueries`, or plugin lifecycle may invalidate reuse from the first changed prompt section; scoped schema restrictions do not remove it.
 
 ### Tool schemas
 
@@ -83,21 +84,21 @@ The model sees the generated [`web_search` and `web_fetch` schemas](../../../doc
 
 #### Token effect
 
-Fixed schema cost per request; config disablement removes both schema and guidance, while a scoped restriction removes only the schema.
+Fixed schema cost per request for a resolved `searchMaxQueries`; config disablement removes both schema and guidance, while a scoped restriction removes only the schema.
 
 #### KV Cache effect
 
-Prefix-stable while definitions and visibility are unchanged. Config enablement, plugin lifecycle, or scoped restrictions may invalidate reuse from the first changed schema token.
+Prefix-stable while definitions, resolved query cap, and visibility are unchanged. Config enablement, changing `searchMaxQueries`, plugin lifecycle, or scoped restrictions may invalidate reuse from the first changed schema token.
 
 ### Search result
 
 #### What the model sees
 
-The optional provider-owned answer is followed by `Sources:` and data-dependent lines shaped exactly `- [<title-or-url>](<url>)`, optionally suffixed ` — <snippet> (<publishedAt>)`. With neither answer nor sources the result says `No results found.` A capped list adds `(Showing the first <count> sources. Refine the query for more.)`; every result ends `Cite the relevant URLs above as markdown links in your answer.`
+The optional provider-owned answer is followed by `Sources:` and data-dependent lines shaped exactly `- [<title-or-url>](<url>)`, optionally suffixed ` — <snippet> (<publishedAt>)`. A multi-query call labels each provider answer with the originating query as a markdown heading, deduplicates sources by URL, and takes one source at each rank from every query before advancing to the next rank. With neither answer nor sources the result says `No results found.` A capped list adds `(Showing the first <count> sources. Refine the query for more.)`; every result ends `Cite the relevant URLs above as markdown links in your answer.`
 
 #### Token effect
 
-Data-dependent results are resent until compaction and sources are capped by `searchMaxResults`.
+Data-dependent results are resent until compaction; query fan-out is capped by `searchMaxQueries`, and sources are capped by `searchMaxResults`.
 
 #### KV Cache effect
 
@@ -121,7 +122,7 @@ Append-only; newly visible content follows the reusable request prefix and does
 
 #### What the model sees
 
-Blank inputs become exactly `Error: query must be a non-empty string` or `Error: url must be a non-empty string`.
+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 <count> queries`, `Error: each query must be a non-empty string`, or `Error: url must be a non-empty string`.
 
 #### Token effect
 
@@ -133,6 +134,7 @@ Append-only; newly visible content follows the reusable request prefix and does
 
 ## Known Limitations and Deferred Work
 
+- **The query cap is not a total native-search budget** — `searchMaxQueries` bounds `ctx.web.search` calls, but a provider may perform several native searches inside each call. For example, a model-backed provider configured with `maxUses` can permit up to `searchMaxQueries × maxUses` native searches; `searchMaxResults` limits only the combined sources returned to the caller.
 - **HTML→markdown conversion degrades on inputs GFM cannot safely represent** — [turndown](https://github.com/mixmark-io/turndown) (with GFM tables/strikethrough) converts at most `fetchMaxOutputChars` source characters through a real DOM. A conservative 512-level lexical guard passes deeply or ambiguously nested bodies through as raw HTML, conversion exceptions do the same, and table `colspan` is ignored because GFM has no spanning-cell representation; these bounds avoid blocking the event loop or expanding output from an untrusted numeric attribute ([archived dependency decision](../../../.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md)).
 - **The model-facing API is minimal by design, with promotions deferred** — `max_results` stays a config bound (not a model argument), and `web_fetch` takes only `url` (no `format`/`prompt`/LLM-summarization mode); both are named later steps in [the seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md).
 - **No web-specific permission policy** — both tools execute without requesting `ctx.approval`; a deployment that needs confirmation must add a `tools/pre-execute` policy, and the package does not define persistent URL/domain grants.

+ 14 - 12
packages/web/tool-web/README.zh.md

@@ -10,7 +10,7 @@
 
 | 工具 | 参数 | 行为 |
 |---|---|---|
-| `web_search` | `query`(string) | 用于发现信息。返回可选答案与来源 URL。`max_results` **不**面向模型:工具设置上限(`searchMaxResults` 配置,默认 8)并传给 seam。 |
+| `web_search` | `query`(string)或 `queries`(string[]) | 用于发现信息。返回可选答案与来源 URL。`queries` 会并发执行至多 `searchMaxQueries` 次搜索,按轮询顺序合并来源,再应用组合后的 `searchMaxResults` 上限。两个上限都不面向模型。 |
 | `web_fetch` | `url`(string) | 获取特定 URL。HTML 主体渲染为 markdown(turndown,带 GFM 表格/删除线);文本主体原样通过。非 2xx 状态会报告,而非报错。工具调用超时是部署策略(`dsh-tool-call-timeout-policy`),不是模型参数。 |
 
 两个工具都选择并发调度,因为提供方读取会返回内容,不会修改父 agent(智能体)的状态。
@@ -23,12 +23,13 @@
 |---|---|---|
 | `search` | `true` | 注册 `web_search`。 |
 | `fetch` | `true` | 注册 `web_fetch`。 |
-| `searchMaxResults` | `8` | 一次 `web_search` 调用返回的来源数量上限(seam 截断更长的提供方列表并标记)。 |
+| `searchMaxResults` | `8` | 一次 `web_search` 调用返回的来源数量上限(seam 截断各提供方列表;工具还会限制多查询组合列表)。 |
+| `searchMaxQueries` | `4` | 一次 `web_search` 调用接受的查询数量上限。配置值会出现在提示词指引与 schema 描述中。 |
 | `fetchTimeoutMs` | `30000` | `web_fetch` 的协作式工具调用超时预算(ms)。 |
 | `searchTimeoutMs` | `30000` | `web_search` 的协作式工具调用超时预算(ms)。 |
 | `fetchMaxOutputChars` | `200000` | 同步转换的源字符数与单次完整 `web_fetch` 输出的上限(状态头、渲染后的主体与页脚合并计算);主体被截断时,在能容纳的情况下附带截断提示。 |
 
-`fetchTimeoutMs`/`searchTimeoutMs` 声明每个工具的协作式超时预算(附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.md) 强制执行;面向模型的 schema 不公开超时参数。`fetchMaxOutputChars` 同时限制同步转换工作量和完整渲染结果:只转换至多该数量的源字符,随后对状态头、转换后的前缀和截断提示合并设限。默认值为本地提供方的 100,000 字符主体上限留出余量,但渲染膨胀仍可能使最终上限截断结果。
+`searchMaxQueries` 限制提供方请求扇出与组合后的提供方答案增长;校验会在任何搜索开始前拒绝超限数组。`fetchTimeoutMs`/`searchTimeoutMs` 声明每个工具的协作式超时预算(附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.md) 强制执行;面向模型的 schema 不公开超时参数。`fetchMaxOutputChars` 同时限制同步转换工作量和完整渲染结果:只转换至多该数量的源字符,随后对状态头、转换后的前缀和截断提示合并设限。默认值为本地提供方的 100,000 字符主体上限留出余量,但渲染膨胀仍可能使最终上限截断结果。
 
 ```yaml
 - id: tool-web
@@ -52,13 +53,13 @@
 ##### 启用抓取时的 Web 搜索指引
 
 ```markdown
-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.
+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.
 ```
 
 ##### 仅搜索时的 Web 搜索指引
 
 ```markdown
-Use the web_search tool to discover current information on the web. 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. 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.
 ```
 
 ##### Web 抓取指引
@@ -69,11 +70,11 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex
 
 #### Token 影响
 
-每个通过配置启用的工具都会为每次请求增加固定的指引 token 开销,即使限制隐藏了其 schema。切换抓取状态不仅会注册或移除抓取区段,也会更改搜索指引
+每个通过配置启用的工具都会为每次请求增加固定的指引 token 开销,即使限制隐藏了其 schema。切换抓取状态或更改 `searchMaxQueries` 会改变搜索指引;切换抓取状态还会注册或移除抓取区段
 
 #### KV Cache 影响
 
-只要启用工具、scope 与指引文本不变,前缀就保持稳定。配置启用状态(包括因切换抓取状态而改变搜索指引分支)或插件生命周期可能使从第一个变化的提示词区段起的复用失效;scope schema 限制不会移除该区段。
+只要启用工具、scope 与指引文本不变,前缀就保持稳定。配置启用状态(包括因切换抓取状态而改变搜索指引分支)、更改 `searchMaxQueries` 或插件生命周期可能使从第一个变化的提示词区段起的复用失效;scope schema 限制不会移除该区段。
 
 ### 工具 schema
 
@@ -83,21 +84,21 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex
 
 #### Token 影响
 
-每次请求都会产生固定的 schema token 开销;通过配置禁用会同时移除 schema 与指引,scope 限制只移除 schema。
+对于已解析的 `searchMaxQueries`,每次请求都会产生固定的 schema token 开销;通过配置禁用会同时移除 schema 与指引,scope 限制只移除 schema。
 
 #### KV Cache 影响
 
-只要定义与可见性不变,前缀就保持稳定。配置启用状态、插件生命周期或 scope 限制可能使从第一个变化的 schema token 起的复用失效。
+只要定义、已解析查询上限与可见性不变,前缀就保持稳定。配置启用状态、更改 `searchMaxQueries`、插件生命周期或 scope 限制可能使从第一个变化的 schema token 起的复用失效。
 
 ### 搜索结果
 
 #### 模型看到的内容
 
-可选的提供方答案之后是 `Sources:`,再跟随内容取决于数据且格式严格为 `- [<title-or-url>](<url>)` 的行,并可添加后缀 ` — <snippet> (<publishedAt>)`。既无答案也无来源时,结果显示 `No results found.`。列表被截断至上限时会添加 `(Showing the first <count> sources. Refine the query for more.)`;每个结果都以 `Cite the relevant URLs above as markdown links in your answer.` 结尾。
+可选的提供方答案之后是 `Sources:`,再跟随内容取决于数据且格式严格为 `- [<title-or-url>](<url>)` 的行,并可添加后缀 ` — <snippet> (<publishedAt>)`。多查询调用会用来源查询作为 markdown 标题标注每个提供方答案,按 URL 对来源去重,并从每个查询取得同一排名的一条来源后再推进至下一排名。既无答案也无来源时,结果显示 `No results found.`。列表被截断至上限时会添加 `(Showing the first <count> sources. Refine the query for more.)`;每个结果都以 `Cite the relevant URLs above as markdown links in your answer.` 结尾。
 
 #### Token 影响
 
-数据相关结果会重复发送直到压缩(compaction),来源数量由 `searchMaxResults` 限制。
+数据相关结果会重复发送直到压缩(compaction);查询请求扇出由 `searchMaxQueries` 限制,来源数量由 `searchMaxResults` 限制。
 
 #### KV Cache 影响
 
@@ -121,7 +122,7 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex
 
 #### 模型看到的内容
 
-空输入精确地变为 `Error: query must be a non-empty string` 或 `Error: url must be a non-empty string`。
+无效输入精确地变为 `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 <count> queries`、`Error: each query must be a non-empty string` 或 `Error: url must be a non-empty string`。
 
 #### Token 影响
 
@@ -133,6 +134,7 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex
 
 ## 已知限制与暂缓事项
 
+- **查询上限不是原生搜索总预算**:`searchMaxQueries` 限制 `ctx.web.search` 调用数,但提供方可以在每次调用内执行多次原生搜索。例如,配置了 `maxUses` 的模型型提供方最多可以执行 `searchMaxQueries × maxUses` 次原生搜索;`searchMaxResults` 只限制返回给调用方的组合来源。
 - **HTML→markdown 转换会在 GFM 无法安全表示的输入上降级**:[turndown](https://github.com/mixmark-io/turndown)(带 GFM 表格/删除线)通过真实 DOM 转换至多 `fetchMaxOutputChars` 个源字符。保守的 512 层词法守卫会将深层或嵌套有歧义的主体作为原始 HTML 直接透传,转换异常也会如此处理;表格的 `colspan` 会被忽略,因为 GFM 无法表示跨列单元格。这些限制可避免阻塞事件循环,也避免不受信任的数值属性使输出膨胀([已归档的依赖决策](../../../.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md))。
 - **面向模型的接口有意保持精简,后续扩展暂缓**:`max_results` 保持为配置上限(不是模型参数),`web_fetch` 只接受 `url`(没有 `format`/`prompt`/LLM(大语言模型)摘要模式);两项都列为 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) 中的后续步骤。
 - **没有 web 专用权限策略**:两个工具都不会请求 `ctx.approval` 就直接执行;需要确认的部署必须添加 `tools/pre-execute` 策略,该包不定义持久化的 URL/域名授权。

+ 9 - 5
packages/web/tool-web/src/index.ts

@@ -9,11 +9,11 @@
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import type {} from '@deepseek-ai/dsh-web'
-import { applyWebSearchTool, WEB_SEARCH_MAX_RESULTS } from './search.ts'
+import { applyWebSearchTool, WEB_SEARCH_MAX_QUERIES, WEB_SEARCH_MAX_RESULTS } from './search.ts'
 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 { WEB_SEARCH_MAX_QUERIES, WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall, presentSearchResult, searchMetaFromValue, searchMetaFromResult, searchTitle } from './search.ts'
+export type { WebSearchArgs, WebSearchMeta } from './search.ts'
 export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall, presentFetchResult, fetchMetaFromValue, fetchMetaFromResult } from './fetch.ts'
 export type { WebFetchMeta } from './fetch.ts'
 
@@ -33,7 +33,7 @@ export const DEFAULT_WEB_TOOL_TIMEOUT_MS = 30_000
  */
 export const DEFAULT_FETCH_MAX_OUTPUT_CHARS = 200_000
 
-/** Plugin config: which web tools to register, the source cap, per-tool budgets, and the fetch output cap. */
+/** Plugin config: which web tools to register, search bounds, per-tool budgets, and the fetch output cap. */
 export interface Config {
   /** Register `web_search`. Defaults to true. */
   search?: boolean
@@ -41,6 +41,8 @@ export interface Config {
   fetch?: boolean
   /** Upper bound on sources returned by one `web_search` call. */
   searchMaxResults?: number
+  /** Upper bound on queries accepted by one `web_search` call. */
+  searchMaxQueries?: number
   /** Cooperative timeout budget (ms) for `web_fetch`. Defaults to 30000. */
   fetchTimeoutMs?: number
   /** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */
@@ -53,6 +55,7 @@ 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),
+  searchMaxQueries: z.number().default(WEB_SEARCH_MAX_QUERIES),
   fetchTimeoutMs: z.number().default(DEFAULT_WEB_TOOL_TIMEOUT_MS),
   searchTimeoutMs: z.number().default(DEFAULT_WEB_TOOL_TIMEOUT_MS),
   fetchMaxOutputChars: z.number().default(DEFAULT_FETCH_MAX_OUTPUT_CHARS),
@@ -81,11 +84,12 @@ export function apply(ctx: Context, config: Config): void {
   // schemastery (Config) has already filled every defaulted field.
   const resolved = config as ResolvedConfig
   assertPositiveInteger('searchMaxResults', resolved.searchMaxResults)
+  assertPositiveInteger('searchMaxQueries', resolved.searchMaxQueries)
   assertPositiveInteger('fetchTimeoutMs', resolved.fetchTimeoutMs)
   assertPositiveInteger('searchTimeoutMs', resolved.searchTimeoutMs)
   assertPositiveInteger('fetchMaxOutputChars', resolved.fetchMaxOutputChars)
   if (resolved.search) {
-    applyWebSearchTool(ctx, resolved.searchMaxResults, resolved.searchTimeoutMs, resolved.fetch)
+    applyWebSearchTool(ctx, resolved.searchMaxResults, resolved.searchMaxQueries, resolved.searchTimeoutMs, resolved.fetch)
   }
   if (resolved.fetch) applyWebFetchTool(ctx, resolved.fetchTimeoutMs, resolved.fetchMaxOutputChars)
 }

+ 135 - 23
packages/web/tool-web/src/search.ts

@@ -19,16 +19,45 @@ 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. */
+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.
+ */
+export interface WebSearchArgs {
+  query?: string
+  queries?: string[]
+}
+
 /**
  * Validate value constraints the schema DSL can't express: a non-blank
- * `query`. Throws a plain `Error` otherwise.
+ * `query` or a non-empty `queries` array of non-blank strings, but not both.
+ * `queries` must also fit the deployment's query-count bound. Throws a plain
+ * `Error` otherwise.
  *
  * @param args - the schema-validated `web_search` arguments.
- * @returns the accepted arguments, passed through unchanged.
+ * @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.
  */
-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 }
+export function parseSearchArgs(
+  args: WebSearchArgs,
+  maxQueries = WEB_SEARCH_MAX_QUERIES,
+): { 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')
+  const queries = args.queries
+  if (queries.length === 0) throw new Error('queries must contain at least one query')
+  if (queries.length > maxQueries) throw new Error(`queries must contain at most ${maxQueries} queries`)
+  if (queries.some(query => query.trim().length === 0)) throw new Error('each query must be a non-empty string')
+  return { queries }
 }
 
 /** Display label for a source: its title, else its hostname. */
@@ -75,13 +104,25 @@ export function formatSearchOutput(result: WebSearchResult): string {
 }
 
 /**
- * Pending-call presentation: a search card titled by the query.
+ * Derive a display title from either the single query or the query list.
  *
- * @param args - the raw tool arguments; only `query` feeds the view.
+ * @param args - the raw tool arguments.
+ * @returns a comma-joined title for the search card.
+ */
+export 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.
+ *
+ * @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: { query: string }): GenericCallView {
-  return { card: 'generic', title: args.query, kind: 'search', rawInput: args.query }
+export function presentSearchCall(args: WebSearchArgs): GenericCallView {
+  const title = searchTitle(args)
+  return { card: 'generic', title, kind: 'search', rawInput: title }
 }
 
 /**
@@ -95,7 +136,7 @@ export function presentSearchCall(args: { query: string }): GenericCallView {
 export interface WebSearchMeta {
   /** The faithful structured sources, in result order. */
   sources: WebSource[]
-  /** True when the seam cut the source list to honor the result cap. */
+  /** True when the seam or multi-query merge cut the source list to honor the result cap. */
   truncated: boolean
   /** The provider-generated answer text, when any. */
   answer?: string
@@ -175,26 +216,93 @@ 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; `query` becomes the result-state title so
- *   a window-truncated replay that dropped the call head still has one.
+ * @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 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(args: { query: string }, result: ToolResult): WebSearchResultView | undefined {
+export function presentSearchResult(args: WebSearchArgs, 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,
+    title: searchTitle(args),
     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
+ * into one normalized result capped at `maxResults`.
+ *
+ * @param ctx - context whose `web` service performs the searches.
+ * @param queries - validated non-empty queries.
+ * @param maxResults - the deployment's source cap for the combined result.
+ * @param signal - cancellation signal forwarded to every search.
+ * @returns the combined search result.
+ */
+async function runSearchQueries(
+  ctx: Context,
+  queries: string[],
+  maxResults: number,
+  signal: AbortSignal,
+): Promise<WebSearchResult> {
+  if (queries.length === 1) {
+    return ctx.web.search({ query: queries[0] as string, maxResults }, signal)
+  }
+  const results = await Promise.all(queries.map(query => ctx.web.search({ query, maxResults }, signal)))
+  return mergeSearchResults(queries, results, maxResults)
+}
+
+/** Merge per-query results into one deduplicated, round-robin, capped result. */
+function mergeSearchResults(
+  queries: string[],
+  results: WebSearchResult[],
+  maxResults: number,
+): WebSearchResult {
+  const seen = new Set<string>()
+  const sources: WebSearchSource[] = []
+  let sourceRanks = 0
+  for (const result of results) {
+    sourceRanks = Math.max(sourceRanks, result.sources.length)
+  }
+  let droppedSource = false
+  merge: for (let rank = 0; rank < sourceRanks; rank++) {
+    for (const result of results) {
+      const source = result.sources[rank]
+      if (source !== undefined && !seen.has(source.url)) {
+        seen.add(source.url)
+        if (sources.length === maxResults) {
+          droppedSource = true
+          break merge
+        }
+        sources.push(source)
+      }
+    }
+  }
+  const contents = results.flatMap((result, index) => {
+    if (result.content === undefined || result.content.length === 0) return []
+    return [`### ${queries[index]}\n\n${result.content}`]
+  })
+  return {
+    ...contents.length > 0 ? { content: contents.join('\n\n') } : {},
+    sources,
+    truncated: results.some(result => result.truncated) || droppedSource,
+  }
+}
+
 /**
  * Register the `web_search` tool and its system-prompt guidance.
  *
@@ -202,6 +310,7 @@ export function presentSearchResult(args: { query: string }, result: ToolResult)
  *   registrations; both are effect-scoped and unregister on plugin dispose.
  * @param maxResults - the deployment's source cap, sent as every seam
  *   request's `maxResults`.
+ * @param maxQueries - the deployment's query cap enforced before provider calls.
  * @param timeoutMs - the cooperative tool-call budget (ms) attached as the tool's
  *   `ToolDefinition.timeoutMs` for `@deepseek-ai/dsh-tool-call-timeout-policy` to enforce.
  * @param fetchEnabled - whether the same composition exposes `web_fetch`, which
@@ -210,6 +319,7 @@ export function presentSearchResult(args: { query: string }, result: ToolResult)
 export function applyWebSearchTool(
   ctx: Context,
   maxResults: number,
+  maxQueries: number,
   timeoutMs: number,
   fetchEnabled: boolean,
 ): void {
@@ -217,15 +327,20 @@ export function applyWebSearchTool(
     name: 'tool:web_search',
     order: 110,
     text: fetchEnabled
-      ? '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.'
-      : 'Use the web_search tool to discover current information on the web. 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. 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.`,
   })
 
   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.',
+    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.`,
     parameters: {
-      query: { type: 'string', required: true, description: 'The search query.' },
+      query: { type: 'string', description: 'The search query. Provide either this or queries.' },
+      queries: {
+        type: 'array',
+        items: { type: 'string' },
+        description: `Up to ${maxQueries} search queries to run concurrently and merge into one result. Provide either this or query.`,
+      },
     },
     output: {
       schema: {
@@ -257,11 +372,8 @@ export function applyWebSearchTool(
     // Provider reads do not mutate parent-agent state.
     isConcurrencySafe: () => true,
     async execute(args, exec) {
-      const input = parseSearchArgs(args)
-      const result = await ctx.web.search(
-        { query: input.query, maxResults },
-        exec.signal,
-      )
+      const input = parseSearchArgs(args, maxQueries)
+      const result = await runSearchQueries(ctx, queriesFromSearchArgs(input), maxResults, exec.signal)
       return {
         ...result.content !== undefined ? { content: result.content } : {},
         sources: result.sources.map(projectSource),

+ 1 - 1
packages/web/tool-web/tests/integration.spec.ts

@@ -110,7 +110,7 @@ describe('tool-call timeout policy over the migrated web tools', () => {
     const searchParams = byName.get('web_search')!.parameters as { properties: Record<string, unknown> }
     expect(Object.keys(fetchParams.properties)).toEqual(['url'])
     expect('timeout_ms' in fetchParams.properties).toBe(false)
-    expect(Object.keys(searchParams.properties)).toEqual(['query'])
+    expect(Object.keys(searchParams.properties)).toEqual(['query', 'queries'])
   })
 })
 

+ 145 - 1
packages/web/tool-web/tests/tool-web.spec.ts

@@ -20,6 +20,7 @@ import {
   searchMetaFromResult,
   fetchMetaFromValue,
   fetchMetaFromResult,
+  WEB_SEARCH_MAX_QUERIES,
   WEB_SEARCH_MAX_RESULTS,
 } from '@deepseek-ai/dsh-tool-web'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm'
@@ -89,6 +90,15 @@ describe('search formatting', () => {
     expect(parseSearchArgs({ query: 'hi' })).toEqual({ query: 'hi' })
   })
 
+  it('validates multiple queries', () => {
+    expect(parseSearchArgs({ queries: ['one', ' two '] })).toEqual({ queries: ['one', ' two '] })
+    expect(() => parseSearchArgs({})).toThrow('provide either query or queries')
+    expect(() => parseSearchArgs({ queries: [] })).toThrow('at least one query')
+    expect(() => parseSearchArgs({ queries: ['one', 'two', 'three'] }, 2)).toThrow('at most 2 queries')
+    expect(() => parseSearchArgs({ queries: ['ok', ' '] })).toThrow('each query must be a non-empty string')
+    expect(() => parseSearchArgs({ query: 'one', queries: ['two'] })).toThrow('not both')
+  })
+
   it('falls back to the raw URL as a source label when the URL is unparseable', () => {
     const out = formatSearchOutput({ truncated: false, sources: [{ url: 'not a url' }] })
     expect(out).toContain('[not a url](not a url)')
@@ -97,6 +107,10 @@ describe('search formatting', () => {
   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', () => {
+    expect(presentSearchCall({ queries: ['one', 'two'] })).toEqual({ card: 'generic', title: 'one, two', kind: 'search', rawInput: 'one, two' })
+  })
 })
 
 /** Build a completed non-error tool result with the given meta and text content. */
@@ -483,7 +497,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. 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. 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_fetch tool to retrieve the content of a specific HTTP(S) URL')
     await fiber.dispose()
   })
@@ -512,6 +526,82 @@ describe('tool-web execution through the real registry', () => {
     await fiber.dispose()
   })
 
+  it('executes web_search with multiple queries concurrently and merges results', async () => {
+    const seen: string[] = []
+    let releaseFirst: (() => void) | undefined
+    const firstResult = new Promise<WebSearchResult>((resolve) => {
+      releaseFirst = () => {
+        resolve({
+          content: 'answer one', truncated: false,
+          sources: [
+            { url: 'https://a.test', title: 'A' },
+            { url: 'https://shared.test' },
+          ],
+        })
+      }
+    })
+    const provider: WebSearchProvider = {
+      id: 'stub-search',
+      available: () => available,
+      search: (request) => {
+        seen.push(request.query)
+        if (request.query === 'one') return firstResult
+        return Promise.resolve({
+          content: 'answer two', truncated: false,
+          sources: [
+            { url: 'https://b.test', title: 'B' },
+            { url: 'https://shared.test' },
+          ],
+        })
+      },
+    }
+    const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
+    const pending = call('web_search', { queries: ['one', 'two'] })
+    try {
+      await vi.waitFor(() => { expect(seen).toEqual(['one', 'two']) })
+    } finally {
+      releaseFirst?.()
+    }
+    const out = await pending
+    expect(out.isError).toBe(false)
+    expect(out.value).toEqual({
+      content: '### one\n\nanswer one\n\n### two\n\nanswer two',
+      sources: [
+        { url: 'https://a.test', title: 'A' },
+        { url: 'https://b.test', title: 'B' },
+        { url: 'https://shared.test' },
+      ],
+      truncated: false,
+    })
+    const body = out.content.map(b => b.type === 'text' ? b.text : '').join('')
+    expect(body).toContain('### one')
+    expect(body).toContain('### two')
+    await fiber.dispose()
+  })
+
+  it('caps combined multi-query results to searchMaxResults', async () => {
+    const provider: WebSearchProvider = {
+      id: 'stub-search',
+      available: () => available,
+      search: request => Promise.resolve({
+        sources: request.query === 'one'
+          ? [{ url: 'https://a.test' }, { url: 'https://b.test' }]
+          : [{ url: 'https://c.test' }, { url: 'https://d.test' }],
+        truncated: false,
+      }),
+    }
+    const { fiber, call } = await mountTools({ config: { searchMaxResults: 2 }, webConfig: { searchProvider: 'stub-search' }, search: provider })
+    const out = await call('web_search', { queries: ['one', 'two'] })
+    expect(out.isError).toBe(false)
+    expect(out.value).toEqual({
+      sources: [{ url: 'https://a.test' }, { url: 'https://c.test' }],
+      truncated: true,
+    })
+    const body = out.content.map(b => b.type === 'text' ? b.text : '').join('')
+    expect(body).toContain('Showing the first 2 sources.')
+    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,
@@ -639,6 +729,23 @@ describe('tool-web execution through the real registry', () => {
     expect(seen.signal).toBe(controller.signal)
     await fiber.dispose()
   })
+
+  it('forwards the abort signal to every multi-query search', async () => {
+    const signals: (AbortSignal | undefined)[] = []
+    const provider: WebSearchProvider = {
+      id: 'stub-search',
+      available: () => available,
+      search: (_request, signal) => {
+        signals.push(signal)
+        return Promise.resolve({ sources: [], truncated: false })
+      },
+    }
+    const { ctx, fiber } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
+    const controller = new AbortController()
+    await ctx.tools.execute({ callId: CallId('search-multi-1'), name: 'web_search', arguments: { queries: ['one', 'two'] }, signal: controller.signal })
+    expect(signals).toEqual([controller.signal, controller.signal])
+    await fiber.dispose()
+  })
 })
 
 describe('searchMaxResults is plugin config', () => {
@@ -686,6 +793,43 @@ describe('searchMaxResults is plugin config', () => {
   })
 })
 
+describe('searchMaxQueries is plugin config', () => {
+  it('exposes the configured cap to the model and enforces it before provider calls', async () => {
+    const seen: string[] = []
+    const provider: WebSearchProvider = {
+      id: 'stub-search',
+      available: () => available,
+      search: (request) => {
+        seen.push(request.query)
+        return Promise.resolve({ sources: [], truncated: false })
+      },
+    }
+    const { fiber, ctx, call } = await mountTools({
+      config: { searchMaxQueries: 2 },
+      webConfig: { searchProvider: 'stub-search' },
+      search: provider,
+    })
+    const schema = ctx.tools.schemas().find(item => item.name === 'web_search')
+    expect(schema?.description).toContain('up to 2 queries')
+    const prompt = await ctx.systemPrompt.assemble()
+    expect(prompt.sections.map(section => section.text).join('\n')).toContain('pass up to 2 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' }])
+    expect(seen).toEqual([])
+    await fiber.dispose()
+  })
+
+  it.each([0, -1, 1.5])('rejects an invalid searchMaxQueries value %s at load', async (value) => {
+    const ctx = new Context()
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRuntime)
+    await ctx.plugin(WebRuntime, {})
+    await expect(ctx.plugin(ToolWeb, { searchMaxQueries: value }))
+      .rejects.toThrow(/tool-web: searchMaxQueries must be a positive integer/)
+  })
+})
+
 describe('tool-call timeout budget is plugin config', () => {
   it('attaches the default 30s budget to web_fetch and web_search', async () => {
     const { fiber, ctx } = await mountTools()