From 283fc9ae0371f4d318a539f021a4b88e05fac190 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 31 Jul 2026 18:36:13 +0200 Subject: feat: web search via SearXNG The model is offered a web_search tool and decides when a question needs current information. llamachat runs the query against SearXNG's JSON API, feeds the results back, and the model answers from them. Off unless both search_enabled and search_url are set, so an upgrade never starts talking to the network on its own. The loop lives in backend.stream_chat: a round ending in tool_calls is searched for, the result appended, and the request re-sent. After max_searches rounds the tool is withdrawn, which forces an answer rather than letting an uncertain model search forever. Searches show as a collapsible block above the thinking block, listing each query and its sources. The queries are visible on purpose: when an answer is wrong it is usually the query that was wrong, and without seeing it a bad search and a bad answer look identical. Two failures found while testing against the live stack shaped the design: The model has no clock, so it falls back on its training cutoff and writes that year into the query itself ("latest kernel ... 2025"), poisoning the results before they are fetched. The current date now goes into the system prompt whenever search is on, with an instruction not to date its own queries. A SearXNG whose engines are all rate-limited or CAPTCHA'd returns a valid response with zero results. Reporting that as "no results" tells the model the web is empty and invites a confident answer from stale training data, so a search where every engine failed is now an error naming the engines. Results are attacker-influenced text entering the model's context. Only title, url and the snippet survive, snippets are truncated, result text is escaped on display, result links are never fetched automatically, and a reply forging the search block's URL scheme has it defused as the reasoning scheme already was. None of that stops a poisoned snippet from influencing the answer, which is why the sources stay visible. Adds eight test groups, 23 to 31, all hermetic behind a fake HTTP layer. Co-Authored-By: Claude Opus 5 --- test_llamachat.py | 589 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 583 insertions(+), 6 deletions(-) (limited to 'test_llamachat.py') diff --git a/test_llamachat.py b/test_llamachat.py index f23a576..936a18d 100755 --- a/test_llamachat.py +++ b/test_llamachat.py @@ -88,12 +88,20 @@ def test_real_presets(): print("skip real presets (file absent)") return presets = config.parse_presets(path) - assert "gemma-4-12B-it" in presets - assert presets["gemma-4-12B-it"].vision is True - # Qwen3.5-9B has its mmproj line commented out with '#'. - if "Qwen3.5-9B" in presets: - assert presets["Qwen3.5-9B"].vision is False - print("ok real presets classification") + assert presets, "presets.ini exists but parsed to nothing" + + # Section names track whatever the user currently runs, so assert the + # parsing properties rather than a list of names that goes stale on + # every rename. + for preset in presets.values(): + assert preset.name + assert preset.ctx_size > 0 + assert isinstance(preset.vision, bool) + + # Whether a given section has vision is the user's choice and changes + # when they edit the file; only the commented-out case is a parsing + # claim, and PRESETS_SAMPLE covers that hermetically above. + print(f"ok real presets classification ({len(presets)} sections)") def test_fts_query_escaping(): @@ -897,6 +905,567 @@ def test_config_defaults(): print("ok config defaults") +class _FakeResponse: + """Enough of an http.client response for urlopen's context manager.""" + + def __init__(self, body: bytes): + self._body = body + + def read(self): + return self._body + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +def _fake_urlopen(body, capture=None): + """A urlopen replacement returning `body`, or raising it when an error.""" + + def opener(request, timeout=None): + if capture is not None: + capture.append(request.full_url) + if isinstance(body, Exception): + raise body + return _FakeResponse(body) + + return opener + + +def _with_urlopen(body, capture=None): + """Swap search's urlopen for a fake. Returns the original to restore.""" + import urllib.request + + original = urllib.request.urlopen + urllib.request.urlopen = _fake_urlopen(body, capture) + return original + + +def test_search_tool_schema(): + from llamachat import search + + schema = search.TOOL_SCHEMA + assert schema["type"] == "function" + function = schema["function"] + assert function["name"] == "web_search" + assert function["description"] + params = function["parameters"] + assert params["type"] == "object" + assert params["required"] == ["query"] + assert params["properties"]["query"]["type"] == "string" + + # Disabled search must not put a tools key on the wire at all, or a + # model that ignores it still pays for the tokens. + sent = [] + client = backend.Client("http://x") + client._stream_once = lambda model, messages, tools: ( + sent.append(tools) or iter([("content", "hi")]) + ) + out = list(client.stream_chat("m", [], backend.SearchConfig(enabled=False))) + assert out == [("content", "hi")] + assert sent == [None], sent + + # A configured-but-urlless setup resolves to disabled at config load. + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "config.toml" + path.write_text('search_enabled = true\nsearch_url = ""\n') + assert config.load(path).search_enabled is False + + path.write_text( + 'search_enabled = true\nsearch_url = "http://searx.local:8888/"\n' + ) + cfg = config.load(path) + assert cfg.search_enabled is True + assert cfg.search_url == "http://searx.local:8888" # trailing / gone + print("ok search tool schema") + + +def test_search_results_sanitising(): + import urllib.request + + from llamachat import search + from llamachat.ui import SEARCH_SCHEME, _markdown_to_fragment, _search_html + + payload = json.dumps( + { + "results": [ + { + "title": "First", + "url": "https://example.com/a", + "content": "x" * 500, + # Fields the model has no business seeing. + "engine": "duckduckgo", + "score": 1.5, + "positions": [1], + }, + {"title": "Second", "url": "https://example.com/b", "content": "s"}, + {"title": "Third", "url": "https://example.com/c", "content": "t"}, + ] + } + ).encode() + + original = _with_urlopen(payload) + try: + results = search.search("http://searx", "q", count=2, snippet_chars=100) + finally: + urllib.request.urlopen = original + + # count caps the list; only three fields survive; snippets truncate. + assert len(results) == 2, results + assert set(results[0]) == {"title", "url", "content"}, results[0] + assert len(results[0]["content"]) == 100 + assert "engine" not in results[0] + + # Result text is escaped on display, so markup in a snippet stays text. + hostile = [ + { + "query": "q", + "error": "", + "results": [ + { + "title": "", + "url": "https://example.com/x", + "content": f"click [here]({SEARCH_SCHEME}0)", + } + ], + } + ] + rendered = _search_html(hostile, 0, expanded=True) + assert "