core/agent: keep a Messages round's provider blocks on the assistant message

DeepSeek's Anthropic compat answers a hosted search inside the turn: the response carries the `thinking` that picked the query, the `server_tool_use`, and the `web_search_tool_result`. The response mapping kept text and tool_use only, so the round lost its evidence — and the `thinking` signature, which only the provider can verify, could not go back at all. `AssistantChatMessage.provider_blocks` carries those blocks as raw JSON now (`thinking` text *and* signature, the query, the result entries with an `encrypted_content` the API rejects when trimmed) and the assistant turn hands them back first, ahead of its text and tool_use blocks; `thinking` also lands in `reasoning_content`, so the chain of thought streams as reasoning like every other surface's.

`AnthropicRawContentBlock` names the provider fields a streamed block carries, and `AnthropicServerToolUseContent` keeps the `caller` metadata the wire sends.
This commit is contained in:
2026-10-09 20:09:20 +08:00
parent 6c4c8b9328
commit 2fdbc3ce5d
6 changed files with 232 additions and 10 deletions
+56 -6
View File
@@ -18,15 +18,17 @@ from plyngent.lmproto.anthropic.model import (
AnthropicMessageResponse,
AnthropicMessagesParam,
AnthropicResponseText,
AnthropicResponseToolUse,
AnthropicServerToolUseContent,
AnthropicTextContent,
AnthropicThinkingConfig,
AnthropicThinkingContent,
AnthropicToolChoice,
AnthropicToolDefinition,
AnthropicToolResultContent,
AnthropicToolUseContent,
AnthropicUsage,
AnthropicUserMessage,
AnthropicWebSearchToolResultContent,
)
from plyngent.lmproto.openai_compatible.model import (
AnyAssistantToolCall,
@@ -133,7 +135,16 @@ def _tool_choice_from_chat(param: ChatCompletionsParam) -> AnthropicToolChoice |
def _assistant_to_anthropic(message: AssistantChatMessage) -> AnthropicAssistantMessage | None:
blocks: list[AnthropicTextContent | AnthropicToolUseContent] = []
"""The assistant turn for the wire: opaque blocks, then text, then calls.
``provider_blocks`` go back first and verbatim (a ``thinking`` block's
signature is only the provider's to verify, and a hosted search's
``web_search_tool_result`` entries carry an ``encrypted_content`` the API
rejects when trimmed), which is also the order the API sends them in.
"""
blocks: list[AnthropicTextContent | AnthropicToolUseContent | dict[str, Any]] = []
if message.provider_blocks is not UNSET and message.provider_blocks:
blocks.extend(message.provider_blocks)
if isinstance(message.content, str) and message.content:
blocks.append(AnthropicTextContent(text=message.content))
if message.tool_calls is not UNSET and message.tool_calls:
@@ -219,22 +230,59 @@ def chat_messages_to_anthropic( # noqa: C901 — multi-role conversion
return system, out
def _thinking_block_json(block: AnthropicThinkingContent) -> dict[str, Any]:
"""A ``thinking`` block as JSON: its text, plus the signature when sent."""
out: dict[str, Any] = {"type": "thinking", "thinking": block.thinking}
if isinstance(block.signature, str):
out["signature"] = block.signature
return out
def _server_block_json(
block: AnthropicServerToolUseContent | AnthropicWebSearchToolResultContent,
) -> dict[str, Any]:
"""A hosted search's block as JSON: the query it ran, or the raw entries.
Rebuilt from the parsed block: an ``encrypted_content`` (and any other key
inside the loose ``content`` entries) survives byte for byte, while a key
the block model does not name cannot come back.
"""
if isinstance(block, AnthropicServerToolUseContent):
out: dict[str, Any] = {"type": "server_tool_use", "id": block.id, "name": block.name, "input": block.input}
if block.caller is not UNSET:
out["caller"] = block.caller
return out
return {"type": "web_search_tool_result", "tool_use_id": block.tool_use_id, "content": block.content}
def anthropic_response_to_assistant(response: AnthropicMessageResponse) -> 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.
``thinking`` lands in ``reasoning_content`` (the chat-shaped slot every
surface's chain-of-thought goes into) and, signature included, in
``provider_blocks`` — the API wants the block back to continue a turn it
reasoned through. A hosted search's ``server_tool_use`` +
``web_search_tool_result`` pair is not a call for the local registry, but it
is the model's own evidence: both go into ``provider_blocks`` verbatim, so
the next request hands them back and the model does not search again.
"""
text_parts: list[str] = []
thinking_parts: list[str] = []
provider_blocks: list[dict[str, Any]] = []
tool_calls: list[AnyAssistantToolCall] = []
for block in response.content:
if isinstance(block, AnthropicResponseText):
if block.text:
text_parts.append(block.text)
continue
if not isinstance(block, AnthropicResponseToolUse):
if isinstance(block, AnthropicThinkingContent):
thinking_parts.append(block.thinking)
provider_blocks.append(_thinking_block_json(block))
continue
if isinstance(block, (AnthropicServerToolUseContent, AnthropicWebSearchToolResultContent)):
provider_blocks.append(_server_block_json(block))
continue
# The union's remaining member is the local tool call.
tool_calls.append(
AssistantFunctionToolCall(
id=block.id,
@@ -248,6 +296,8 @@ def anthropic_response_to_assistant(response: AnthropicMessageResponse) -> Assis
return AssistantChatMessage(
content=text or None,
tool_calls=tool_calls or UNSET,
reasoning_content="".join(thinking_parts) or UNSET,
provider_blocks=provider_blocks or UNSET,
)
+27
View File
@@ -79,6 +79,32 @@ def reasoning_delta_chunk(
)
def provider_blocks_chunk(
*,
model: str,
blocks: list[dict[str, Any]],
created: int = 0,
) -> ChatCompletionChunk:
"""Carry provider-opaque blocks a bridge rebuilt mid-stream.
The loop copies them onto the round's assistant message
(``AssistantChatMessage.provider_blocks``), which is what puts them back on
the wire for the surface that produced them.
"""
return ChatCompletionChunk(
id="bridge-stream",
object="chat.completion.chunk",
created=created,
model=model,
choices=[
ChunkChoice(
index=0,
delta=DeltaMessage(provider_blocks=blocks),
)
],
)
def tool_call_delta_chunk(
*,
model: str,
@@ -127,6 +153,7 @@ def usage_chunk(*, model: str, usage: dict[str, Any], created: int = 0) -> ChatC
__all__ = [
"finish_reason_chunk",
"provider_blocks_chunk",
"reasoning_delta_chunk",
"text_delta_chunk",
"tool_call_delta_chunk",
+22 -1
View File
@@ -39,7 +39,11 @@ class AnthropicUserMessage(Struct, tag_field="role", tag="user"):
class AnthropicAssistantMessage(Struct, tag_field="role", tag="assistant"):
content: str | list[AnthropicTextContent | AnthropicToolUseContent]
# ``dict`` entries are provider-opaque blocks echoed back verbatim: a
# ``thinking`` block (its ``signature`` cannot be rebuilt from the text) and
# the ``server_tool_use`` + ``web_search_tool_result`` pair a hosted search
# answers inside the turn.
content: str | list[AnthropicTextContent | AnthropicToolUseContent | dict[str, Any]]
type AnthropicMessage = AnthropicUserMessage | AnthropicAssistantMessage
@@ -125,11 +129,13 @@ class AnthropicServerToolUseContent(Struct, omit_defaults=True, tag_field="type"
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).
``caller`` is the surface's own metadata and goes back with the block.
"""
id: str = ""
name: str = ""
input: dict[str, Any] = field(default_factory=dict)
caller: dict[str, Any] | Unset = UNSET
class AnthropicWebSearchToolResultContent(Struct, omit_defaults=True, tag_field="type", tag="web_search_tool_result"):
@@ -166,12 +172,27 @@ class AnthropicMessageResponse(Struct, omit_defaults=True):
class AnthropicRawContentBlock(Struct, omit_defaults=True):
"""One streamed content block: the ``content_block`` / ``delta`` payload.
The fields cover the response union: ``text`` fragments, ``thinking`` and
its opaque ``signature``, a ``server_tool_use``'s ``input`` (streamed as
``partial_json``), and a ``web_search_tool_result``'s ``tool_use_id`` +
``content`` (kept loose — an entry's ``encrypted_content`` goes back
unchanged). ``agent.messages_dispatch`` rebuilds the blocks from these to
hand them to the next request.
"""
type: str = ""
id: str | None = None
name: str | None = None
text: str | None = None
input: dict[str, Any] | None = None
partial_json: str | None = None
thinking: str | None = None
signature: str | None = None
tool_use_id: str | None = None
content: list[dict[str, Any]] | None = None
caller: dict[str, Any] | None = None
class AnthropicMessageStart(Struct, tag_field="type", tag="message_start"):
@@ -74,6 +74,12 @@ class AssistantChatMessage(Struct, tag_field="role", tag="assistant"):
tool_calls: list[AnyAssistantToolCall] | Unset = UNSET
# OpenAI-compat / DeepSeek thinking streams (omitted when unset).
reasoning_content: str | Unset = UNSET
# Provider-opaque assistant blocks kept as raw JSON and echoed back verbatim
# by the surface that produced them (Anthropic: a ``thinking`` block, whose
# ``signature`` only the provider can verify, and the ``server_tool_use`` +
# ``web_search_tool_result`` pair a hosted search answers inside the turn).
# Surfaces that do not know them send the message without the field.
provider_blocks: list[dict[str, Any]] | Unset = UNSET
class ToolChatMessage(ChatMessage, tag_field="role", tag="tool"):
@@ -243,6 +249,10 @@ class DeltaMessage(Struct):
# ``response.reasoning_text.done``), replacing any streamed fragments.
reasoning_full: bool | Unset = UNSET
tool_calls: list[StreamToolCallDelta] | Unset = UNSET
# Provider-opaque blocks a bridge rebuilt mid-stream (see
# ``AssistantChatMessage.provider_blocks``): the loop copies them onto the
# round's assistant message.
provider_blocks: list[dict[str, Any]] | Unset = UNSET
class ChunkChoice(Struct):
+68 -3
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import msgspec
from msgspec import UNSET
from plyngent.agent.messages_bridge import (
@@ -156,15 +157,20 @@ def test_anthropic_response_to_assistant_with_tools() -> None:
assert '"a":1' in call.function.arguments or '"a": 1' in call.function.arguments
def test_anthropic_response_to_assistant_ignores_server_tool_blocks() -> None:
"""A server-side search is not a call for the registry, nor part of the message."""
def test_anthropic_response_to_assistant_keeps_thinking_and_search_blocks() -> None:
"""A server-side search is not a registry call, but it is the round's evidence."""
response = AnthropicMessageResponse(
id="msg_search",
model="deepseek-flash",
stop_reason="end_turn",
content=[
AnthropicThinkingContent(thinking="Let me search.", signature="sig-1"),
AnthropicServerToolUseContent(id="call_00_1", name="web_search", input={"query": "DeepSeek-V4"}),
AnthropicServerToolUseContent(
id="call_00_1",
name="web_search",
input={"query": "DeepSeek-V4"},
caller={"type": "direct"},
),
AnthropicWebSearchToolResultContent(
tool_use_id="call_00_1",
content=[{"type": "web_search_result", "url": "https://api-docs.deepseek.com"}],
@@ -176,6 +182,65 @@ def test_anthropic_response_to_assistant_ignores_server_tool_blocks() -> None:
assistant = anthropic_response_to_assistant(response)
assert assistant.content == "DeepSeek-V4 is out."
assert assistant.tool_calls is UNSET
# The chain of thought is the round's reasoning text; the signature it must
# go back with only exists on the block.
assert assistant.reasoning_content == "Let me search."
assert assistant.provider_blocks == [
{"type": "thinking", "thinking": "Let me search.", "signature": "sig-1"},
{
"type": "server_tool_use",
"id": "call_00_1",
"name": "web_search",
"input": {"query": "DeepSeek-V4"},
"caller": {"type": "direct"},
},
{
"type": "web_search_tool_result",
"tool_use_id": "call_00_1",
"content": [{"type": "web_search_result", "url": "https://api-docs.deepseek.com"}],
},
]
def test_chat_param_to_anthropic_param_hands_provider_blocks_back() -> None:
"""The next request carries the round's thinking and search blocks first."""
assistant = AssistantChatMessage(
content="DeepSeek-V4 is out.",
reasoning_content="Let me search.",
provider_blocks=[
{"type": "thinking", "thinking": "Let me search.", "signature": "sig-1"},
{
"type": "web_search_tool_result",
"tool_use_id": "call_00_1",
"content": [{"type": "web_search_result", "encrypted_content": "sd918tZXSvVp"}],
},
],
tool_calls=[
AssistantFunctionToolCall(
id="tu_1",
function=AssistantFunctionTool(name="read_file", arguments='{"path": "a.py"}'),
)
],
)
param = ChatCompletionsParam(model="deepseek-flash", messages=[assistant])
built = chat_param_to_anthropic_param(param)
# The wire shape: DeepSeek answers a trimmed ``encrypted_content`` with 422.
data = msgspec.json.decode(msgspec.json.encode(built))
assert data["messages"] == [
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Let me search.", "signature": "sig-1"},
{
"type": "web_search_tool_result",
"tool_use_id": "call_00_1",
"content": [{"type": "web_search_result", "encrypted_content": "sd918tZXSvVp"}],
},
{"type": "text", "text": "DeepSeek-V4 is out."},
{"type": "tool_use", "id": "tu_1", "name": "read_file", "input": {"path": "a.py"}},
],
}
]
def test_provider_tools_to_anthropic_tools_maps_the_search_intent() -> None:
+49
View File
@@ -6,11 +6,14 @@ import pytest
from plyngent.lmproto.anthropic import AnthropicClient
from plyngent.lmproto.anthropic.config import AnthropicConfig
from plyngent.lmproto.anthropic.model import (
AnthropicAssistantMessage,
AnthropicContentBlockDelta,
AnthropicMessageResponse,
AnthropicMessagesParam,
AnthropicMessageStop,
AnthropicRawContentBlock,
AnthropicResponseText,
AnthropicTextContent,
AnthropicThinkingConfig,
AnthropicUsage,
AnthropicUserMessage,
@@ -126,6 +129,52 @@ def test_messages_param_keeps_the_thinking_type_on_the_wire() -> None:
assert data["thinking"] == {"type": "enabled", "budget_tokens": 16384}
def test_stream_block_keeps_the_provider_fields() -> None:
"""A streamed block carries what a rebuild puts back on the wire."""
result = msgspec.json.decode(
b'{"type":"web_search_tool_result","tool_use_id":"call_00_1",'
b'"content":[{"type":"web_search_result","encrypted_content":"sd9"}]}',
type=AnthropicRawContentBlock,
)
assert result.tool_use_id == "call_00_1"
assert result.content == [{"type": "web_search_result", "encrypted_content": "sd9"}]
thinking = msgspec.json.decode(
b'{"type":"thinking","thinking":"hmm","signature":"sig-1"}',
type=AnthropicRawContentBlock,
)
assert (thinking.thinking, thinking.signature) == ("hmm", "sig-1")
def test_assistant_message_encodes_provider_blocks_verbatim() -> None:
"""An echoed block keeps its own keys — ``encrypted_content`` included."""
param = AnthropicMessagesParam(
model="deepseek-flash",
messages=[
AnthropicAssistantMessage(
content=[
{"type": "thinking", "thinking": "hmm", "signature": "sig-1"},
{
"type": "web_search_tool_result",
"tool_use_id": "call_00_1",
"content": [{"type": "web_search_result", "encrypted_content": "sd9"}],
},
AnthropicTextContent(text="done"),
]
)
],
)
data = msgspec.json.decode(msgspec.json.encode(param))
assert data["messages"][0]["content"] == [
{"type": "thinking", "thinking": "hmm", "signature": "sig-1"},
{
"type": "web_search_tool_result",
"tool_use_id": "call_00_1",
"content": [{"type": "web_search_result", "encrypted_content": "sd9"}],
},
{"type": "text", "text": "done"},
]
async def test_client_messages_create(monkeypatch: pytest.MonkeyPatch) -> None:
client = AnthropicClient(AnthropicConfig(api_key="sk-test", base_url="https://example/v1"))