diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index 63d8dfa..eb3fb6a 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -13,6 +13,7 @@ from plyngent.agent.todo_stack import TodoStack if TYPE_CHECKING: from datetime import datetime + from plyngent.agent.tools import ToolDefinition from plyngent.agent.types import AnyLLMClient from plyngent.cli.models_source import ( DEFAULT_MODELS_CACHE_TTL, @@ -398,7 +399,7 @@ class ReplState: tools = catalog.select(surface="local", exclude_names=excluded) yolo = self.effective_yolo() != "off" # Always attach soft-confirm path so non-YOLO tools still prompt under YOLO mode. - return ToolRegistry( + registry = ToolRegistry( tools, danger=classify_danger, on_confirm=prompt_confirm_tool_async, @@ -407,6 +408,36 @@ class ReplState: instance_state=self.instance_state, session_state=self.session_state, ) + search_tool = self._web_search_tool() + if search_tool is not None: + registry.register(search_tool) + return registry + + def _web_search_tool(self) -> ToolDefinition | None: + """Local ``web_search`` for a provider whose surface cannot host one. + + DeepSeek searches only on its Anthropic-compatible surface, so the other + conventions get the tool instead of a hosted definition (see + :func:`~plyngent.config.routing.local_web_search`). + """ + from plyngent.config.routing import ( + deepseek_search_base_url, + local_web_search, + ) + from plyngent.lmproto.anthropic.config import AnthropicConfig + from plyngent.lmproto.deepseek.anthropic.search import DeepseekSearchClient + from plyngent.tools.net.search import build_web_search_tool + + provider = self.effective_provider + if not local_web_search(provider) or not self.model: + return None + client = DeepseekSearchClient( + AnthropicConfig( + api_key=provider.access_key_or_token, + base_url=deepseek_search_base_url(provider), + ) + ) + return build_web_search_tool(client, model=self.model) def _mcp_instructions_text(self) -> str: """Compose connected MCP servers' initialize ``instructions`` into one block. diff --git a/src/plyngent/config/routing.py b/src/plyngent/config/routing.py index d7f7878..6631771 100644 --- a/src/plyngent/config/routing.py +++ b/src/plyngent/config/routing.py @@ -86,6 +86,37 @@ def model_config_for(provider: Provider, model: str | None) -> ModelConfig | Non return provider.models.get(model) +def wants_web_search(effective: EffectiveProvider) -> bool: + """True when *effective*'s ``provider_tools`` ask for a search tool.""" + return any( + isinstance(tool.get("type"), str) and tool.get("type") == "web_search" for tool in effective.provider_tools + ) + + +def local_web_search(effective: EffectiveProvider) -> bool: + """True when this provider searches through plyngent's own ``web_search`` tool. + + DeepSeek runs a hosted search only on its Anthropic-compatible surface + (``convention = "anthropic"``): the Responses and chat-completions surfaces + ignore a hosted tool — a live probe answers "I can't browse the web" with + either ``{"type": "web_search"}`` or the server-tool shape — so a DeepSeek + provider that talks them searches through + :func:`~plyngent.tools.net.search.build_web_search_tool`, which calls the + search-capable endpoint behind the scenes. + """ + if effective.preset != "deepseek" or effective.convention == "anthropic": + return False + return wants_web_search(effective) + + +def deepseek_search_base_url(effective: EffectiveProvider) -> str: + """The endpoint a DeepSeek search turn runs on (its Anthropic-compatible base).""" + url = effective.url.rstrip("/") + if url.endswith("/anthropic"): + return url + return DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL + + def resolve_effective_provider(provider: Provider, *, model: str | None = None) -> EffectiveProvider: """Apply model-level preset/url overrides to *provider*. diff --git a/src/plyngent/tools/net/search.py b/src/plyngent/tools/net/search.py new file mode 100644 index 0000000..eb1d4ee --- /dev/null +++ b/src/plyngent/tools/net/search.py @@ -0,0 +1,53 @@ +"""``web_search``: the model's way to search when its surface cannot host one. + +DeepSeek only searches on its Anthropic-compatible surface, so a DeepSeek +provider that talks the Responses or chat-completions surface gets this local +tool instead of a hosted one (see ``config.routing.local_web_search``): the +handler asks the search-capable endpoint behind the scenes and hands the model +the pages it matched. The index keeps the page bodies, so a hit is a title plus a +URL — read one with ``fetch`` before relying on it. +""" + +from __future__ import annotations + +import inspect +from typing import TYPE_CHECKING + +from plyngent.agent.tools import ToolDefinition, ToolTag, schema_from_callable + +if TYPE_CHECKING: + from plyngent.lmproto.deepseek.anthropic.search import DeepseekSearchClient + +WEB_SEARCH_TOOL_NAME = "web_search" +DEFAULT_MAX_HITS = 8 + + +def build_web_search_tool( + client: DeepseekSearchClient, + *, + model: str, + max_hits: int = DEFAULT_MAX_HITS, +) -> ToolDefinition: + """A ``web_search`` definition over *client* (one per provider/session).""" + + async def web_search(query: str, hits: int = max_hits) -> str: + """Search the web with DeepSeek and return the pages that matched. + + One line per hit (``title — url``, the search index keeps the page bodies + on its side): read a page with ``fetch`` before relying on its content. + """ + found = await client.search(query, model=model) + if not found: + return "error: the search returned no results" + return "\n".join(f"- {hit.title or '(untitled)'} — {hit.url}" for hit in found[:hits]) + + return ToolDefinition( + name=WEB_SEARCH_TOOL_NAME, + description=inspect.getdoc(web_search) or "", + parameters=schema_from_callable(web_search), + handler=web_search, + tags=ToolTag.LOCAL | ToolTag.READ_ONLY, + ) + + +__all__ = ["DEFAULT_MAX_HITS", "WEB_SEARCH_TOOL_NAME", "build_web_search_tool"] diff --git a/tests/test_config/test_routing.py b/tests/test_config/test_routing.py new file mode 100644 index 0000000..f48685f --- /dev/null +++ b/tests/test_config/test_routing.py @@ -0,0 +1,54 @@ +"""The routing decisions around a provider's API surface.""" + +from __future__ import annotations + +from dataclasses import replace + +from plyngent.config.models import DeepseekProvider +from plyngent.config.routing import ( + EffectiveProvider, + deepseek_search_base_url, + local_web_search, + resolve_effective_provider, + wants_web_search, +) + +SEARCH_TOOLS: list[dict[str, object]] = [{"type": "web_search"}] + + +def _effective( + *, + convention: str = "", + url: str = "", + tools: list[dict[str, object]] | None = None, +) -> EffectiveProvider: + provider = DeepseekProvider(access_key_or_token="sk-test", convention=convention, url=url) + effective = resolve_effective_provider(provider, model="deepseek-flash") + return effective if tools is None else replace(effective, provider_tools=tools) + + +def test_a_deepseek_provider_asks_for_no_hosted_tool_by_default() -> None: + # OpenAI's preset defaults ``provider_tools`` to web_search; DeepSeek's does + # not, so search stays opt-in there (``provider_tools = [{type = "web_search"}]``). + assert wants_web_search(_effective()) is False + assert wants_web_search(_effective(tools=SEARCH_TOOLS)) is True + + +def test_chat_and_responses_conventions_use_the_local_tool() -> None: + # Only the Anthropic surface hosts a search; the others ignore a hosted tool, + # so an asked-for web_search becomes plyngent's own tool there. + assert local_web_search(_effective(convention="", tools=SEARCH_TOOLS)) is True + assert local_web_search(_effective(convention="responses", tools=SEARCH_TOOLS)) is True + assert local_web_search(_effective(convention="anthropic", tools=SEARCH_TOOLS)) is False + + +def test_without_provider_tools_there_is_no_search() -> None: + assert local_web_search(_effective(convention="responses", tools=[])) is False + + +def test_search_base_url_prefers_an_anthropic_endpoint() -> None: + assert deepseek_search_base_url(_effective(convention="responses")) == "https://api.deepseek.com/anthropic" + assert ( + deepseek_search_base_url(_effective(convention="responses", url="https://gateway.example/anthropic")) + == "https://gateway.example/anthropic" + ) diff --git a/tests/test_tools/test_search.py b/tests/test_tools/test_search.py new file mode 100644 index 0000000..7df300c --- /dev/null +++ b/tests/test_tools/test_search.py @@ -0,0 +1,58 @@ +"""The local ``web_search`` tool DeepSeek uses where its surface cannot host one.""" + +from __future__ import annotations + +from typing import Any + +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 + + +class _StubSearch: + """Stands in for :class:`DeepseekSearchClient`.""" + + def __init__(self, hits: list[SearchHit]) -> None: + self.hits = hits + self.queries: list[tuple[str, str]] = [] + + async def search(self, query: str, *, model: str, **kwargs: object) -> list[SearchHit]: + self.queries.append((query, model)) + 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 test_tool_definition_shape() -> None: + definition, _ = _definition([]) + assert definition.name == WEB_SEARCH_TOOL_NAME + assert definition.tags == ToolTag.LOCAL | ToolTag.READ_ONLY + assert "fetch" in definition.description + parameters = definition.parameters + assert parameters["properties"]["query"] == {"type": "string"} + assert parameters["required"] == ["query"] + + +@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")] + + +@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"] + + +@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")