Skip to content

Commit fa30bdb

Browse files
Add retry hint on empty search results; warn on unrecognized shape
1 parent 950a792 commit fa30bdb

6 files changed

Lines changed: 174 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ All notable changes to this project are documented here.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [2.2.0] - 2026-04-26
8+
### Added
9+
- `web_search` now returns a retry hint on empty results, instructing the model to broaden/simplify the query before declining to answer. Closes the failure mode where the model interpreted "0 results" as "I cannot search" and gave up.
10+
- Diagnostic warning event when the search backend returns an unrecognized response shape (previously silently coerced to `[]`).
11+
### Changed
12+
- `web_search` docstring strengthened with explicit guidance on retrying after empty results.
13+
14+
## [2.1.0] - 2026-04-26
15+
### Added
16+
- Per-URL citation events for every page that gets fetched (both via auto-fetch and explicit `fetch_url`). Open WebUI now displays each fetched page as its own clickable source in the chat instead of a single generic `websearch/web_search` entry.
17+
718
## [2.0.0] - 2026-04-26
819
### Added
920
- `auto_fetch_enabled` valve (default `true`). Dedicated master switch for the post-search auto-fetch step; when `false`, `web_search` returns snippets only but the model can still call `fetch_url` itself.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ A small [Open WebUI](https://github.com/open-webui/open-webui) tool that lets th
1414
- **Domain allow/block lists** applied to both search results and `fetch_url` targets.
1515
- **Auto-fetches every result page by default** in parallel after every search, so answers are based on real page bodies rather than short snippets — even if the model wouldn't have called `fetch_url` itself. Configurable to a top-N cap.
1616
- **Status events** for visible progress in the chat UI.
17+
- **Per-URL citations** — every fetched page is emitted as its own clickable source in the chat, not a single generic tool entry.
1718
- **Single-file deployment** — copy `websearch.py` into Workspace → Tools.
1819

1920
## Requirements
@@ -67,7 +68,7 @@ Both methods return a JSON-encoded string with a stable shape:
6768
{"error": "human-readable reason"}
6869
```
6970

70-
`content` is present on every returned result by default (`auto_fetch_top=0`) when both `enable_fetch_url` and `auto_fetch_enabled` are on; set a positive `N` to cap pre-fetching to the top `N`, or set `auto_fetch_enabled=false` to skip pre-fetching entirely while keeping `fetch_url` available to the model. On a fetch failure the entry gets `"fetch_error": "..."` instead. The `hint` is omitted when there are no results or when `enable_fetch_url` is off.
71+
`content` is present on every returned result by default (`auto_fetch_top=0`) when both `enable_fetch_url` and `auto_fetch_enabled` are on; set a positive `N` to cap pre-fetching to the top `N`, or set `auto_fetch_enabled=false` to skip pre-fetching entirely while keeping `fetch_url` available to the model. On a fetch failure the entry gets `"fetch_error": "..."` instead. The `hint` is always present on empty results (instructs the model to retry with a broader query) and on results when `enable_fetch_url` is on; it is omitted only on results when `enable_fetch_url` is off.
7172

7273
## Development
7374

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "openwebui-tool-websearch"
7-
version = "2.0.0"
7+
version = "2.2.0"
88
description = "OpenWebUI tool that exposes web_search and fetch_url to the model, reusing OpenWebUI's configured search backend."
99
readme = "README.md"
1010
requires-python = ">=3.11"

tests/test_normalize.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import json
44

5-
from websearch import _normalize
5+
from websearch import _looks_empty, _normalize
66

77

88
def test_normalize_json_string_list() -> None:
@@ -40,3 +40,24 @@ def test_normalize_none_returns_empty() -> None:
4040

4141
def test_normalize_drops_non_dict_entries() -> None:
4242
assert _normalize(["string", 42, {"title": "T"}]) == [{"title": "T", "link": "", "snippet": ""}]
43+
44+
45+
def test_looks_empty_recognizes_known_empty_shapes() -> None:
46+
assert _looks_empty(None)
47+
assert _looks_empty("")
48+
assert _looks_empty(" ")
49+
assert _looks_empty([])
50+
assert _looks_empty({})
51+
assert _looks_empty({"results": []})
52+
assert _looks_empty("[]")
53+
assert _looks_empty("{}")
54+
assert _looks_empty('{"results": []}')
55+
56+
57+
def test_looks_empty_flags_unrecognized_or_non_empty_shapes() -> None:
58+
assert not _looks_empty({"data": [{"title": "x"}]})
59+
assert not _looks_empty({"web": {"results": []}})
60+
assert not _looks_empty('{"data":[{"title":"x"}]}')
61+
assert not _looks_empty([{"title": "x"}])
62+
assert not _looks_empty("not json")
63+
assert not _looks_empty(42)

tests/test_tool.py

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,33 @@ async def test_web_search_returns_json_results(patch_builtins, fake_request, fak
3737
assert "fetch_url" in payload["hint"]
3838
assert search_calls and search_calls[0]["query"] == "openwebui"
3939
assert search_calls[0]["count"] == tool.valves.result_count
40-
assert any(e["data"]["done"] for e in emitted)
40+
assert any(e["data"].get("done") for e in emitted if e.get("type") == "status")
4141

4242

43-
async def test_web_search_omits_hint_when_no_results(patch_builtins, fake_request, fake_user) -> None:
43+
async def test_web_search_warns_on_unrecognized_response_shape(
44+
patch_builtins, fake_request, fake_user, emitter, emitted
45+
) -> None:
46+
patch_builtins(search_result={"data": [{"title": "x", "link": "https://x/1"}]})
47+
tool = _make_tool()
48+
out = await tool.web_search("q", __request__=fake_request, __user__=fake_user, __event_emitter__=emitter)
49+
payload = json.loads(out)
50+
assert payload["results"] == []
51+
statuses = [e for e in emitted if e.get("type") == "status"]
52+
assert any(
53+
e["data"].get("status") == "warning" and "unrecognized" in e["data"].get("description", "").lower()
54+
for e in statuses
55+
)
56+
57+
58+
async def test_web_search_emits_retry_hint_when_no_results(patch_builtins, fake_request, fake_user) -> None:
4459
patch_builtins(search_result=[])
4560
tool = _make_tool()
4661
out = await tool.web_search("q", __request__=fake_request, __user__=fake_user)
4762
payload = json.loads(out)
4863
assert payload["results"] == []
49-
assert "hint" not in payload
64+
hint = payload["hint"].lower()
65+
assert "broader" in hint or "shorter" in hint
66+
assert "web_search" in hint
5067

5168

5269
async def test_web_search_omits_hint_when_fetch_url_disabled(patch_builtins, fake_request, fake_user) -> None:
@@ -131,6 +148,55 @@ async def test_web_search_auto_fetch_top_inactive_when_fetch_url_disabled(
131148
assert "hint" not in payload
132149

133150

151+
async def test_web_search_auto_fetch_emits_citation_per_page(
152+
patch_builtins, fake_request, fake_user, emitter, emitted
153+
) -> None:
154+
patch_builtins(
155+
search_result=[
156+
{"title": "T1", "link": "https://a.test/1", "snippet": "s1"},
157+
{"title": "T2", "link": "https://b.test/2", "snippet": "s2"},
158+
],
159+
fetch_result="body",
160+
)
161+
tool = _make_tool()
162+
await tool.web_search("q", __request__=fake_request, __user__=fake_user, __event_emitter__=emitter)
163+
citations = [e for e in emitted if e.get("type") == "citation"]
164+
assert len(citations) == 2
165+
urls = {c["data"]["source"]["url"] for c in citations}
166+
assert urls == {"https://a.test/1", "https://b.test/2"}
167+
assert all(c["data"]["document"] == ["body"] for c in citations)
168+
names = {c["data"]["source"]["name"] for c in citations}
169+
assert names == {"T1", "T2"}
170+
171+
172+
async def test_web_search_auto_fetch_skips_citation_on_empty_body(
173+
patch_builtins, fake_request, fake_user, emitter, emitted
174+
) -> None:
175+
patch_builtins(
176+
search_result=[{"title": "T", "link": "https://a.test/1", "snippet": "s"}],
177+
fetch_result="",
178+
)
179+
tool = _make_tool()
180+
await tool.web_search("q", __request__=fake_request, __user__=fake_user, __event_emitter__=emitter)
181+
citations = [e for e in emitted if e.get("type") == "citation"]
182+
assert citations == []
183+
184+
185+
async def test_fetch_url_emits_citation(patch_builtins, fake_request, fake_user, emitter, emitted) -> None:
186+
patch_builtins(fetch_result="hello")
187+
tool = _make_tool()
188+
await tool.fetch_url(
189+
"https://example.com/x",
190+
__request__=fake_request,
191+
__user__=fake_user,
192+
__event_emitter__=emitter,
193+
)
194+
citations = [e for e in emitted if e.get("type") == "citation"]
195+
assert len(citations) == 1
196+
assert citations[0]["data"]["source"]["url"] == "https://example.com/x"
197+
assert citations[0]["data"]["document"] == ["hello"]
198+
199+
134200
async def test_web_search_auto_fetch_records_errors(patch_builtins, fake_request, fake_user) -> None:
135201
patch_builtins(
136202
search_result=[{"title": "A", "link": "https://a.test/1", "snippet": "sa"}],

websearch.py

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
description: Search the web and fetch pages on demand, via OpenWebUI's configured backend.
77
required_open_webui_version: 0.6.0
88
requirements: pydantic>=2
9-
version: 2.0.0
9+
version: 2.2.0
1010
license: MIT
1111
"""
1212

@@ -81,6 +81,32 @@ def _filter_results(results: list[dict[str, Any]], allow: list[str], block: list
8181
return filtered
8282

8383

84+
def _looks_empty(raw: Any) -> bool:
85+
"""True if `raw` plausibly represents an empty result set in a recognized shape.
86+
87+
Used to distinguish a backend that genuinely returned nothing from one whose
88+
response shape we failed to parse — the latter deserves a diagnostic warning,
89+
the former does not.
90+
"""
91+
if raw is None:
92+
return True
93+
if isinstance(raw, str):
94+
if not raw.strip():
95+
return True
96+
try:
97+
return _looks_empty(json.loads(raw))
98+
except json.JSONDecodeError:
99+
return False
100+
if isinstance(raw, list):
101+
return len(raw) == 0
102+
if isinstance(raw, dict):
103+
if not raw:
104+
return True
105+
results = raw.get("results")
106+
return isinstance(results, list) and len(results) == 0
107+
return False
108+
109+
84110
def _normalize(raw: Any) -> list[dict[str, Any]]:
85111
if raw is None:
86112
return []
@@ -123,6 +149,21 @@ async def _emit(emitter: EmitFn | None, description: str, *, done: bool = False,
123149
)
124150

125151

152+
async def _emit_citation(emitter: EmitFn | None, *, url: str, title: str, content: str) -> None:
153+
if emitter is None or not content:
154+
return
155+
await emitter(
156+
{
157+
"type": "citation",
158+
"data": {
159+
"document": [content],
160+
"metadata": [{"source": url}],
161+
"source": {"name": title or url, "url": url},
162+
},
163+
}
164+
)
165+
166+
126167
def _error(message: str) -> str:
127168
return json.dumps({"error": message})
128169

@@ -190,12 +231,15 @@ async def web_search(
190231
Search the web when the user's question requires current, recent,
191232
or post-training-cutoff information, or specific facts you do not
192233
reliably know. Call multiple times with refined queries if the first
193-
results are insufficient. After results come back, call fetch_url
194-
on one or more of the most relevant links to read the full page
195-
before answering — snippets are short and frequently misleading,
196-
especially for lists, comparisons, dates, prices, or specifications.
197-
Do not call for general knowledge, math, or topics fully covered
198-
by your training data.
234+
results are insufficient. If a call returns no results, retry with a
235+
broader query (drop the year, drop adjectives, keep 2-4 core terms)
236+
before declining to answer — search engines frequently miss long,
237+
over-specified phrases. After results come back, call fetch_url on
238+
one or more of the most relevant links to read the full page before
239+
answering — snippets are short and frequently misleading, especially
240+
for lists, comparisons, dates, prices, or specifications. Do not
241+
call for general knowledge, math, or topics fully covered by your
242+
training data.
199243
200244
:param query: A focused search query in natural language.
201245
:param count: Optional override for number of results (1-20). 0 uses the configured default.
@@ -232,6 +276,12 @@ async def web_search(
232276
return _error(message)
233277

234278
results = _normalize(raw)
279+
if not results and not _looks_empty(raw):
280+
await _emit(
281+
__event_emitter__,
282+
"Search backend returned an unrecognized response shape; treating as empty.",
283+
status="warning",
284+
)
235285
results = _filter_results(
236286
results,
237287
allow=_split_csv(self.valves.allow_domains),
@@ -253,7 +303,14 @@ async def web_search(
253303
status="success" if results else "warning",
254304
)
255305
payload: dict[str, Any] = {"results": results}
256-
if results and self.valves.enable_fetch_url:
306+
if not results:
307+
payload["hint"] = (
308+
"No results returned. The search engine often misses long, "
309+
"over-specified queries. Call web_search again with a shorter, "
310+
"broader query — drop the year, drop adjectives, keep 2-4 core "
311+
"terms — before telling the user you don't know."
312+
)
313+
elif self.valves.enable_fetch_url:
257314
if fetched_count:
258315
payload["hint"] = (
259316
f"The top {fetched_count} page(s) have already been fetched and are "
@@ -296,7 +353,9 @@ async def _one(entry: dict[str, Any]) -> None:
296353
return
297354
try:
298355
content = await fetch_url(url=link, __request__=__request__, __user__=__user__)
299-
entry["content"] = content if isinstance(content, str) else ""
356+
text = content if isinstance(content, str) else ""
357+
entry["content"] = text
358+
await _emit_citation(emitter, url=link, title=str(entry.get("title") or ""), content=text)
300359
except Exception as exc:
301360
entry["fetch_error"] = str(exc)
302361

@@ -363,6 +422,7 @@ async def fetch_url(
363422
return _error(message)
364423

365424
text = content if isinstance(content, str) else ""
425+
await _emit_citation(__event_emitter__, url=cleaned_url, title=cleaned_url, content=text)
366426
await _emit(
367427
__event_emitter__,
368428
f"Fetched {len(text)} character(s) from {host}",

0 commit comments

Comments
 (0)