Przeglądaj źródła

fix multi-query web search review feedback

Dudu-0223 1 miesiąc temu
rodzic
commit
6b4df99c44

+ 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: c5307d2bdbdf3f0d5ea4375b4dd19c6018414b5b
-2026-08-17-web-search-multiple-queries.zh.md: c2fc9cb677c9e14d1e24a59345ce0f28e9a91b56
+2026-08-17-web-search-multiple-queries.md: bed2af83fc2a8dd7b4faa960b46bd1978ef2ab06
+2026-08-17-web-search-multiple-queries.zh.md: 2236dc7674f179bb02e3219e8f315ce58ba4fde2

+ 7 - 5
.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.
+`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.
 
-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.
+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.
 
 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.
 
@@ -24,10 +24,12 @@ The multi-query orchestration lives in the tool consumer, not in the web seam or
 
 **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.
 
+**Add an overall native-search budget to `WebSearchRequest`.** Rejected: the generic seam cannot count provider-internal search units without leaking one provider's mechanism or accepting a limit that other providers cannot enforce. Deployments combine the consumer-owned `searchMaxQueries` bound with provider-owned controls such as `maxUses`.
+
 ## 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`.
+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`.
 
-`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.
+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.
 
-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.
+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, exact-query and source deduplication, uneven result exhaustion, truncation, caller cancellation propagation, and batch quiescence after failure.

+ 7 - 5
.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` 接受原有的 `query` 字符串,或 `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 也保持提供方无关。
 
@@ -24,10 +24,12 @@ Status: implemented
 
 **接受无上限的 `queries` 数组。** 不采用:一次模型操作可以启动任意数量的提供方请求,并拼接任意数量的提供方答案。由部署拥有的上限既让模型 schema 聚焦搜索输入,也能控制成本与输出增长。
 
+**给 `WebSearchRequest` 增加原生搜索总预算。** 不采用:通用 seam 若要计算提供方内部的搜索单位,要么泄漏某个提供方的机制,要么接受其他提供方无法强制执行的上限。部署会把消费方自有的 `searchMaxQueries` 上限与提供方自有的 `maxUses` 等控制项结合使用。
+
 ## 结果
 
-模型可以把多个不同搜索合并到一次原生 `web_search` 调用中,减少转向 MCP 搜索的动机。默认查询上限 4 与 Codex `web.run` 面向模型的批量大小一致,同时限制并发提供方调用;部署可以独立于来源上限选择另一个正整数。组合来源仍受 `searchMaxResults` 限制,并通过轮询合并保留每个查询的结果排名。多查询结果中的提供方答案会以 `### <query>` 标题标注,便于模型区分答案来自哪个搜索。schema 不再把 `query` 标记为必填;运行时校验要求 `query` 与 `queries` 二选一。
+模型可以把多个不同搜索合并到一次原生 `web_search` 调用中,减少转向 MCP 搜索的动机。默认查询上限 4 与 Codex `web.run` 面向模型的批量大小一致,同时限制并发提供方调用;部署可以独立于来源上限选择另一个正整数。完全相同的重复字符串会占用输入数组上限,但只会触发一次提供方调用。组合来源仍受 `searchMaxResults` 限制,并通过轮询合并保留每个查询的结果排名。多查询结果中的提供方答案会以 `### <query>` 标题标注,便于模型区分答案来自哪个搜索。`query` 在 schema 中是可选字段;运行时校验要求 `query` 与 `queries` 二选一。
 
-`searchMaxQueries` 不是原生搜索总预算。提供方可以在一次 `ctx.web.search` 调用内执行多次原生搜索,因此拥有自身 `maxUses` 的模型型提供方最多可以执行 `searchMaxQueries × maxUses` 次原生搜索。`searchMaxResults` 只限制返回给调用方的组合来源。Issue #2602 记录了本实现进入正式审查前仍需完成的决策:独立配置的上限是否足够,还是提供方契约需要原生搜索总预算。
+多查询失败采用全有或全无语义:如果另一个查询失败,成功的提供方结果也会被丢弃;在同批取消达到静默状态前,调用不会返回。`searchMaxQueries` 与提供方自有的控制项可以独立配置,并共同构成搜索预算。提供方可以在一次 `ctx.web.search` 调用内执行多次原生搜索,因此拥有自身 `maxUses` 的模型型提供方最多可以执行 `searchMaxQueries × maxUses` 次原生搜索;`searchMaxResults` 只限制返回给调用方的组合来源。提供方中立的 seam 有意不定义原生搜索总计数器。
 
