From 2fdbc3ce5d944526d321e98f3217b87f53c451b0 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Fri, 9 Oct 2026 20:09:20 +0800 Subject: [PATCH] core/agent: keep a Messages round's provider blocks on the assistant message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/plyngent/agent/messages_bridge.py | 62 ++++++++++++++-- src/plyngent/agent/stream_chunks.py | 27 +++++++ src/plyngent/lmproto/anthropic/model.py | 23 +++++- .../lmproto/openai_compatible/model.py | 10 +++ tests/test_agent/test_messages_bridge.py | 71 ++++++++++++++++++- tests/test_lmproto/test_anthropic.py | 49 +++++++++++++ 6 files changed, 232 insertions(+), 10 deletions(-) diff --git a/src/plyngent/agent/messages_bridge.py b/src/plyngent/agent/messages_bridge.py index 06bf457..b5a32f8 100644 --- a/src/plyngent/agent/messages_bridge.py +++ b/src/plyngent/agent/messages_bridge.py @@ -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, ) diff --git a/src/plyngent/agent/stream_chunks.py b/src/plyngent/agent/stream_chunks.py index cb55606..6591553 100644 --- a/src/plyngent/agent/stream_chunks.py +++ b/src/plyngent/agent/stream_chunks.py @@ -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", diff --git a/src/plyngent/lmproto/anthropic/model.py b/src/plyngent/lmproto/anthropic/model.py index b3e140d..37093dc 100644 --- a/src/plyngent/lmproto/anthropic/model.py +++ b/src/plyngent/lmproto/anthropic/model.py @@ -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"): diff --git a/src/plyngent/lmproto/openai_compatible/model.py b/src/plyngent/lmproto/openai_compatible/model.py index 72bce1f..2da03cd 100644 --- a/src/plyngent/lmproto/openai_compatible/model.py +++ b/src/plyngent/lmproto/openai_compatible/model.py @@ -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): diff --git a/tests/test_agent/test_messages_bridge.py b/tests/test_agent/test_messages_bridge.py index e770d09..8fae7a7 100644 --- a/tests/test_agent/test_messages_bridge.py +++ b/tests/test_agent/test_messages_bridge.py @@ -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: diff --git a/tests/test_lmproto/test_anthropic.py b/tests/test_lmproto/test_anthropic.py index 26f71b9..c200785 100644 --- a/tests/test_lmproto/test_anthropic.py +++ b/tests/test_lmproto/test_anthropic.py @@ -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"))