core/tools: search through a local tool where a hosted one is ignored

Only DeepSeek's Anthropic-compatible surface runs a hosted ``web_search``: the
Responses and chat-completions surfaces answer "I can't browse the web" with
``{"type": "web_search"}`` and with the server-tool shape alike, so a DeepSeek
provider that talks them had no search at all.

``config.routing.local_web_search`` decides per provider: a DeepSeek provider
whose ``provider_tools`` ask for ``web_search`` and whose convention is not
``anthropic`` gets the local tool (``tools/net/search.py``), which asks the
search-capable endpoint behind the scenes and hands the model one ``title —
url`` line per hit — read a page with ``fetch``, the bodies stay server-side.
DeepSeek's preset asks for no hosted tool by default, so this stays opt-in.
This commit is contained in:
2026-10-06 23:45:35 +08:00
parent e2ee937d8f
commit a0d39bba6d
5 changed files with 228 additions and 1 deletions
+32 -1
View File
@@ -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.
+31
View File
@@ -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*.
+53
View File
@@ -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"]