mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
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:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user