mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
core/tools: search a list of sources, DeepSeek's index then Bing
``search_sources`` (a provider list, ``["deepseek", "bing"]`` by default, ``[]`` to not offer the tool at all) names what the local ``web_search`` tool searches with, in fallback order: ``search_in_order`` asks each source in turn and keeps the first answer, so a source that errors — a rate limit, a rejected key — or finds nothing hands over to the next instead of failing the tool. The handler reports the failures only when every source came up empty. The Bing leg answers with its RSS results (``?format=rss``), which need no key and carry a description — the snippet DeepSeek's own index does not hand out — and it goes through the ``fetch`` client, so the SSRF and host-grant policy applies to it too (a host that binds tool instance state, as the CLI does).
This commit is contained in:
@@ -8,28 +8,34 @@ import pytest
|
||||
|
||||
from plyngent.agent.tools import ToolTag
|
||||
from plyngent.lmproto.deepseek.anthropic.search import SearchHit
|
||||
from plyngent.tools.net.search import WEB_SEARCH_TOOL_NAME, build_web_search_tool
|
||||
from plyngent.tools.net.search import (
|
||||
WEB_SEARCH_TOOL_NAME,
|
||||
build_web_search_tool,
|
||||
search_in_order,
|
||||
)
|
||||
|
||||
|
||||
class _StubSearch:
|
||||
"""Stands in for :class:`DeepseekSearchClient`."""
|
||||
class _StubBackend:
|
||||
"""Stands in for a search source (raises, finds nothing, or answers)."""
|
||||
|
||||
def __init__(self, hits: list[SearchHit]) -> None:
|
||||
self.hits = hits
|
||||
self.queries: list[tuple[str, str]] = []
|
||||
def __init__(self, *, hits: list[SearchHit] | None = None, error: str | None = None) -> None:
|
||||
self.hits = hits or []
|
||||
self.error = error
|
||||
self.queries: list[str] = []
|
||||
|
||||
async def search(self, query: str, *, model: str, **kwargs: object) -> list[SearchHit]:
|
||||
self.queries.append((query, model))
|
||||
async def search(self, query: str) -> list[SearchHit]:
|
||||
self.queries.append(query)
|
||||
if self.error is not None:
|
||||
raise RuntimeError(self.error)
|
||||
return self.hits
|
||||
|
||||
|
||||
def _definition(hits: list[SearchHit]) -> tuple[Any, _StubSearch]:
|
||||
client = _StubSearch(hits)
|
||||
return build_web_search_tool(client, model="deepseek-flash"), client
|
||||
def _hits(*pairs: tuple[str, str]) -> list[SearchHit]:
|
||||
return [SearchHit(title=title, url=url) for title, url in pairs]
|
||||
|
||||
|
||||
def test_tool_definition_shape() -> None:
|
||||
definition, _ = _definition([])
|
||||
definition = build_web_search_tool([_StubBackend()])
|
||||
assert definition.name == WEB_SEARCH_TOOL_NAME
|
||||
assert definition.tags == ToolTag.LOCAL | ToolTag.READ_ONLY
|
||||
assert "fetch" in definition.description
|
||||
@@ -39,20 +45,73 @@ def test_tool_definition_shape() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_returns_one_line_per_hit() -> None:
|
||||
definition, client = _definition([SearchHit(title="DeepSeek-V4 preview", url="https://api-docs.deepseek.com/x")])
|
||||
assert await definition.handler(query="DeepSeek-V4") == ("- DeepSeek-V4 preview — https://api-docs.deepseek.com/x")
|
||||
assert client.queries == [("DeepSeek-V4", "deepseek-flash")]
|
||||
async def test_handler_returns_one_entry_per_hit() -> None:
|
||||
backend = _StubBackend(hits=_hits(("DeepSeek-V4 preview", "https://api-docs.deepseek.com/x")))
|
||||
definition = build_web_search_tool([backend])
|
||||
assert await definition.handler(query="DeepSeek-V4") == "- DeepSeek-V4 preview — https://api-docs.deepseek.com/x"
|
||||
assert backend.queries == ["DeepSeek-V4"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_prints_the_snippet_under_its_hit() -> None:
|
||||
backend = _StubBackend(hits=[SearchHit(title="T", url="https://example.com/1", snippet="One line of it.")])
|
||||
definition = build_web_search_tool([backend])
|
||||
assert await definition.handler(query="q") == "- T — https://example.com/1\n One line of it."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_first_source_that_answers_wins() -> None:
|
||||
failing = _StubBackend(error="HTTP 503")
|
||||
empty = _StubBackend()
|
||||
answering = _StubBackend(hits=_hits(("Found", "https://example.com/found")))
|
||||
hits, failures = await search_in_order([failing, empty, answering], "q")
|
||||
assert [hit.url for hit in hits] == ["https://example.com/found"]
|
||||
assert failures == ["_StubBackend: HTTP 503", "_StubBackend: no results"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_caps_the_hits_and_names_an_untitled_page() -> None:
|
||||
definition, _ = _definition([SearchHit(title="", url=f"https://example.com/{index}") for index in range(5)])
|
||||
lines = (await definition.handler(query="anything", hits=2)).splitlines()
|
||||
assert lines == ["- (untitled) — https://example.com/0", "- (untitled) — https://example.com/1"]
|
||||
backend = _StubBackend(hits=_hits(*[("", f"https://example.com/{index}") for index in range(5)]))
|
||||
definition = build_web_search_tool([backend], max_hits=2)
|
||||
assert (await definition.handler(query="anything")).splitlines() == [
|
||||
"- (untitled) — https://example.com/0",
|
||||
"- (untitled) — https://example.com/1",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_reports_an_empty_search() -> None:
|
||||
definition, _ = _definition([])
|
||||
assert "no results" in await definition.handler(query="nothing matches this")
|
||||
async def test_handler_reports_every_source_that_failed() -> None:
|
||||
definition = build_web_search_tool([_StubBackend(error="timeout"), _StubBackend()])
|
||||
message = await definition.handler(query="nothing matches this")
|
||||
assert "found nothing" in message
|
||||
assert "timeout" in message and "no results" in message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_without_sources_says_so() -> None:
|
||||
definition = build_web_search_tool([])
|
||||
assert "no source configured" in await definition.handler(query="q")
|
||||
|
||||
|
||||
def test_deepseek_backend_passes_the_model_through() -> None:
|
||||
from plyngent.tools.net.search import DeepseekSearchBackend
|
||||
|
||||
class _Client:
|
||||
def __init__(self) -> None:
|
||||
self.seen: list[tuple[str, str]] = []
|
||||
|
||||
async def search(self, query: str, *, model: str, **kwargs: object) -> list[SearchHit]:
|
||||
self.seen.append((query, model))
|
||||
return _hits(("T", "https://example.com"))
|
||||
|
||||
client: Any = _Client()
|
||||
backend = DeepseekSearchBackend(client, model="deepseek-flash")
|
||||
|
||||
async def run() -> list[SearchHit]:
|
||||
return list(await backend.search("q"))
|
||||
|
||||
import asyncio
|
||||
|
||||
hits = asyncio.run(run())
|
||||
assert [hit.url for hit in hits] == ["https://example.com"]
|
||||
assert client.seen == [("q", "deepseek-flash")]
|
||||
|
||||
Reference in New Issue
Block a user