Merge pull request #575 from ggozad/fix/capability-search-results

Return "No results found." and accumulate repeated search results
This commit is contained in:
Yiorgis Gozadinos 2026-08-21 10:20:47 +03:00 committed by GitHub
commit 88925e86d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 80 additions and 10 deletions

View file

@ -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

View file

@ -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)

View file

@ -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",
]

View file

@ -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,

View file

@ -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())