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:
2026-10-07 00:09:46 +08:00
parent 19ab86f590
commit 2bb6e06171
9 changed files with 348 additions and 56 deletions
+27 -11
View File
@@ -15,6 +15,7 @@ if TYPE_CHECKING:
from plyngent.agent.tools import ToolDefinition
from plyngent.agent.types import AnyLLMClient
from plyngent.tools.net.search import SearchBackend
from plyngent.cli.models_source import (
DEFAULT_MODELS_CACHE_TTL,
client_supports_models,
@@ -416,9 +417,8 @@ class ReplState:
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`).
The sources come from ``provider.search_sources``, in fallback order
(see :func:`~plyngent.config.routing.local_web_search`).
"""
from plyngent.config.routing import (
deepseek_search_base_url,
@@ -426,18 +426,34 @@ class ReplState:
)
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
from plyngent.tools.net.bing import BingSearchBackend
from plyngent.tools.net.search import (
DeepseekSearchBackend,
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)
backends: list[SearchBackend] = []
for source in provider.search_sources:
if source == "deepseek":
backends.append(
DeepseekSearchBackend(
DeepseekSearchClient(
AnthropicConfig(
api_key=provider.access_key_or_token,
base_url=deepseek_search_base_url(provider),
)
),
model=self.model,
)
)
elif source == "bing":
backends.append(BingSearchBackend())
if not backends:
return None
return build_web_search_tool(backends)
def _mcp_instructions_text(self) -> str:
"""Compose connected MCP servers' initialize ``instructions`` into one block.
+18 -1
View File
@@ -9,6 +9,8 @@ from msgspec import Struct, field
# - "anthropic" → Anthropic Messages API (``POST /messages`` on
# ``https://api.deepseek.com/anthropic``)
type DeepSeekConvention = Literal["", "openai", "anthropic", "responses"]
# Sources the local ``web_search`` tool can use (see ``SearchBackend``).
type SearchSource = Literal["deepseek", "bing"]
# Static directory pre-allow mode for ``[agent].allow_paths`` (path → mode).
type AllowPathMode = Literal["read", "write", "exec"]
@@ -321,6 +323,9 @@ class ProviderConfig(Struct, tag_field="preset", omit_defaults=True):
# Thinking strength for every model of this provider ("" / 0 = inherit [agent]).
reasoning_effort: ReasoningEffortConfig = ""
thinking_budget_tokens: int = 0
# Sources the local ``web_search`` tool tries, in fallback order (``[]``
# disables the tool; see ``config.routing.local_web_search``).
search_sources: list[SearchSource] = field(default_factory=list)
def _default_openai_models() -> dict[str, ModelConfig]:
@@ -371,6 +376,15 @@ def _default_deepseek_models() -> dict[str, ModelConfig]:
}
def _default_deepseek_search_sources() -> list[SearchSource]:
"""Sources for the local ``web_search`` tool, in fallback order.
DeepSeek's own index first (it is the provider's own search), Bing's RSS as
the backstop when a search fails or finds nothing.
"""
return ["deepseek", "bing"]
class DeepseekProvider(ProviderConfig, tag="deepseek"):
"""DeepSeek API provider.
@@ -383,7 +397,9 @@ class DeepseekProvider(ProviderConfig, tag="deepseek"):
``provider_tools`` asks for a search tool like OpenAI's does (``[]``
disables it). DeepSeek runs a hosted search only on the Anthropic surface, so
the other conventions get a local ``web_search`` tool instead (see
``config.routing.local_web_search``).
``config.routing.local_web_search``); ``search_sources`` names what that tool
searches with, in fallback order — ``["deepseek", "bing"]`` by default,
``[]`` to not offer it.
``extras`` keeps arbitrary provider keys; the legacy ``extras.convention``
key is parsed but ignored (prefer the typed ``convention`` field).
@@ -392,6 +408,7 @@ class DeepseekProvider(ProviderConfig, tag="deepseek"):
models: dict[str, ModelConfig] = field(default_factory=_default_deepseek_models)
convention: DeepSeekConvention = ""
provider_tools: list[dict[str, Any]] = field(default_factory=_default_provider_tools)
search_sources: list[SearchSource] = field(default_factory=_default_deepseek_search_sources)
extras: dict[str, str] = field(default_factory=dict)
+12 -5
View File
@@ -33,6 +33,8 @@ class EffectiveProvider:
model: str | None
timeout: float | HttpTimeoutConfig | None
provider_tools: list[dict[str, object]]
# Sources the local ``web_search`` tool tries, in fallback order.
search_sources: list[str]
source: Provider
# DeepSeek API surface (see DeepSeekConvention): "openai" / "anthropic" /
# "responses" for deepseek presets; "" (unused) for other presets.
@@ -101,11 +103,13 @@ def local_web_search(effective: EffectiveProvider) -> bool:
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.
:func:`~plyngent.tools.net.search.build_web_search_tool`, which asks the
sources named by ``search_sources`` behind the scenes.
"""
if effective.preset != "deepseek" or effective.convention == "anthropic":
return False
if not effective.search_sources:
return False
return wants_web_search(effective)
@@ -147,13 +151,15 @@ def resolve_effective_provider(provider: Provider, *, model: str | None = None)
else:
url = default_url_for_preset(preset)
# Hosted tools belong to the preset that declares them: OpenAI's Responses
# surface, and DeepSeek's (whose Anthropic surface runs the search inside the
# response while its other surfaces get a local tool — see
# Hosted tools and search sources belong to the preset that declares them:
# OpenAI's Responses surface, and DeepSeek's (whose Anthropic surface runs the
# search inside the response while its other surfaces get a local tool — see
# :func:`local_web_search`).
provider_tools: list[dict[str, object]] = []
search_sources: list[str] = []
if parent_preset == preset and isinstance(provider, OpenAIProvider | DeepseekProvider):
provider_tools = [dict(item) for item in provider.provider_tools]
search_sources = [str(item) for item in provider.search_sources]
return EffectiveProvider(
preset=preset,
@@ -162,6 +168,7 @@ def resolve_effective_provider(provider: Provider, *, model: str | None = None)
model=model,
timeout=provider.timeout,
provider_tools=provider_tools,
search_sources=search_sources,
source=provider,
convention=convention,
)
@@ -34,10 +34,16 @@ _SEARCH_TOOL: dict[str, Any] = {"type": "web_search_20250305", "name": "web_sear
class SearchHit(msgspec.Struct, frozen=True, omit_defaults=True):
"""One page the search index matched."""
"""One page a search source matched.
DeepSeek's index keeps the page bodies server-side, so its hits carry a
title and a URL only; a source that ships snippets (Bing's RSS) fills
``snippet`` too.
"""
title: str = ""
url: str = ""
snippet: str = ""
def hits_from_content(content: object) -> list[SearchHit]:
+66
View File
@@ -0,0 +1,66 @@
"""Bing's RSS endpoint as a search source for the local ``web_search`` tool.
Bing answers a query with a plain RSS document (``?format=rss``): no key, no
page scraping, and each ``<item>`` already carries a title, a link and a
description (the snippet DeepSeek's own index does not hand out). The request
goes through the fetch client the ``fetch`` tool uses, so the SSRF and
private-host grant policy applies to it as well.
"""
from __future__ import annotations
import urllib.parse
import xml.etree.ElementTree as ET
from plyngent.lmproto.deepseek.anthropic.search import SearchHit
from plyngent.tools.net.client import http_fetch
SEARCH_URL = "https://www.bing.com/search"
DEFAULT_TIMEOUT_SECONDS = 30.0
DEFAULT_MAX_BYTES = 2_000_000
_HTTP_ERROR = 400
def parse_bing_rss(document: str) -> list[SearchHit]:
"""Map Bing's RSS items to hits (title, link, description as the snippet)."""
try:
root = ET.fromstring(document)
except ET.ParseError as exc:
msg = f"bing search returned unparseable XML: {exc}"
raise RuntimeError(msg) from exc
hits: list[SearchHit] = []
for item in root.iter("item"):
title = (item.findtext("title") or "").strip()
url = (item.findtext("link") or "").strip()
if not url:
continue
hits.append(SearchHit(title=title, url=url, snippet=(item.findtext("description") or "").strip()))
return hits
class BingSearchBackend:
"""Search through Bing's RSS results (no key, snippets included)."""
timeout_seconds: float
def __init__(self, *, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS) -> None:
self.timeout_seconds = timeout_seconds
async def search(self, query: str) -> list[SearchHit]:
"""Run *query* against Bing and return the items it answered with."""
query_string = urllib.parse.urlencode({"format": "rss", "q": query})
result = await http_fetch(
method="GET",
url=f"{SEARCH_URL}?{query_string}",
headers={},
body=None,
timeout_seconds=self.timeout_seconds,
max_bytes=DEFAULT_MAX_BYTES,
)
if result.status >= _HTTP_ERROR:
msg = f"bing search HTTP {result.status}"
raise RuntimeError(msg)
return parse_bing_rss(result.body_text)
__all__ = ["SEARCH_URL", "BingSearchBackend", "parse_bing_rss"]
+78 -15
View File
@@ -3,43 +3,99 @@
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.
model calls it like any other tool and the handler asks the configured search
sources behind the scenes — ``provider.search_sources``, in fallback order, so
the next source is tried when one fails or finds nothing. A hit is a title, a
URL and (when the source has one) a snippet: read a page with ``fetch`` before
relying on its content.
"""
from __future__ import annotations
import inspect
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Protocol
from plyngent.agent.tools import ToolDefinition, ToolTag, schema_from_callable
if TYPE_CHECKING:
from plyngent.lmproto.deepseek.anthropic.search import DeepseekSearchClient
from collections.abc import Sequence
from plyngent.lmproto.deepseek.anthropic.search import (
DeepseekSearchClient,
SearchHit,
)
WEB_SEARCH_TOOL_NAME = "web_search"
DEFAULT_MAX_HITS = 8
class SearchBackend(Protocol):
"""One way to answer a query (``provider.search_sources`` picks the order)."""
async def search(self, query: str) -> Sequence[SearchHit]: ...
class DeepseekSearchBackend:
"""DeepSeek's own index: one server-side search turn per query."""
_client: DeepseekSearchClient
_model: str
def __init__(self, client: DeepseekSearchClient, *, model: str) -> None:
self._client = client
self._model = model
async def search(self, query: str) -> Sequence[SearchHit]:
return await self._client.search(query, model=self._model)
async def search_in_order(
backends: Sequence[SearchBackend],
query: str,
) -> tuple[list[SearchHit], list[str]]:
"""Ask each backend in turn; the first one that answers wins.
Returns ``(hits, failures)`` — a source that raises or finds nothing is
recorded and the next one gets its chance, so a broken or rate-limited
source never takes the tool down with it.
"""
failures: list[str] = []
for backend in backends:
name = type(backend).__name__
try:
hits = list(await backend.search(query))
except Exception as exc: # noqa: BLE001 — a failing source falls through
failures.append(f"{name}: {exc}")
continue
if hits:
return hits, failures
failures.append(f"{name}: no results")
return [], failures
def build_web_search_tool(
client: DeepseekSearchClient,
backends: Sequence[SearchBackend],
*,
model: str,
max_hits: int = DEFAULT_MAX_HITS,
) -> ToolDefinition:
"""A ``web_search`` definition over *client* (one per provider/session)."""
"""A ``web_search`` definition over *backends*, tried in order."""
async def web_search(query: str, hits: int = max_hits) -> str:
"""Search the web with DeepSeek and return the pages that matched.
"""Search the web 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.
One entry per hit (``title — url``, plus the snippet when the source has
one): read a page with ``fetch`` before relying on its content.
"""
found = await client.search(query, model=model)
found, failures = await search_in_order(backends, query)
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])
detail = "; ".join(failures) if failures else "no source configured"
return f"error: the search found nothing ({detail})"
lines: list[str] = []
for hit in found[:hits]:
lines.append(f"- {hit.title or '(untitled)'} — {hit.url}")
if hit.snippet:
lines.append(f" {hit.snippet}")
return "\n".join(lines)
return ToolDefinition(
name=WEB_SEARCH_TOOL_NAME,
@@ -50,4 +106,11 @@ def build_web_search_tool(
)
__all__ = ["DEFAULT_MAX_HITS", "WEB_SEARCH_TOOL_NAME", "build_web_search_tool"]
__all__ = [
"DEFAULT_MAX_HITS",
"WEB_SEARCH_TOOL_NAME",
"DeepseekSearchBackend",
"SearchBackend",
"build_web_search_tool",
"search_in_order",
]