mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-08 16:37:55 +08:00
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:
@@ -13,6 +13,7 @@ from plyngent.agent.todo_stack import TodoStack
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from plyngent.agent.tools import ToolDefinition
|
||||||
from plyngent.agent.types import AnyLLMClient
|
from plyngent.agent.types import AnyLLMClient
|
||||||
from plyngent.cli.models_source import (
|
from plyngent.cli.models_source import (
|
||||||
DEFAULT_MODELS_CACHE_TTL,
|
DEFAULT_MODELS_CACHE_TTL,
|
||||||
@@ -398,7 +399,7 @@ class ReplState:
|
|||||||
tools = catalog.select(surface="local", exclude_names=excluded)
|
tools = catalog.select(surface="local", exclude_names=excluded)
|
||||||
yolo = self.effective_yolo() != "off"
|
yolo = self.effective_yolo() != "off"
|
||||||
# Always attach soft-confirm path so non-YOLO tools still prompt under YOLO mode.
|
# Always attach soft-confirm path so non-YOLO tools still prompt under YOLO mode.
|
||||||
return ToolRegistry(
|
registry = ToolRegistry(
|
||||||
tools,
|
tools,
|
||||||
danger=classify_danger,
|
danger=classify_danger,
|
||||||
on_confirm=prompt_confirm_tool_async,
|
on_confirm=prompt_confirm_tool_async,
|
||||||
@@ -407,6 +408,36 @@ class ReplState:
|
|||||||
instance_state=self.instance_state,
|
instance_state=self.instance_state,
|
||||||
session_state=self.session_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:
|
def _mcp_instructions_text(self) -> str:
|
||||||
"""Compose connected MCP servers' initialize ``instructions`` into one block.
|
"""Compose connected MCP servers' initialize ``instructions`` into one block.
|
||||||
|
|||||||
@@ -86,6 +86,37 @@ def model_config_for(provider: Provider, model: str | None) -> ModelConfig | Non
|
|||||||
return provider.models.get(model)
|
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:
|
def resolve_effective_provider(provider: Provider, *, model: str | None = None) -> EffectiveProvider:
|
||||||
"""Apply model-level preset/url overrides to *provider*.
|
"""Apply model-level preset/url overrides to *provider*.
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user