@browserwright/pi 0.18.2 → 0.18.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -228,9 +228,17 @@ legitimately inside raw HTML and inside search results *about* JavaScript.
228
228
  | field | applies to | note |
229
229
  |-------|-----------|------|
230
230
  | `minChars` | text payloads only | default 0 (off) |
231
- | `minResults` | list payloads only | an empty list is rejected regardless |
231
+ | `minResults` | list payloads only | an empty list is rejected regardless, unless the engine asserted it |
232
232
  | `matches` | both | searched in the text, or in joined titles + snippets |
233
233
 
234
+ The one exception is an **asserted** empty. When a search provider reports
235
+ `noMatch: true` — Google says "did not match any documents" in prose, on an
236
+ ordinary HTTP 200 page — the empty list is the engine's answer and is accepted,
237
+ `minResults` included. Everything else empty is still rejected, because that is
238
+ what a consent wall or a captcha looks like from here. Without the distinction a
239
+ query the engine answered correctly reaches the model as `bw_web_search failed`,
240
+ which reads as broken tooling rather than as a query worth rewriting.
241
+
234
242
  `minChars` is deliberately not applied to a list, and `minResults` not to text:
235
243
  the two floors measure different things, and applying both would reject a short
236
244
  but complete set of hits.
package/core/format.ts CHANGED
@@ -154,6 +154,20 @@ export function renderResults(result: ChainResult<SearchPayload>, query: string)
154
154
  if (lines.length > 0) out.push("", "## Knowledge panel", ...lines);
155
155
  }
156
156
 
157
+ if (results.length === 0) {
158
+ // Reaching the success path with no rows means the engine said so itself
159
+ // (see SearchPayload.noMatch) — everything else is rejected upstream. Say
160
+ // which it was, and say what to do next: the model's move here is to
161
+ // rewrite the query, not to conclude the tool is broken.
162
+ out.push(
163
+ "",
164
+ payload.noMatch
165
+ ? "The search engine states that nothing matched this query. That is its answer, not a failure — " +
166
+ "widen the query before retrying: drop a `site:` path, a quoted phrase, or the least essential keywords."
167
+ : "No rows were extracted from the results page.",
168
+ );
169
+ }
170
+
157
171
  if (results.length > 0) {
158
172
  out.push("");
159
173
  for (const row of results) {
@@ -171,7 +185,7 @@ export function renderResults(result: ChainResult<SearchPayload>, query: string)
171
185
  out.push("## Related searches", payload.relatedSearches.join(" · "), "");
172
186
  }
173
187
 
174
- out.push("Use bw_web_fetch on a URL above to read it.");
188
+ if (results.length > 0) out.push("Use bw_web_fetch on a URL above to read it.");
175
189
  return out.join("\n");
176
190
  }
177
191
 
@@ -74,6 +74,7 @@ export const inspectText: Inspector<string> = (content) => ({ text: content });
74
74
  export const inspectSearch: Inspector<SearchPayload> = (payload) => ({
75
75
  count: payload.results.length,
76
76
  text: payload.results.map((r) => `${r.title} ${r.snippet ?? ""}`).join("\n"),
77
+ authoritativeEmpty: payload.noMatch === true,
77
78
  });
78
79
 
79
80
  /**
@@ -89,9 +90,13 @@ export function failureReason<T>(value: T, rule: FailWhen, inspect: Inspector<T>
89
90
  const count = inspected.count;
90
91
 
91
92
  if (count !== undefined) {
92
- // A search that parsed cleanly but found nothing is a failure worth
93
- // falling through on: an interstitial usually yields a valid, empty list
94
- // rather than an error.
93
+ // An engine that said "no documents matched" has answered the question.
94
+ // Accepting that is what stops a correctly-answered query from reaching
95
+ // the model as a failed tool call; the needle scan below is skipped with
96
+ // it, which costs nothing because the haystack is empty either way.
97
+ if (count === 0 && inspected.authoritativeEmpty) return undefined;
98
+ // Any other empty list is a failure worth falling through on: an
99
+ // interstitial usually yields a valid, empty list rather than an error.
95
100
  if (count === 0) return "no results";
96
101
  const minResults = rule.minResults ?? 0;
97
102
  if (minResults > 0 && count < minResults) {
package/core/results.ts CHANGED
@@ -20,6 +20,7 @@ const ANSWER_KEYS = ["answerBox", "answer_box", "answer", "featured_snippet"] as
20
20
  const KG_KEYS = ["knowledgeGraph", "knowledge_graph"] as const;
21
21
  const PAA_KEYS = ["peopleAlsoAsk", "people_also_ask", "relatedQuestions"] as const;
22
22
  const RELATED_KEYS = ["relatedSearches", "related_searches", "relatedQueries"] as const;
23
+ const NO_MATCH_KEYS = ["noMatch", "no_match", "zeroResults"] as const;
23
24
 
24
25
  function firstValue(row: Record<string, unknown>, keys: readonly string[]): unknown {
25
26
  for (const key of keys) {
@@ -137,5 +138,9 @@ export function normalizeSearchPayload(value: unknown): SearchPayload {
137
138
  const relatedSearches = stringList(firstValue(body, RELATED_KEYS));
138
139
  if (relatedSearches) payload.relatedSearches = relatedSearches;
139
140
 
141
+ // Only meaningful next to an empty list. A source claiming both rows and
142
+ // "nothing matched" is contradicting itself, and the rows are the evidence.
143
+ if (payload.results.length === 0 && firstValue(body, NO_MATCH_KEYS) === true) payload.noMatch = true;
144
+
140
145
  return payload;
141
146
  }
package/core/types.ts CHANGED
@@ -60,6 +60,18 @@ export interface KnowledgeGraph {
60
60
  */
