core/agent: run a hosted search as the surface's own tool

DeepSeek's Anthropic compat runs a hosted ``web_search`` itself and answers with
the blocks that search produced — ``thinking``, ``server_tool_use`` (the query)
and ``web_search_tool_result`` — so the content union that knew only text and
tool_use could not decode a live search turn. It carries them now, ``tools``
accepts the opaque server-tool dicts the surface declares, and
``web_search_tool_result.content`` stays loose on purpose: its
``web_search_result`` entries hold an ``encrypted_content`` the API demands back
unchanged (a trimmed result is answered with 422 "missing field
``encrypted_content``").

``provider_tools`` reached only the Responses surface, and the Anthropic one
wants its own definition of the same intent: a bare ``web_search`` becomes
``{type="web_search_20250305", name="web_search"}`` (the server tool Claude
declares and DeepSeek's compat reads, keeping a ``max_uses`` cap or any other
key), merged after the local function tools.

Such a tool is the server's: it streams ``input_json_delta`` fragments like a
call, and reading them as one asked the registry for a tool the server had
already run. Block types are now tracked, so only a ``tool_use`` block becomes a
tool call; the search blocks and ``thinking`` are skipped by the response
mapping (the answer the model wrote already used them) and never executed.
This commit is contained in:
2026-10-06 23:35:23 +08:00
parent 88b93a8dfe
commit 1fa41c9fde
7 changed files with 376 additions and 18 deletions
+1
View File
@@ -221,6 +221,7 @@ async def _dispatch_chat_completions(
return await dispatch_messages(
cast("AnthropicClient", client),
param,
provider_tools=provider_tools,
stream=stream,
)
msg = f"client kind {kind!r} is not supported by the agent loop"
+48 -6
View File
@@ -18,6 +18,7 @@ from plyngent.lmproto.anthropic.model import (
AnthropicMessageResponse,
AnthropicMessagesParam,
AnthropicResponseText,
AnthropicResponseToolUse,
AnthropicTextContent,
AnthropicThinkingConfig,
AnthropicToolChoice,
@@ -69,11 +70,11 @@ def _encode_tool_arguments(obj: dict[str, Any]) -> str:
def tool_items_to_anthropic_tools(
tools: Sequence[AnyToolItem] | None,
) -> list[AnthropicToolDefinition]:
) -> list[AnthropicToolDefinition | dict[str, Any]]:
"""Map chat ``ToolFunctionItem`` list to Anthropic tool definitions."""
if not tools:
return []
result: list[AnthropicToolDefinition] = []
result: list[AnthropicToolDefinition | dict[str, Any]] = []
for item in tools:
if not isinstance(item, ToolFunctionItem):
continue
@@ -88,6 +89,35 @@ def tool_items_to_anthropic_tools(
return result
# The surface-neutral hosted-tool intent (``provider_tools`` default) as the
# Anthropic server tool it means; DeepSeek's compat and Claude both define the
# search tool this way, and both ignore an OpenAI-shaped ``web_search`` entry.
_ANTHROPIC_WEB_SEARCH_TOOL: dict[str, Any] = {"type": "web_search_20250305", "name": "web_search"}
def provider_tools_to_anthropic_tools(
provider_tools: Sequence[dict[str, Any]] | None,
) -> list[dict[str, Any]]:
"""Map hosted-tool dicts to Anthropic server tools (``[]`` when none).
Entries arrive in the OpenAI Responses shape the config defaults to, where a
bare ``web_search`` states the intent only; the Anthropic surface wants its
own definition, so that entry becomes
``{"type": "web_search_20250305", "name": "web_search"}`` — the server tool
Claude declares and DeepSeek's compat reads — keeping any other key (a
``max_uses`` cap, say). Anything else passes through as it is.
"""
result: list[dict[str, Any]] = []
for item in provider_tools or ():
entry = dict(item)
if entry.get("type") == "web_search":
extra = {key: value for key, value in entry.items() if key not in {"type", "name"}}
entry = {**_ANTHROPIC_WEB_SEARCH_TOOL, **extra}
if entry.get("type"):
result.append(entry)
return result
def _tool_choice_from_chat(param: ChatCompletionsParam) -> AnthropicToolChoice | Unset:
if param.tool_choice is UNSET:
return UNSET
@@ -190,7 +220,12 @@ def chat_messages_to_anthropic( # noqa: C901 — multi-role conversion
def anthropic_response_to_assistant(response: AnthropicMessageResponse) -> AssistantChatMessage:
"""Map a completed Anthropic message to agent ``AssistantChatMessage``."""
"""Map a completed Anthropic message to agent ``AssistantChatMessage``.
Server-side blocks (``server_tool_use`` / ``web_search_tool_result``) and
``thinking`` are not part of the chat-shaped message: a search the model ran
through its backend already shows up in the text it answered with.
"""
text_parts: list[str] = []
tool_calls: list[AnyAssistantToolCall] = []
for block in response.content:
@@ -198,7 +233,8 @@ def anthropic_response_to_assistant(response: AnthropicMessageResponse) -> Assis
if block.text:
text_parts.append(block.text)
continue
# AnthropicResponseToolUse (remaining content arm)
if not isinstance(block, AnthropicResponseToolUse):
continue
tool_calls.append(
AssistantFunctionToolCall(
id=block.id,
@@ -338,14 +374,20 @@ def chat_param_to_anthropic_param(
param: ChatCompletionsParam,
*,
thinking_budget_tokens: int = 0,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> AnthropicMessagesParam:
"""Build :class:`AnthropicMessagesParam` from a chat completions param.
*thinking_budget_tokens* is the configured explicit ``thinking`` budget
(0 = derive it from the param's ``reasoning_effort``).
(0 = derive it from the param's ``reasoning_effort``); *provider_tools* are
hosted tools (web_search, …) as opaque dicts, merged after local function
tools and never executed by the registry.
"""
system, messages = chat_messages_to_anthropic(param.messages)
tools = tool_items_to_anthropic_tools(param.tools if param.tools is not UNSET else None)
tools: list[AnthropicToolDefinition | dict[str, Any]] = list(
tool_items_to_anthropic_tools(param.tools if param.tools is not UNSET else None)
)
tools.extend(provider_tools_to_anthropic_tools(provider_tools))
max_tokens, thinking = _thinking_and_max_tokens(param, thinking_budget_tokens=thinking_budget_tokens)
kwargs: dict[str, Any] = {
+40 -7
View File
@@ -33,7 +33,7 @@ from plyngent.lmproto.anthropic.model import (
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Sequence
from plyngent.lmproto.anthropic.client import AnthropicClient
from plyngent.lmproto.anthropic.model import AnthropicStreamEvent
@@ -92,6 +92,7 @@ class _StreamState:
model: str
tool_blocks: dict[int, _ToolBlockState]
block_types: dict[int, str]
next_tool_index: int
stop_reason: str | None
last_usage: dict[str, Any] | None
@@ -101,6 +102,7 @@ class _StreamState:
def __init__(self, model: str) -> None:
self.model = model
self.tool_blocks = {}
self.block_types = {}
self.next_tool_index = 0
self.stop_reason = None
self.last_usage = None
@@ -126,11 +128,19 @@ class _StreamState:
return self.start_tool(block_index=block_index, call_id=f"toolu_{block_index}", name="")
def _handle_tool_use_start(
def _handle_block_start(
event: AnthropicContentBlockStart,
state: _StreamState,
) -> list[ChatCompletionChunk]:
"""Record the block's type; only ``tool_use`` becomes a local tool call.
``server_tool_use`` (a search the model's backend runs), its
``web_search_tool_result``, and ``thinking`` stream here too — recording
their type keeps the argument fragments that follow from being mistaken for
a local call's arguments (see ``_chunks_for_content_delta``).
"""
block = event.content_block
state.block_types[event.index] = block.type
if block.type != "tool_use":
return []
call_id = block.id or f"toolu_{event.index}"
@@ -170,7 +180,7 @@ def _chunks_for_event(event: AnthropicStreamEvent, state: _StreamState) -> list[
return []
if isinstance(event, AnthropicContentBlockStart):
return _handle_tool_use_start(event, state)
return _handle_block_start(event, state)
if isinstance(event, AnthropicContentBlockDelta):
return _chunks_for_content_delta(event, state)
@@ -199,6 +209,13 @@ def _chunks_for_content_delta(
if not is_json_delta:
return []
known_type = state.block_types.get(event.index)
if known_type is not None and known_type != "tool_use":
# A server tool's own arguments (DeepSeek streams the search query as
# ``input_json_delta`` on a ``server_tool_use`` block): nothing local
# asked for it, and its result arrived in this same response.
return []
tool_state = state.ensure_tool(event.index)
fragment = delta.partial_json if isinstance(delta.partial_json, str) else ""
if not fragment:
@@ -215,6 +232,8 @@ def _chunks_for_content_delta(
async def _stream_as_chat_chunks(
client: AnthropicClient,
param: ChatCompletionsParam,
*,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> AsyncIterator[ChatCompletionChunk]:
"""Yield chat-completions chunks from an Anthropic Messages SSE stream.
@@ -224,7 +243,11 @@ async def _stream_as_chat_chunks(
``message_stop``. Errors raise ``RuntimeError`` so the agent loop can
surface them as retryable failures.
"""
create = chat_param_to_anthropic_param(param, thinking_budget_tokens=_thinking_budget(client))
create = chat_param_to_anthropic_param(
param,
thinking_budget_tokens=_thinking_budget(client),
provider_tools=provider_tools,
)
stream = await client.messages(create, stream=True)
state = _StreamState(param.model)
@@ -251,12 +274,22 @@ async def dispatch_messages(
client: AnthropicClient,
param: ChatCompletionsParam,
*,
provider_tools: Sequence[dict[str, Any]] | None = None,
stream: bool = False,
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
"""Run one Anthropic Messages turn and return a chat-completions-shaped result."""
create = chat_param_to_anthropic_param(param, thinking_budget_tokens=_thinking_budget(client))
"""Run one Anthropic Messages turn and return a chat-completions-shaped result.
*provider_tools* are hosted tools (web_search, …) as opaque dicts; the
surface's own server-tool definition is built from them and the results
arrive inside the same response, so nothing is executed locally.
"""
create = chat_param_to_anthropic_param(
param,
thinking_budget_tokens=_thinking_budget(client),
provider_tools=provider_tools,
)
if stream:
return _stream_as_chat_chunks(client, param)
return _stream_as_chat_chunks(client, param, provider_tools=provider_tools)
response = await client.messages(create, stream=False)
return anthropic_response_to_chat_completion(response)
+45 -5
View File
@@ -2,12 +2,11 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal
from typing import Any, Literal
from msgspec import UNSET, Struct, field
if TYPE_CHECKING:
from plyngent.typedef import Unset
from plyngent.typedef import Unset # noqa: TC001
class AnthropicTextContent(Struct, tag_field="type", tag="text"):
@@ -79,7 +78,9 @@ class AnthropicMessagesParam(Struct, omit_defaults=True):
max_tokens: int = 8192
messages: list[AnthropicMessage] = field(default_factory=list)
system: str | list[AnthropicTextContent] | Unset = UNSET
tools: list[AnthropicToolDefinition] | Unset = UNSET
# ``dict`` entries are opaque hosted/server tools (e.g. DeepSeek's
# ``{"type": "web_search_20250305", "name": "web_search"}``).
tools: list[AnthropicToolDefinition | dict[str, Any]] | Unset = UNSET
tool_choice: AnthropicToolChoice | Unset = UNSET
metadata: AnthropicMetadata | Unset = UNSET
stop_sequences: list[str] | Unset = UNSET
@@ -107,7 +108,46 @@ class AnthropicResponseToolUse(Struct, tag_field="type", tag="tool_use"):
input: dict[str, Any]
type AnthropicResponseContent = AnthropicResponseText | AnthropicResponseToolUse
class AnthropicThinkingContent(Struct, omit_defaults=True, tag_field="type", tag="thinking"):
"""Extended-thinking block; ``signature`` is opaque and not verified back."""
thinking: str = ""
signature: str | Unset = UNSET
class AnthropicServerToolUseContent(Struct, omit_defaults=True, tag_field="type", tag="server_tool_use"):
"""A tool the *server* runs — a web search the model asked its backend for.
Not a call for the local registry: the matching
:class:`AnthropicWebSearchToolResultContent` arrives in the same response
(see the ``messages_dispatch`` / ``messages_bridge`` bullets in AGENTS.md).
"""
id: str = ""
name: str = ""
input: dict[str, Any] = field(default_factory=dict)
class AnthropicWebSearchToolResultContent(Struct, omit_defaults=True, tag_field="type", tag="web_search_tool_result"):
"""What the server's search returned for one ``server_tool_use`` call.
``content`` stays loose: it holds ``web_search_result`` entries (title, url
and an ``encrypted_content`` the API demands back unchanged — DeepSeek
answers a trimmed result with 422) or a single
``web_search_tool_result_error`` such as ``max_uses_exceeded``.
"""
tool_use_id: str = ""
content: list[dict[str, Any]] = field(default_factory=list)
type AnthropicResponseContent = (
AnthropicResponseText
| AnthropicResponseToolUse
| AnthropicThinkingContent
| AnthropicServerToolUseContent
| AnthropicWebSearchToolResultContent
)
class AnthropicMessageResponse(Struct, omit_defaults=True):