Skip to content

Commit c3c111a

Browse files
committed
type constraints on channels, serde
1 parent fbaaccc commit c3c111a

File tree

4 files changed

+81
-4
lines changed

4 files changed

+81
-4
lines changed

src/fast_agent/llm/provider/anthropic/llm_anthropic.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -781,7 +781,20 @@ async def _anthropic_completion(
781781
if raw_thinking_blocks:
782782
if channels is None:
783783
channels = {}
784-
channels[ANTHROPIC_THINKING_BLOCKS] = raw_thinking_blocks
784+
serialized_blocks = []
785+
for block in raw_thinking_blocks:
786+
try:
787+
payload = block.model_dump()
788+
except Exception:
789+
payload = {"type": getattr(block, "type", "thinking")}
790+
if isinstance(block, ThinkingBlock):
791+
payload.update(
792+
{"thinking": block.thinking, "signature": block.signature}
793+
)
794+
elif isinstance(block, RedactedThinkingBlock):
795+
payload.update({"data": block.data})
796+
serialized_blocks.append(TextContent(type="text", text=json.dumps(payload)))
797+
channels[ANTHROPIC_THINKING_BLOCKS] = serialized_blocks
785798

786799
return PromptMessageExtended(
787800
role="assistant",

src/fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import re
23
from typing import Literal, Sequence, Union, cast
34
from urllib.parse import urlparse
@@ -11,6 +12,7 @@
1112
MessageParam,
1213
PlainTextSourceParam,
1314
RedactedThinkingBlock,
15+
RedactedThinkingBlockParam,
1416
TextBlockParam,
1517
ThinkingBlock,
1618
ThinkingBlockParam,
@@ -106,6 +108,28 @@ def convert_to_anthropic(multipart_msg: PromptMessageExtended) -> MessageParam:
106108
# Redacted thinking blocks are passed as-is
107109
# They contain encrypted data that the API can verify
108110
all_content_blocks.append(thinking_block)
111+
elif isinstance(thinking_block, TextContent):
112+
try:
113+
payload = json.loads(thinking_block.text)
114+
except (TypeError, json.JSONDecodeError):
115+
payload = None
116+
if isinstance(payload, dict):
117+
block_type = payload.get("type")
118+
if block_type == "thinking":
119+
all_content_blocks.append(
120+
ThinkingBlockParam(
121+
type="thinking",
122+
thinking=payload.get("thinking", ""),
123+
signature=payload.get("signature", ""),
124+
)
125+
)
126+
elif block_type == "redacted_thinking":
127+
all_content_blocks.append(
128+
RedactedThinkingBlockParam(
129+
type="redacted_thinking",
130+
data=payload.get("data", ""),
131+
)
132+
)
109133

110134
for tool_use_id, req in multipart_msg.tool_calls.items():
111135
sanitized_id = AnthropicConverter._sanitize_tool_id(tool_use_id)

src/fast_agent/mcp/prompt_message_extended.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Mapping, Sequence
1+
from typing import Mapping, Sequence
22

33
from mcp.types import (
44
CallToolRequest,
@@ -27,11 +27,11 @@ class PromptMessageExtended(BaseModel):
2727
content: list[ContentBlock] = []
2828
tool_calls: dict[str, CallToolRequest] | None = None
2929
tool_results: dict[str, CallToolResult] | None = None
30-
# Channels can carry provider-specific payloads (e.g., raw Anthropic thinking blocks).
31-
channels: Mapping[str, Sequence[Any]] | None = None
30+
channels: Mapping[str, Sequence[ContentBlock]] | None = None
3231
stop_reason: LlmStopReason | None = None
3332
is_template: bool = False
3433

34+
3535
@classmethod
3636
def to_extended(cls, messages: list[PromptMessage]) -> list["PromptMessageExtended"]:
3737
"""Convert a sequence of PromptMessages into PromptMessageExtended objects."""

tests/unit/fast_agent/llm/providers/test_multipart_converter_anthropic.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import base64
2+
import json
23
import unittest
34

45
from mcp.types import (
56
BlobResourceContents,
7+
CallToolRequest,
8+
CallToolRequestParams,
69
CallToolResult,
710
EmbeddedResource,
811
ImageContent,
@@ -12,6 +15,7 @@
1215
)
1316
from pydantic import AnyUrl
1417

18+
from fast_agent.constants import ANTHROPIC_THINKING_BLOCKS
1519
from fast_agent.llm.provider.anthropic.multipart_converter_anthropic import (
1620
AnthropicConverter,
1721
)
@@ -715,6 +719,42 @@ def test_assistant_multiple_text_blocks(self):
715719
self.assertEqual(anthropic_msg["content"][1]["type"], "text")
716720
self.assertEqual(anthropic_msg["content"][1]["text"], "Second part of response")
717721

722+
def test_assistant_thinking_blocks_deserialized_from_channel(self):
723+
"""Ensure thinking channel JSON is converted to Anthropic thinking params."""
724+
thinking_payload = {
725+
"type": "thinking",
726+
"thinking": "Reasoning summary.",
727+
"signature": "sig123",
728+
}
729+
redacted_payload = {"type": "redacted_thinking", "data": "opaque"}
730+
channels = {
731+
ANTHROPIC_THINKING_BLOCKS: [
732+
TextContent(type="text", text=json.dumps(thinking_payload)),
733+
TextContent(type="text", text=json.dumps(redacted_payload)),
734+
]
735+
}
736+
tool_calls = {
737+
"toolu_1": CallToolRequest(
738+
method="tools/call",
739+
params=CallToolRequestParams(name="test_tool", arguments={"x": 1}),
740+
)
741+
}
742+
multipart = PromptMessageExtended(
743+
role="assistant", content=[], tool_calls=tool_calls, channels=channels
744+
)
745+
746+
anthropic_msg = AnthropicConverter.convert_to_anthropic(multipart)
747+
748+
self.assertEqual(anthropic_msg["role"], "assistant")
749+
self.assertEqual(len(anthropic_msg["content"]), 3)
750+
self.assertEqual(anthropic_msg["content"][0]["type"], "thinking")
751+
self.assertEqual(anthropic_msg["content"][0]["thinking"], "Reasoning summary.")
752+
self.assertEqual(anthropic_msg["content"][0]["signature"], "sig123")
753+
self.assertEqual(anthropic_msg["content"][1]["type"], "redacted_thinking")
754+
self.assertEqual(anthropic_msg["content"][1]["data"], "opaque")
755+
self.assertEqual(anthropic_msg["content"][2]["type"], "tool_use")
756+
self.assertEqual(anthropic_msg["content"][2]["name"], "test_tool")
757+
718758
def test_assistant_non_text_content_stripped(self):
719759
"""Test that non-text content is stripped from assistant messages."""
720760
# Create a mixed content message with text and image

0 commit comments

Comments
 (0)