61
61
  export interface SearchPayload {
62
62
  results: SearchResult[];
63
+ /**
64
+ * The engine stated, in so many words, that nothing matched.
65
+ *
66
+ * Only ever set alongside an empty `results`, and it is the one thing that
67
+ * separates an honestly empty search from the failure mode that looks
68
+ * identical from the outside: a captcha or consent wall parses perfectly and
69
+ * also yields zero rows. Without this flag both have to be rejected, and a
70
+ * query the engine answered correctly is reported to the model as a broken
71
+ * tool — which is exactly what it then works around instead of rewriting the
72
+ * query.
73
+ */
74
+ noMatch?: boolean;
63
75
  answerBox?: AnswerBox;
64
76
  knowledgeGraph?: KnowledgeGraph;
65
77
  peopleAlsoAsk?: string[];
@@ -236,6 +248,12 @@ export interface Inspected {
236
248
  text: string;
237
249
  /** Item count, for list payloads. Undefined for text payloads. */
238
250
  count?: number;
251
+ /**
252
+ * A zero count that the source asserted rather than one we inferred from a
253
+ * payload we could not read. Lifts the blanket rejection of an empty list —
254
+ * see `SearchPayload.noMatch`.
255
+ */
256
+ authoritativeEmpty?: boolean;
239
257
  }
240
258
 
241
259
  export type Inspector<T> = (value: T) => Inspected;
package/index.ts CHANGED
@@ -180,6 +180,9 @@ export default function (pi: ExtensionAPI) {
180
180
  query,
181
181
  provider: result.provider,
182
182
  count: result.content?.results.length ?? 0,
183
+ // Zero rows is a success now, so the trace has to record which
184
+ // kind of zero it was.
185
+ noMatch: result.content?.noMatch === true,
183
186
  features: {
184
187
  answerBox: Boolean(result.content?.answerBox),
185
188
  knowledgeGraph: Boolean(result.content?.knowledgeGraph),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserwright/pi",
3
- "version": "0.18.2",
3
+ "version": "0.18.4",
4
4
  "description": "bw_web_fetch and bw_web_search for the pi coding agent, using browserwright plus a raw text fallback",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -31,7 +31,11 @@
31
31
  "them as a reason.",
32
32
  "minResults 1 is the real guard. A captcha or consent wall parses perfectly",
33
33
  "and yields an empty list, which is the failure mode that would otherwise be",
34
- "indistinguishable from an honestly empty search.",
34
+ "indistinguishable from an honestly empty search. The extractor tells the two",
35
+ "apart by reading Google's own sentence ('did not match any documents') off",
36
+ "the page and setting noMatch, which lifts minResults for that one case: an",
37
+ "engine that says nothing matched has answered the query, and reporting that",
38
+ "as a tool failure only teaches the model to route around a working tool.",
35
39
  "searchUrl is a template so the engine can be swapped without touching code.",
36
40
  "{queryEncoded} and {limit} are the only substitutions.",
37
41
  "SERP features (measured 2026-08-10, see docs/adr/0008). The page is server-",
@@ -246,6 +246,17 @@ function buildScript(query: string, limit: number, outPath: string, searchUrl: s
246
246
  return out.slice(0, 10);
247
247
  }, []);
248
248
 
249
+ // --- explicit "nothing matched" ---------------------------------------
250
+ // Google says this in prose on an otherwise ordinary page: no error, no
251
+ // interstitial, HTTP 200, zero rows. Reading the sentence is the only way to
252
+ // tell that empty list apart from the one a consent wall produces. Measured
253
+ // 2026-09-02 on a site:-with-path query, which is the shape that reaches zero
254
+ // most often.
255
+ const noMatch = attempt(() => {
256
+ const body = (document.body.innerText || '').toLowerCase();
257
+ return ['did not match any documents', 'no results found for'].some((n) => body.includes(n));
258
+ }, false);
259
+
249
260
  // --- related searches --------------------------------------------------
250
261
  // Scoped to #botstuff: the same href pattern at the top of the page is
251
262
  // Google's own tab bar ("Images", "News", "Past hour"), not a related query.
@@ -266,7 +277,7 @@ function buildScript(query: string, limit: number, outPath: string, searchUrl: s
266
277
  return out.slice(0, 10);
267
278
  }, []);
268
279
 
269
- return { results, answerBox, knowledgeGraph, peopleAlsoAsk, relatedSearches };
280
+ return { results, answerBox, knowledgeGraph, peopleAlsoAsk, relatedSearches, noMatch };
270
281
  }`;
271
282
 
272
283
  // json.dumps gives us correctly escaped Python string literals for free, so
@@ -310,6 +321,9 @@ function buildScript(query: string, limit: number, outPath: string, searchUrl: s
310
321
  ' "knowledgeGraph": data.get("knowledgeGraph"),',
311
322
  ' "peopleAlsoAsk": data.get("peopleAlsoAsk") or [],',
312
323
  ' "relatedSearches": data.get("relatedSearches") or [],',
324
+ // Only ever consulted when rows is empty, but carried unconditionally so
325
+ // the shape of the payload does not depend on the outcome.
326
+ ' "noMatch": bool(data.get("noMatch")),',
313
327
  " }",
314
328
  " if not rows:",
315
329
  // An interstitial parses fine and yields zero rows; say which kind it was.