-真实 Web 组合快照通过 DeepSeek 搜索提供方发起一次 `queries` 调用,观察两次辅助提供方请求,并固定轮询组合结果、持久化元数据和拼接后的搜索卡片标题。包测试另行证明:第一个提供方 promise 结算前已经发起重叠调用;查询上限会在提供方分发前拒绝请求;去重、截断与取消信号传播保持正确。
+真实 Web 组合快照通过 DeepSeek 搜索提供方发起一次 `queries` 调用,观察两次辅助提供方请求,并固定轮询组合结果、持久化元数据和拼接后的搜索卡片标题。包测试另行证明:第一个提供方 promise 结算前已经发起重叠调用;查询上限会在提供方分发前拒绝请求;完全相同查询与来源都会去重;不等长结果能够耗尽;截断、调用方取消传播以及失败后的批次静默状态保持正确。

+ 12 - 5
apps/web/tests/web-search-round.e2e.ts

@@ -215,11 +215,18 @@ describe('web e2e: shipped default web search', () => {
         event.type === 'web/deepseek-search-llm-request',
     )
     expect(auxiliaryRequests).toHaveLength(QUERIES.length)
-    expect(auxiliaryRequests.map(event => event.data)).toEqual(searchRequests.map(request => ({
-      endpoint: `${searchBaseURL}/messages`,
-      apiVersion: '2023-06-01',
-      body: request.body,
-    })))
+    for (const query of QUERIES) {
+      const request = searchRequests.find(candidate => JSON.stringify(candidate.body).includes(query))
+      const auxiliaryRequest = auxiliaryRequests.find(event => JSON.stringify(event.data.body).includes(query))
+      if (request === undefined || auxiliaryRequest === undefined) {
+        throw new Error(`missing paired provider request for query: ${query}`)
+      }
+      expect(auxiliaryRequest.data).toEqual({
+        endpoint: `${searchBaseURL}/messages`,
+        apiVersion: '2023-06-01',
+        body: request.body,
+      })
+    }
 
     const searchCall = sessionEvents.find(
       (event): event is Extract<SessionEvent, { type: 'tool/call' }> =>

+ 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: 3bcd3ac24927c8c51baeabd770e2bd91c5ad1b77
-web.zh.md: 3348be2b808dc286364f5a795b236cb299acd6a6
+web.md: 6726c9604fd04eeac3d26aaa52f4516c111a5096
+web.zh.md: c389e070388a1e4d1fc4e905b35997dcebd21241

+ 5 - 4
docs/subsystems/web.md

@@ -12,13 +12,14 @@ Search and fetch share no request schema and no business logic, but they are del
 
 ## Search request and result
 
-The model-facing tool argument is just a `query`; `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 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`.
 
 ```ts type-equiv
 /**
- * What one search-capable backend can return. The model-facing argument is just
- * a query; `maxResults` is a `dsh-tool-web`-layer bound passed through unchanged
- * and enforced on the way back by the seam (see {@link WebSearchResult}).
+ * What one search-capable backend is asked to search. Each request carries one
+ * query; a consumer may issue several requests. `maxResults` is a
+ * `dsh-tool-web`-layer bound passed through unchanged and enforced on the way
+ * back by the seam (see {@link WebSearchResult}).
  */
 interface WebSearchRequest {
   readonly query: string

+ 5 - 4
docs/subsystems/web.zh.md

@@ -12,13 +12,14 @@ Web 访问 seam 是一个[能力 seam](../../.agents/notes/implemented/architect
 
 ## 搜索请求与结果
 
-面向模型的工具参数仅为一个 `query`;`maxResults` 是消费方自有的上限(`dsh-tool-web` 的 `searchMaxResults` 配置,默认 `8`),通过 seam 传递并在返回时强制执行——如果提供方返回超量,seam 截断 `sources[]` 并设置 `truncated`。
+每个 seam 请求只携带一个 `query`。消费方 `dsh-tool-web` 接受单个 `query` 或 `queries` 数组,并把数组扇出为多个独立 seam 请求。`maxResults` 是消费方自有的上限(`dsh-tool-web` 的 `searchMaxResults` 配置,默认 `8`),通过 seam 传递并在返回时强制执行——如果提供方返回超量,seam 截断 `sources[]` 并设置 `truncated`。
 
 ```ts type-equiv
 /**
- * What one search-capable backend can return. The model-facing argument is just
- * a query; `maxResults` is a `dsh-tool-web`-layer bound passed through unchanged
- * and enforced on the way back by the seam (see {@link WebSearchResult}).
+ * What one search-capable backend is asked to search. Each request carries one
+ * query; a consumer may issue several requests. `maxResults` is a
+ * `dsh-tool-web`-layer bound passed through unchanged and enforced on the way
+ * back by the seam (see {@link WebSearchResult}).
  */
 interface WebSearchRequest {
   readonly query: string

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

@@ -661,7 +661,7 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
     // 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': {
-      const queries = Array.isArray(args.queries) ? args.queries.filter((query): query is string => typeof query === 'string') : []
+      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)
       return { card: 'generic', title: `Search ${title}`, kind: 'search', rawInput: args }
     }

+ 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: 2446d8d23d0569a507e1c4cb227d808bd05de062
-README.zh.md: c3891fad003789d107003141c02a1552c48980b5
+README.md: 78c49d59406b6e0874676ca1110cf14d2883da32
+README.zh.md: eeebc0723340289550ae8c29ecd835834b06e367

+ 20 - 6
packages/web/tool-web/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and the UI presentation projection — `presentCall`, `presentResult` (a `card: 'web'` result card discriminated by `kind: 'search' | 'fetch'`), and the `output.presentationMeta` that carries the structured search sources or the fetch summary the lossy render text cannot (see the [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md)). All web access goes through `ctx.web`; this package never imports a concrete provider. Neither tool exposes a model-facing timeout — each tool's cooperative tool-call budget is declared here via config (`fetchTimeoutMs`/`searchTimeoutMs`, attached as `ToolDefinition.timeoutMs`) and enforced by [`@deepseek-ai/dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.md) (a `tools/execute` wrapper); each tool just forwards `exec.signal` to the seam.
+The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and the UI presentation projection — `presentCall`, `presentResult` (a `card: 'web'` result card discriminated by `kind: 'search' | 'fetch'`), and the `output.presentationMeta` that carries the structured search sources or the fetch summary the lossy render text cannot (see the [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md)). All web access goes through `ctx.web`; this package never imports a concrete provider. Neither tool exposes a model-facing timeout — each tool's cooperative tool-call budget is declared here via config (`fetchTimeoutMs`/`searchTimeoutMs`, attached as `ToolDefinition.timeoutMs`) and enforced by [`@deepseek-ai/dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.md) (a `tools/execute` wrapper). Single operations forward `exec.signal`; a multi-query search fuses it with batch cancellation so a failed query aborts its siblings.
 
 Each tool is registered independently; a product that wants only one disables the other via config (`{ search: false }` / `{ fetch: false }`). Search guidance mentions `web_fetch` only when fetch is also config-enabled; a search-only composition instead tells the model to use returned snippets and cite their URLs.
 
@@ -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` searches concurrently and merges their sources in round-robin order before applying the combined `searchMaxResults` cap. Neither bound is model-facing. |
+| `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_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.
@@ -29,7 +29,7 @@ The normalized service results are also the canonical tool values: `WebSearchRes
 | `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. |
 
-`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.
+`searchMaxQueries` bounds the accepted array before exact-string deduplication, provider fan-out, and combined provider-answer growth; validation rejects an oversized array before any search starts, then dispatch keeps the first occurrence of each query. Together with each provider's own controls such as `maxUses`, these independent settings are the product's search budgets; the generic seam does not expose provider-internal native-search accounting. `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
@@ -94,7 +94,7 @@ Prefix-stable while definitions, resolved query cap, and visibility are unchange
 
 #### 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>)`. 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.`
+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 runs each exact query string once, preserving its first position; it 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
 
@@ -104,6 +104,20 @@ Data-dependent results are resent until compaction; query fan-out is capped by `
 
 Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
 
+### Search failure
+
+#### What the model sees
+
+If any query in a multi-query call fails, `web_search` aborts the other searches, waits for every started search to settle, discards successful results, and returns `Error: <message>` for the first failure.
+
+#### Token effect
+
+Only the retained error result adds tokens; discarded successful results do not enter model history.
+
+#### KV Cache effect
+
+Append-only; the error follows the reusable request prefix and does not invalidate existing KV-cache entries.
+
 ### Fetch result
 
 #### What the model sees
@@ -122,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 <count> queries`, `Error: each 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 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
 
@@ -134,7 +148,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.
+- **There is no batch-wide native-search counter** — `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. Deployments control cost through these independent consumer and provider settings because the generic seam does not know provider-internal search units.
 - **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.

+ 20 - 6
packages/web/tool-web/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-面向模型的 web 工具套件 `web_search` 与 `web_fetch`,构建于 [web 能力 seam](../web/README.md)(`ctx.web`)之上。它只负责面向模型的事项:工具名称、JSON Schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 UI 呈现投影——`presentCall`、`presentResult`(以 `kind: 'search' | 'fetch'` 区分的 `card: 'web'` 结果卡片),以及承载有损渲染文本无法携带的结构化搜索来源或抓取摘要的 `output.presentationMeta`(见 [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md))。所有 web 访问都通过 `ctx.web`;该包绝不导入具体提供方。两个工具都不公开面向模型的超时:每个工具的协作式工具调用超时预算通过配置在此声明(`fetchTimeoutMs`/`searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.md)(`tools/execute` 包装层)强制执行;每个工具只把 `exec.signal` 转发给 seam。
+面向模型的 web 工具套件 `web_search` 与 `web_fetch`,构建于 [web 能力 seam](../web/README.md)(`ctx.web`)之上。它只负责面向模型的事项:工具名称、JSON Schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 UI 呈现投影——`presentCall`、`presentResult`(以 `kind: 'search' | 'fetch'` 区分的 `card: 'web'` 结果卡片),以及承载有损渲染文本无法携带的结构化搜索来源或抓取摘要的 `output.presentationMeta`(见 [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md))。所有 web 访问都通过 `ctx.web`;该包绝不导入具体提供方。两个工具都不公开面向模型的超时:每个工具的协作式工具调用超时预算通过配置在此声明(`fetchTimeoutMs`/`searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.md)(`tools/execute` 包装层)强制执行。单项操作会转发 `exec.signal`;多查询搜索会把它与批次取消信号融合,使失败查询能够中止其余查询。
 
 每个工具独立注册;只需要其中一个工具的产品可以通过配置禁用另一个(`{ search: false }`/`{ fetch: false }`)。仅当抓取也通过配置启用时,搜索指引才会提及 `web_fetch`;仅启用搜索的组合则会要求模型使用返回的 snippet 并引用其 URL。
 
@@ -10,7 +10,7 @@
 
 | 工具 | 参数 | 行为 |
 |---|---|---|
-| `web_search` | `query`(string)或 `queries`(string[]) | 用于发现信息。返回可选答案与来源 URL。`queries` 会并发执行至多 `searchMaxQueries` 次搜索,按轮询顺序合并来源,再应用组合后的 `searchMaxResults` 上限。两个上限都不面向模型。 |
+| `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(智能体)的状态。
@@ -29,7 +29,7 @@
 | `searchTimeoutMs` | `30000` | `web_search` 的协作式工具调用超时预算(ms)。 |
 | `fetchMaxOutputChars` | `200000` | 同步转换的源字符数与单次完整 `web_fetch` 输出的上限(状态头、渲染后的主体与页脚合并计算);主体被截断时,在能容纳的情况下附带截断提示。 |
 
-`searchMaxQueries` 限制提供方请求扇出与组合后的提供方答案增长;校验会在任何搜索开始前拒绝超限数组。`fetchTimeoutMs`/`searchTimeoutMs` 声明每个工具的协作式超时预算(附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.md) 强制执行;面向模型的 schema 不公开超时参数。`fetchMaxOutputChars` 同时限制同步转换工作量和完整渲染结果:只转换至多该数量的源字符,随后对状态头、转换后的前缀和截断提示合并设限。默认值为本地提供方的 100,000 字符主体上限留出余量,但渲染膨胀仍可能使最终上限截断结果。
+`searchMaxQueries` 在完全相同的字符串去重前限制可接受数组、提供方请求扇出与组合后的提供方答案增长;校验会在任何搜索开始前拒绝超限数组,随后分发只保留每个查询第一次出现的位置。该设置与各提供方自己的 `maxUses` 等控制项共同构成产品的搜索预算;通用 seam 不公开提供方内部的原生搜索计数。`fetchTimeoutMs`/`searchTimeoutMs` 声明每个工具的协作式超时预算(附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.md) 强制执行;面向模型的 schema 不公开超时参数。`fetchMaxOutputChars` 同时限制同步转换工作量和完整渲染结果:只转换至多该数量的源字符,随后对状态头、转换后的前缀和截断提示合并设限。默认值为本地提供方的 100,000 字符主体上限留出余量,但渲染膨胀仍可能使最终上限截断结果。
 
 ```yaml
 - id: tool-web
@@ -94,7 +94,7 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex
 
 #### 模型看到的内容
 
-可选的提供方答案之后是 `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.` 结尾。
+可选的提供方答案之后是 `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 影响
 
@@ -104,6 +104,20 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex
 
 仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
 
+### 搜索失败
+
+#### 模型看到的内容
+
+多查询调用中的任何查询失败时,`web_search` 会中止其余搜索,等待所有已启动搜索结算,丢弃成功结果,并针对首次失败返回 `Error: <message>`。
+
+#### Token 影响
+
+只有保留的错误结果会增加 token;被丢弃的成功结果不会进入模型历史。
+
+#### KV Cache 影响
+
+仅追加;错误位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
+
 ### 抓取结果
 
 #### 模型看到的内容
@@ -122,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`、`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`。
+无效输入精确地变为 `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`。
 
 #### Token 影响
 
@@ -134,7 +148,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` 只限制返回给调用方的组合来源。
+- **没有覆盖整个批次的原生搜索计数器**:`searchMaxQueries` 限制 `ctx.web.search` 调用数,但提供方可以在每次调用内执行多次原生搜索。例如,配置了 `maxUses` 的模型型提供方最多可以执行 `searchMaxQueries × maxUses` 次原生搜索;`searchMaxResults` 只限制返回给调用方的组合来源。部署通过这些独立的消费方与提供方设置控制成本,因为通用 seam 不知道提供方内部的搜索计量单位。
 - **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/域名授权。

+ 2 - 2
packages/web/tool-web/src/index.ts

@@ -12,8 +12,8 @@ import type {} from '@deepseek-ai/dsh-web'
 import { applyWebSearchTool, WEB_SEARCH_MAX_QUERIES, WEB_SEARCH_MAX_RESULTS } from './search.ts'
 import { applyWebFetchTool } from './fetch.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 { WEB_SEARCH_MAX_QUERIES, WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, presentSearchCall, presentSearchResult, searchMetaFromValue, searchMetaFromResult } from './search.ts'
+export type { WebSearchMeta } from './search.ts'
 export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall, presentFetchResult, fetchMetaFromValue, fetchMetaFromResult } from './fetch.ts'
 export type { WebFetchMeta } from './fetch.ts'
 

+ 29 - 9
packages/web/tool-web/src/search.ts

@@ -26,7 +26,7 @@ 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 {
+interface WebSearchArgs {
   query?: string
   queries?: string[]
 }
@@ -34,8 +34,9 @@ export interface WebSearchArgs {
 /**
  * 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. Throws a plain
- * `Error` otherwise.
+ * `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.
  *
  * @param args - the schema-validated `web_search` arguments.
  * @param maxQueries - the deployment's upper bound on queries in one call.
@@ -43,7 +44,7 @@ export interface WebSearchArgs {
  */
 export function parseSearchArgs(
   args: WebSearchArgs,
-  maxQueries = WEB_SEARCH_MAX_QUERIES,
+  maxQueries: number,
 ): { query: string } | { queries: string[] } {
   if (args.query !== undefined && args.queries !== undefined) {
     throw new Error('provide either query or queries, not both')
@@ -55,9 +56,12 @@ export function parseSearchArgs(
   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.length > maxQueries) {
+    const noun = maxQueries === 1 ? 'query' : 'queries'
+    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 }
+  return { queries: [...new Set(queries)] }
 }
 
 /** Display label for a source: its title, else its hostname. */
@@ -109,7 +113,7 @@ export function formatSearchOutput(result: WebSearchResult): string {
  * @param args - the raw tool arguments.
  * @returns a comma-joined title for the search card.
  */
-export function searchTitle(args: WebSearchArgs): string {
+function searchTitle(args: WebSearchArgs): string {
   const queries = args.queries ?? (args.query !== undefined ? [args.query] : [])
   return queries.join(', ')
 }
@@ -245,7 +249,9 @@ function queriesFromSearchArgs(input: { query: string } | { queries: string[] })
 /**
  * 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`.
+ * into one normalized result capped at `maxResults`. A failed search aborts
+ * its siblings, and this function waits for every search to settle before
+ * rethrowing the first failure.
  *
  * @param ctx - context whose `web` service performs the searches.
  * @param queries - validated non-empty queries.
@@ -262,7 +268,21 @@ async function runSearchQueries(
   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)))
+  const controller = new AbortController()
+  const batchSignal = AbortSignal.any([signal, controller.signal])
+  let firstFailure: { error: unknown } | undefined
+  const results: WebSearchResult[] = []
+  const searches = queries.map(async (query, index) => {
+    try {
+      results[index] = await ctx.web.search({ query, maxResults }, batchSignal)
+    } catch (error) {
+      if (firstFailure === undefined) firstFailure = { error }
+      controller.abort(error)
+      throw error
+    }
+  })
+  await Promise.allSettled(searches)
+  if (firstFailure !== undefined) throw firstFailure.error
   return mergeSearchResults(queries, results, maxResults)
 }
 

+ 77 - 13
packages/web/tool-web/tests/tool-web.spec.ts

@@ -10,7 +10,6 @@ import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
 import {
   formatSearchOutput,
   formatFetchOutput,
-  parseSearchArgs,
   parseFetchArgs,
   presentSearchCall,
   presentFetchCall,
@@ -25,6 +24,7 @@ import {
 } from '@deepseek-ai/dsh-tool-web'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm'
 import type { ToolResult } from '@deepseek-ai/dsh-tools'
+import { parseSearchArgs } from '../src/search.ts'
 
 const testToolSignal = new AbortController().signal
 
@@ -86,17 +86,19 @@ describe('search formatting', () => {
   })
 
   it('validates the query', () => {
-    expect(() => parseSearchArgs({ query: '   ' })).toThrow('non-empty')
-    expect(parseSearchArgs({ query: 'hi' })).toEqual({ query: 'hi' })
+    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', () => {
-    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', 'one', ' two '] }, WEB_SEARCH_MAX_QUERIES))
+      .toEqual({ queries: ['one', ' two '] })
+    expect(() => parseSearchArgs({}, WEB_SEARCH_MAX_QUERIES)).toThrow('provide either query or queries')
+    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', ' '] })).toThrow('each query must be a non-empty string')
-    expect(() => parseSearchArgs({ query: 'one', queries: ['two'] })).toThrow('not both')
+    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', () => {
@@ -560,7 +562,7 @@ describe('tool-web execution through the real registry', () => {
       },
     }
     const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
-    const pending = call('web_search', { queries: ['one', 'two'] })
+    const pending = call('web_search', { queries: ['one', 'one', 'two'] })
     try {
       await vi.waitFor(() => { expect(seen).toEqual(['one', 'two']) })
     } finally {
@@ -583,6 +585,61 @@ describe('tool-web execution through the real registry', () => {
     await fiber.dispose()
   })
 
+  it('continues round-robin merging after a shorter result is exhausted', async () => {
+    const provider: WebSearchProvider = {
+      id: 'stub-search',
+      available: () => available,
+      search: request => Promise.resolve(request.query === 'one'
+        ? { content: '', sources: [{ url: 'https://a.test' }], truncated: false }
+        : { sources: [{ url: 'https://b.test' }, { url: 'https://c.test' }], truncated: false }),
+    }
+    const { fiber, call } = await mountTools({ 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://b.test' },
+        { url: 'https://c.test' },
+      ],
+      truncated: false,
+    })
+    await fiber.dispose()
+  })
+
+  it('aborts sibling searches and waits for them to settle before reporting a batch failure', async () => {
+    let siblingAborted = false
+    let releaseSibling: (() => void) | undefined
+    const provider: WebSearchProvider = {
+      id: 'stub-search',
+      available: () => available,
+      search: (request, signal) => {
+        if (request.query === 'one') return Promise.reject(new Error('first search failed'))
+        return new Promise((_resolve, reject) => {
+          releaseSibling = () => { reject(new Error('sibling search stopped')) }
+          signal?.addEventListener('abort', () => {
+            siblingAborted = true
+          }, { once: true })
+        })
+      },
+    }
+    const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
+    const pending = call('web_search', { queries: ['one', 'two'] })
+    let callSettled = false
+    void pending.then(() => { callSettled = true })
+    try {
+      await vi.waitFor(() => { expect(siblingAborted).toBe(true) })
+      await Promise.resolve()
+      expect(callSettled).toBe(false)
+    } finally {
+      releaseSibling?.()
+    }
+    const out = await pending
+    expect(out.isError).toBe(true)
+    expect(out.content).toEqual([{ type: 'text', text: 'Error: first search failed' }])
+    await fiber.dispose()
+  })
+
   it('caps combined multi-query results to searchMaxResults', async () => {
     const provider: WebSearchProvider = {
       id: 'stub-search',
@@ -734,20 +791,27 @@ describe('tool-web execution through the real registry', () => {
     await fiber.dispose()
   })
 
-  it('forwards the abort signal to every multi-query search', async () => {
+  it('cascades caller cancellation 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 })
+        return new Promise((_resolve, reject) => {
+          signal?.addEventListener('abort', () => { reject(new Error('search aborted')) }, { once: true })
+        })
       },
     }
     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])
+    const pending = ctx.tools.execute({ callId: CallId('search-multi-1'), name: 'web_search', arguments: { queries: ['one', 'two'] }, signal: controller.signal })
+    await vi.waitFor(() => { expect(signals).toHaveLength(2) })
+    expect(signals[0]).toBe(signals[1])
+    expect(signals[0]).not.toBe(controller.signal)
+    controller.abort(new Error('caller cancelled'))
+    await pending
+    expect(signals.every(signal => signal?.aborted === true)).toBe(true)
     await fiber.dispose()
   })
 })

+ 4 - 3
packages/web/web/src/types.ts

@@ -8,9 +8,10 @@
 import { HarnessError } from '@deepseek-ai/dsh-llm'
 
 /**
- * What one search-capable backend can return. The model-facing argument is just
- * a query; `maxResults` is a `dsh-tool-web`-layer bound passed through unchanged
- * and enforced on the way back by the seam (see {@link WebSearchResult}).
+ * What one search-capable backend is asked to search. Each request carries one
+ * query; a consumer may issue several requests. `maxResults` is a
+ * `dsh-tool-web`-layer bound passed through unchanged and enforced on the way
+ * back by the seam (see {@link WebSearchResult}).
  */
 export interface WebSearchRequest {
   readonly query: string