From 97d9c6ed49cd6245497094037977d73c6563fb93 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Fri, 21 Aug 2026 10:09:25 +0300 Subject: [PATCH] Return "No results found." and accumulate repeated search results --- CHANGELOG.md | 5 +++ .../haiku/rag/capabilities/_base.py | 10 ++++- .../haiku/rag/capabilities/_tools.py | 20 ++++++++- .../haiku/rag/capabilities/analysis.py | 12 +++--- tests/capabilities/test_capabilities.py | 43 +++++++++++++++++++ 5 files changed, 80 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8500c89..4cdbf5c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog ## [Unreleased] +### Fixed + +- A capability search that matches nothing returns `No results found.` instead of an empty string. +- Repeating a search query within a question accumulates the results of both calls instead of replacing the earlier ones. + ## [0.76.0] - 2026-08-20 ### Added diff --git a/haiku_rag_slim/haiku/rag/capabilities/_base.py b/haiku_rag_slim/haiku/rag/capabilities/_base.py index b359bbc8..b486de63 100644 --- a/haiku_rag_slim/haiku/rag/capabilities/_base.py +++ b/haiku_rag_slim/haiku/rag/capabilities/_base.py @@ -27,7 +27,11 @@ from pydantic_ai.run import AgentRunResult from pydantic_ai.tools import ToolDefinition from pydantic_ai.toolsets import AgentToolset -from haiku.rag.capabilities._tools import CodeExecutionEntry, search_corpus +from haiku.rag.capabilities._tools import ( + CodeExecutionEntry, + merge_results, + search_corpus, +) from haiku.rag.capabilities.ledger import CapabilityEvidenceRecord, EvidenceRef from haiku.rag.client import HaikuRAG from haiku.rag.config.models import AppConfig @@ -466,7 +470,9 @@ class RAGCapabilityBase[StateT: EvidenceState](AbstractCapability[Any]): document_filter=self.state.document_filter, ) state = self.state - state.searches[query] = results + # A model can search the same query twice with different limits, and the + # narrower return must not drop what the wider one already showed it. + merge_results(state.searches.setdefault(query, []), results) self._note_evidence() if self.vision and (parts := build_image_content_from_results(results)): return ToolReturn(return_value=formatted, content=parts) diff --git a/haiku_rag_slim/haiku/rag/capabilities/_tools.py b/haiku_rag_slim/haiku/rag/capabilities/_tools.py index 4b512479..da3e825d 100644 --- a/haiku_rag_slim/haiku/rag/capabilities/_tools.py +++ b/haiku_rag_slim/haiku/rag/capabilities/_tools.py @@ -1,3 +1,5 @@ +from collections.abc import Iterable + from pydantic import BaseModel from haiku.rag.client import HaikuRAG @@ -24,10 +26,26 @@ async def search_corpus( result.format_for_agent(rank=index + 1, total=len(results)) for index, result in enumerate(results) ) - return formatted, list(results) + return formatted or "No results found.", list(results) + + +def merge_results( + existing: list[SearchResult], incoming: Iterable[SearchResult] +) -> None: + """Add the results not already held. + + Identity is the chunk id, which every stored chunk carries; results built by + hand without one cannot be told apart and collapse to the first. + """ + seen = {result.chunk_id for result in existing} + for result in incoming: + if result.chunk_id not in seen: + existing.append(result) + seen.add(result.chunk_id) __all__ = [ "CodeExecutionEntry", + "merge_results", "search_corpus", ] diff --git a/haiku_rag_slim/haiku/rag/capabilities/analysis.py b/haiku_rag_slim/haiku/rag/capabilities/analysis.py index 0b3c860c..843f208c 100644 --- a/haiku_rag_slim/haiku/rag/capabilities/analysis.py +++ b/haiku_rag_slim/haiku/rag/capabilities/analysis.py @@ -17,6 +17,7 @@ from haiku.rag.capabilities._base import ( RAGCapabilityBase, resolve_db_path, ) +from haiku.rag.capabilities._tools import merge_results from haiku.rag.config.models import AppConfig from haiku.rag.sandbox import AnalysisContext, Sandbox @@ -112,13 +113,10 @@ class AnalysisCapability(RAGCapabilityBase[AnalysisState]): if result.success or result.stdout: self._note_evidence() if sandbox._search_results: - existing = self.state.searches.get("_sandbox", []) - seen = {item.chunk_id for item in existing} - for item in sandbox._search_results: - if item.chunk_id not in seen: - existing.append(item) - seen.add(item.chunk_id) - self.state.searches["_sandbox"] = existing + merge_results( + self.state.searches.setdefault("_sandbox", []), + sandbox._search_results, + ) self.state.executions.append( CodeExecutionEntry( code=code, diff --git a/tests/capabilities/test_capabilities.py b/tests/capabilities/test_capabilities.py index 3124104d..30cb657e 100644 --- a/tests/capabilities/test_capabilities.py +++ b/tests/capabilities/test_capabilities.py @@ -314,6 +314,49 @@ async def test_a_spent_search_budget_fails_the_tool(temp_db_path): await capability._search("anything", None) +def _stub_client(*batches: list[SearchResult]) -> AsyncMock: + """A client whose successive searches return the given result batches.""" + client = AsyncMock() + client.search.side_effect = list(batches) + client.expand_context.side_effect = lambda results: results + return client + + +@pytest.mark.asyncio +async def test_a_fruitless_search_says_so(temp_db_path): + """A blank tool return reads as a broken tool, not as an empty corpus.""" + capability = create_rag(db_path=temp_db_path, config=AppConfig()) + capability.state = RAGState() + capability.borrowed_rag = _stub_client([]) + + assert await capability._search("nothing about this", None) == "No results found." + + +@pytest.mark.asyncio +async def test_a_narrower_repeat_keeps_what_the_wider_search_returned(temp_db_path): + """One query, two limits: the model can still cite the results it was shown.""" + capability = create_rag(db_path=temp_db_path, config=AppConfig()) + capability.state = RAGState() + capability.borrowed_rag = _stub_client( + [ + SearchResult(content="first", score=1.0, chunk_id="chunk-1"), + SearchResult(content="second", score=0.9, chunk_id="chunk-2"), + SearchResult(content="third", score=0.8, chunk_id="chunk-3"), + ], + [SearchResult(content="first", score=1.0, chunk_id="chunk-1")], + ) + + await capability._search("Figure 3-1", 20) + await capability._search("Figure 3-1", None) + + stored = capability.state.searches["Figure 3-1"] + assert [result.chunk_id for result in stored] == [ + "chunk-1", + "chunk-2", + "chunk-3", + ] + + @pytest.mark.asyncio async def test_cite_resolves_direct_chunk_ids_and_reuses_document_lookup(temp_db_path): capability = create_rag(db_path=temp_db_path, config=AppConfig())