chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
"""
|
||||
Tests for ReAct agent dual-source routing (issue #283).
|
||||
Covers: prompt adaptation, web fallback, visit_page errors, routing, and pipeline.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from leann.api import SearchResult
|
||||
from leann.react_agent import ReActAgent
|
||||
from leann.web_search import WebSearcher
|
||||
|
||||
|
||||
def _make_searcher() -> MagicMock:
|
||||
"""Create a lightweight mocked searcher for deterministic unit tests."""
|
||||
searcher = MagicMock()
|
||||
searcher.search.return_value = [
|
||||
SearchResult(
|
||||
id="1",
|
||||
score=0.91,
|
||||
text="LEANN achieves 97% storage reduction via graph pruning.",
|
||||
metadata={"source": "docs"},
|
||||
),
|
||||
SearchResult(
|
||||
id="2",
|
||||
score=0.83,
|
||||
text="The search function uses HNSW for approximate nearest neighbors.",
|
||||
metadata={"source": "code"},
|
||||
),
|
||||
]
|
||||
return searcher
|
||||
|
||||
|
||||
# ── 1. Dual-source presence ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_prompt_includes_web_tools_when_key_present():
|
||||
"""When SERPER_API_KEY is set, prompt should list all three tools."""
|
||||
searcher = _make_searcher()
|
||||
agent = ReActAgent(searcher=searcher, llm=MagicMock(), serper_api_key="test-key")
|
||||
prompt = agent._create_react_prompt("test question", 1, [])
|
||||
assert "web_search" in prompt
|
||||
assert "visit_page" in prompt
|
||||
assert "leann_search" in prompt
|
||||
assert agent.web_search_available is True
|
||||
|
||||
|
||||
def test_prompt_excludes_web_tools_when_no_key():
|
||||
"""When no SERPER_API_KEY, prompt should only show leann_search."""
|
||||
searcher = _make_searcher()
|
||||
with patch.dict("os.environ", {}, clear=False):
|
||||
agent = ReActAgent(searcher=searcher, llm=MagicMock(), serper_api_key=None)
|
||||
prompt = agent._create_react_prompt("test question", 1, [])
|
||||
assert "leann_search" in prompt
|
||||
assert "web_search" not in prompt or "not available" in prompt
|
||||
assert agent.web_search_available is False
|
||||
|
||||
|
||||
# ── 2. Routing behavior ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_local_only_routing():
|
||||
"""Agent uses leann_search for local queries."""
|
||||
searcher = _make_searcher()
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.ask.side_effect = [
|
||||
'Thought: This is about our codebase.\nAction: leann_search("LEANN storage reduction")',
|
||||
"Thought: I have the answer.\nAction: Final Answer: LEANN saves 97% storage.",
|
||||
]
|
||||
agent = ReActAgent(searcher=searcher, llm=mock_llm, max_iterations=3)
|
||||
agent.run("How does LEANN reduce storage?", top_k=2)
|
||||
|
||||
assert len(agent.search_history) >= 1
|
||||
assert agent.search_history[0]["source"] == "local"
|
||||
assert "leann_search" in agent.search_history[0]["action"]
|
||||
|
||||
|
||||
def test_web_only_routing():
|
||||
"""Agent uses web_search for web queries (mocked)."""
|
||||
searcher = _make_searcher()
|
||||
|
||||
with patch.object(WebSearcher, "search") as mock_web:
|
||||
mock_web.return_value = [
|
||||
{
|
||||
"title": "Python 3.13 News",
|
||||
"link": "https://python.org",
|
||||
"snippet": "New features in 3.13",
|
||||
}
|
||||
]
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.ask.side_effect = [
|
||||
'Thought: Need current web info.\nAction: web_search("Python 3.13 features")',
|
||||
"Thought: Got it.\nAction: Final Answer: Python 3.13 has new features.",
|
||||
]
|
||||
agent = ReActAgent(
|
||||
searcher=searcher, llm=mock_llm, max_iterations=3, serper_api_key="test-key"
|
||||
)
|
||||
agent.run("What's new in Python 3.13?", top_k=2)
|
||||
|
||||
assert len(agent.search_history) >= 1
|
||||
assert agent.search_history[0]["source"] == "web"
|
||||
mock_web.assert_called_once()
|
||||
|
||||
|
||||
def test_mixed_routing_local_then_web():
|
||||
"""Agent uses leann_search first, then web_search."""
|
||||
searcher = _make_searcher()
|
||||
|
||||
with patch.object(WebSearcher, "search") as mock_web:
|
||||
mock_web.return_value = [
|
||||
{
|
||||
"title": "Best Practices",
|
||||
"link": "https://example.com",
|
||||
"snippet": "Current best practices...",
|
||||
}
|
||||
]
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.ask.side_effect = [
|
||||
'Thought: First check our code.\nAction: leann_search("search implementation")',
|
||||
'Thought: Now check best practices.\nAction: web_search("vector DB best practices")',
|
||||
"Thought: I can compare now.\nAction: Final Answer: Our search is good but could improve.",
|
||||
]
|
||||
agent = ReActAgent(
|
||||
searcher=searcher, llm=mock_llm, max_iterations=5, serper_api_key="test-key"
|
||||
)
|
||||
agent.run("Compare our search with best practices", top_k=2)
|
||||
|
||||
assert len(agent.search_history) >= 2
|
||||
assert agent.search_history[0]["source"] == "local"
|
||||
assert agent.search_history[1]["source"] == "web"
|
||||
|
||||
|
||||
# ── 3. Pipeline correctness ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_web_results_formatted_as_observations():
|
||||
"""Web search results are properly formatted and passed to the next iteration."""
|
||||
searcher = _make_searcher()
|
||||
|
||||
with patch.object(WebSearcher, "search") as mock_web:
|
||||
mock_web.return_value = [
|
||||
{"title": "Result A", "link": "https://a.com", "snippet": "Snippet A"},
|
||||
{"title": "Result B", "link": "https://b.com", "snippet": "Snippet B"},
|
||||
]
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.ask.side_effect = [
|
||||
'Thought: Search the web.\nAction: web_search("test query")',
|
||||
"Thought: Done.\nAction: Final Answer: Found it.",
|
||||
]
|
||||
agent = ReActAgent(
|
||||
searcher=searcher, llm=mock_llm, max_iterations=3, serper_api_key="test-key"
|
||||
)
|
||||
agent.run("test", top_k=2)
|
||||
|
||||
# The second LLM call should contain the formatted web results
|
||||
second_prompt = mock_llm.ask.call_args_list[1][0][0]
|
||||
assert "Result A" in second_prompt or "Snippet A" in second_prompt
|
||||
|
||||
|
||||
def test_visit_page_content_truncated():
|
||||
"""visit_page content is truncated to 15k chars."""
|
||||
searcher = _make_searcher()
|
||||
|
||||
with patch.object(WebSearcher, "get_page_content") as mock_fetch:
|
||||
mock_fetch.return_value = "x" * 20000
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.ask.side_effect = [
|
||||
'Thought: Read this page.\nAction: visit_page("https://docs.python.org/3")',
|
||||
"Thought: Done.\nAction: Final Answer: Got the content.",
|
||||
]
|
||||
agent = ReActAgent(
|
||||
searcher=searcher, llm=mock_llm, max_iterations=3, serper_api_key="test-key"
|
||||
)
|
||||
agent.run("read docs", top_k=2)
|
||||
|
||||
second_prompt = mock_llm.ask.call_args_list[1][0][0]
|
||||
# Content should be truncated — observation contains at most 15000 chars of content
|
||||
assert "x" * 15000 in second_prompt
|
||||
assert "x" * 15001 not in second_prompt
|
||||
|
||||
|
||||
def test_leann_search_results_include_scores():
|
||||
"""Local search results include scores and text snippets."""
|
||||
searcher = _make_searcher()
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.ask.side_effect = [
|
||||
'Thought: Search locally.\nAction: leann_search("LEANN")',
|
||||
"Thought: Done.\nAction: Final Answer: Found results.",
|
||||
]
|
||||
agent = ReActAgent(searcher=searcher, llm=mock_llm, max_iterations=3)
|
||||
agent.run("tell me about LEANN", top_k=2)
|
||||
|
||||
second_prompt = mock_llm.ask.call_args_list[1][0][0]
|
||||
assert "Score:" in second_prompt
|
||||
assert "Result" in second_prompt
|
||||
|
||||
|
||||
# ── 4. Edge cases ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_web_search_no_api_key_graceful():
|
||||
"""web_search without API key returns clear fallback message, not a crash."""
|
||||
searcher = _make_searcher()
|
||||
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.ask.side_effect = [
|
||||
'Thought: Try web.\nAction: web_search("Python 3.13")',
|
||||
'Thought: Web failed, try local.\nAction: leann_search("search")',
|
||||
"Thought: Done.\nAction: Final Answer: Here's what I found locally.",
|
||||
]
|
||||
with patch.dict("os.environ", {}, clear=False):
|
||||
agent = ReActAgent(searcher=searcher, llm=mock_llm, max_iterations=5, serper_api_key=None)
|
||||
answer = agent.run("test", top_k=2)
|
||||
|
||||
assert agent.search_history[0]["results_count"] == 0
|
||||
assert agent.search_history[0]["source"] == "web"
|
||||
# Agent should not crash and should produce an answer
|
||||
assert answer is not None and len(answer) > 0
|
||||
|
||||
|
||||
def test_web_search_invalid_key_graceful():
|
||||
"""Invalid Serper key returns error, agent continues."""
|
||||
searcher = _make_searcher()
|
||||
|
||||
with patch.object(WebSearcher, "search") as mock_web:
|
||||
mock_web.return_value = [
|
||||
{"title": "Error", "link": "", "snippet": "Web Search failed:401 Unauthorized"}
|
||||
]
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.ask.side_effect = [
|
||||
'Thought: Try web.\nAction: web_search("test")',
|
||||
"Thought: Web failed.\nAction: Final Answer: Could not search web.",
|
||||
]
|
||||
agent = ReActAgent(
|
||||
searcher=searcher, llm=mock_llm, max_iterations=3, serper_api_key="bad-key"
|
||||
)
|
||||
answer = agent.run("test", top_k=2)
|
||||
|
||||
assert agent.search_history[0]["results_count"] == 0
|
||||
assert answer is not None
|
||||
|
||||
|
||||
def test_visit_page_404_graceful():
|
||||
"""visit_page on a 404 URL returns error string, agent continues."""
|
||||
searcher = _make_searcher()
|
||||
|
||||
with patch.object(WebSearcher, "get_page_content") as mock_fetch:
|
||||
mock_fetch.return_value = "Error fetching content: 404 Not Found"
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.ask.side_effect = [
|
||||
'Thought: Read page.\nAction: visit_page("https://example.com/404")',
|
||||
"Thought: Page not found.\nAction: Final Answer: Page was not accessible.",
|
||||
]
|
||||
agent = ReActAgent(
|
||||
searcher=searcher, llm=mock_llm, max_iterations=3, serper_api_key="test-key"
|
||||
)
|
||||
answer = agent.run("read page", top_k=2)
|
||||
|
||||
assert agent.search_history[0]["results_count"] == 0
|
||||
assert "not accessible" in answer.lower() or len(answer) > 0
|
||||
|
||||
|
||||
def test_max_iterations_with_mixed_sources():
|
||||
"""Agent hitting max iterations with mixed sources still produces an answer."""
|
||||
searcher = _make_searcher()
|
||||
|
||||
with patch.object(WebSearcher, "search") as mock_web:
|
||||
mock_web.return_value = [
|
||||
{"title": "Web Result", "link": "https://example.com", "snippet": "Some web info"}
|
||||
]
|
||||
mock_llm = MagicMock()
|
||||
# Never gives a Final Answer — forces max iterations
|
||||
mock_llm.ask.side_effect = [
|
||||
'Thought: Search locally.\nAction: leann_search("storage")',
|
||||
'Thought: Now web.\nAction: web_search("vector databases")',
|
||||
# Max iterations reached — the agent will ask for a final answer
|
||||
"Based on all searches, LEANN is a storage-efficient vector DB.",
|
||||
]
|
||||
agent = ReActAgent(
|
||||
searcher=searcher, llm=mock_llm, max_iterations=2, serper_api_key="test-key"
|
||||
)
|
||||
answer = agent.run("Compare approaches", top_k=2)
|
||||
|
||||
assert len(agent.search_history) == 2
|
||||
sources = {h["source"] for h in agent.search_history}
|
||||
assert "local" in sources
|
||||
assert "web" in sources
|
||||
assert answer is not None and len(answer) > 0
|
||||
|
||||
|
||||
def test_search_history_has_source_field():
|
||||
"""search_history entries include 'source' field for easy inspection."""
|
||||
searcher = _make_searcher()
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.ask.side_effect = [
|
||||
'Thought: Local search.\nAction: leann_search("test")',
|
||||
"Thought: Done.\nAction: Final Answer: Done.",
|
||||
]
|
||||
agent = ReActAgent(searcher=searcher, llm=mock_llm, max_iterations=3)
|
||||
agent.run("test", top_k=2)
|
||||
|
||||
assert "source" in agent.search_history[0]
|
||||
assert agent.search_history[0]["source"] in ("local", "web")
|
||||
|
||||
|
||||
def test_zero_results_on_iteration_two_does_not_abort_early():
|
||||
"""A single empty tool result must not force an ungrounded final answer (#381)."""
|
||||
searcher = _make_searcher()
|
||||
searcher.search.side_effect = [
|
||||
[
|
||||
SearchResult(
|
||||
id="1",
|
||||
score=0.9,
|
||||
text="Partial LEANN benchmark mention.",
|
||||
metadata={"source": "docs"},
|
||||
)
|
||||
],
|
||||
[],
|
||||
[
|
||||
SearchResult(
|
||||
id="2",
|
||||
score=0.95,
|
||||
text="LEANN recall@10 = 0.95 vs FAISS 0.97 per the paper.",
|
||||
metadata={"source": "web"},
|
||||
)
|
||||
],
|
||||
]
|
||||
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.ask.side_effect = [
|
||||
'Thought: Start local.\nAction: leann_search("LEANN benchmark")',
|
||||
'Thought: Narrow local query.\nAction: leann_search("LEANN recall numbers vs faiss")',
|
||||
'Thought: Try web.\nAction: web_search("LEANN paper recall comparison FAISS")',
|
||||
"Thought: Found it.\nAction: Final Answer: LEANN achieves recall@10 = 0.95 vs FAISS 0.97.",
|
||||
]
|
||||
|
||||
with patch.object(WebSearcher, "search") as mock_web:
|
||||
mock_web.return_value = [
|
||||
{
|
||||
"title": "LEANN paper",
|
||||
"link": "https://example.com/paper",
|
||||
"snippet": "recall@10 comparison",
|
||||
}
|
||||
]
|
||||
agent = ReActAgent(
|
||||
searcher=searcher,
|
||||
llm=mock_llm,
|
||||
max_iterations=5,
|
||||
serper_api_key="test-key",
|
||||
)
|
||||
answer = agent.run("What is LEANN's recall vs FAISS at recall@10?", top_k=2)
|
||||
|
||||
assert len(agent.search_history) == 3
|
||||
assert agent.search_history[1]["results_count"] == 0
|
||||
assert "0.95" in answer
|
||||
assert mock_llm.ask.call_count == 4
|
||||
|
||||
|
||||
def test_transient_web_failure_allows_retry_on_next_iteration():
|
||||
"""Transient web_search failure should be retried, not terminate the loop (#381)."""
|
||||
searcher = _make_searcher()
|
||||
searcher.search.return_value = [
|
||||
SearchResult(
|
||||
id="1",
|
||||
score=0.8,
|
||||
text="Local setup notes.",
|
||||
metadata={"source": "docs"},
|
||||
)
|
||||
]
|
||||
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.ask.side_effect = [
|
||||
'Thought: Check local docs.\nAction: leann_search("project setup")',
|
||||
'Thought: Check web.\nAction: web_search("latest setup guide")',
|
||||
'Thought: Retry web.\nAction: web_search("setup guide tutorial")',
|
||||
"Thought: Found it.\nAction: Final Answer: per the latest docs, run `make install`.",
|
||||
]
|
||||
|
||||
with patch.object(WebSearcher, "search") as mock_web:
|
||||
mock_web.side_effect = [
|
||||
[{"title": "Error", "link": "", "snippet": "Web Search failed:502 Bad Gateway"}],
|
||||
[
|
||||
{
|
||||
"title": "Setup guide",
|
||||
"link": "https://example.com/setup",
|
||||
"snippet": "Run `make install` to set up the project.",
|
||||
}
|
||||
],
|
||||
]
|
||||
agent = ReActAgent(
|
||||
searcher=searcher,
|
||||
llm=mock_llm,
|
||||
max_iterations=5,
|
||||
serper_api_key="test-key",
|
||||
)
|
||||
answer = agent.run("How do I set up the project?", top_k=2)
|
||||
|
||||
assert len(agent.search_history) == 3
|
||||
assert agent.search_history[1]["results_count"] == 0
|
||||
assert agent.search_history[2]["results_count"] == 1
|
||||
assert "make install" in answer
|
||||
assert mock_llm.ask.call_count == 4
|
||||
Reference in New Issue
Block a user