From 05ba43d363f576c286c430e056e6eddd145d1f53 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 26 Oct 2026 16:39:21 +0100 Subject: [PATCH 1/2] Python: Preserve Anthropic server-tool history replay --- python/packages/anthropic/README.md | 8 + .../agent_framework_anthropic/_chat_client.py | 128 ++++- .../anthropic/tests/test_anthropic_client.py | 460 +++++++++++++++++- 3 files changed, 562 insertions(+), 34 deletions(-) diff --git a/python/packages/anthropic/README.md b/python/packages/anthropic/README.md index f3288c38688..8099207c640 100644 --- a/python/packages/anthropic/README.md +++ b/python/packages/anthropic/README.md @@ -44,3 +44,11 @@ response = await client.get_response("Hello", options={"instructions": system_bl Instructions contributed later in a run — by a context provider such as `SkillsProvider`, or by per-run `options` — are appended as an additional text block after the configured blocks. The blocks you supply keep their structure and their position, so a `cache_control` breakpoint stays valid. + +### Persisted server-tool history + +Anthropic server-tool calls and results use `Content.additional_properties["anthropic"]["server_tool_block"]` +internally to preserve the native block type, structured result content, and optional caller metadata across +message serialization. Result metadata can contain provider-encrypted fields such as `encrypted_content`; the +adapter stores those plain serialized values unchanged because Anthropic requires them for replay. Applications +should treat this namespace as adapter-owned replay data and must not modify it. diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index dd894913609..881db46b56c 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -28,6 +28,7 @@ TextSpanRegion, UsageDetails, add_usage_details, + get_namespaced_value, tool, ) from agent_framework._settings import SecretString, load_settings @@ -39,6 +40,7 @@ ChatClientException, ChatClientInvalidAuthException, ChatClientInvalidRequestException, + ContentError, ResponseInvalidatedException, ) from agent_framework.observability import ChatTelemetryLayer @@ -97,6 +99,13 @@ ANTHROPIC_DEFAULT_MAX_TOKENS: Final[int] = 1024 BETA_FLAGS: Final[list[str]] = ["mcp-client-2025-04-04"] UNSUPPORTED_SAMPLING_OPTIONS: Final[tuple[str, ...]] = ("temperature", "top_p", "top_k") +_ANTHROPIC_ADDITIONAL_PROPS_NAMESPACE: Final[str] = "anthropic" +_SERVER_TOOL_BLOCK_KEY: Final[str] = "server_tool_block" +_SERVER_TOOL_USE_ID_PREFIX: Final[str] = "srvtoolu_" +_SERVER_TOOL_RESULT_TYPES: Final[frozenset[str]] = frozenset({ + "web_fetch_tool_result", + "web_search_tool_result", +}) ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) AnthropicAsyncClient = AsyncAnthropic | AsyncAnthropicBedrock | AsyncAnthropicFoundry | AsyncAnthropicVertex @@ -119,6 +128,30 @@ def _wrap_anthropic_error(ex: Exception) -> ChatClientException: return ChatClientException(f"Anthropic chat request failed: {ex}", inner_exception=ex) +def _serialize_anthropic_payload(value: Any) -> Any: + """Convert Anthropic SDK payloads into plain persisted data.""" + if isinstance(value, BaseModel): + return value.model_dump(mode="json", exclude_none=True) + if isinstance(value, Mapping): + mapping = cast(Mapping[Any, Any], value) + return {str(key): _serialize_anthropic_payload(item) for key, item in mapping.items()} + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + sequence = cast(Sequence[Any], value) + return [_serialize_anthropic_payload(item) for item in sequence] + return value + + +def _server_tool_block_metadata(content_block: Any, *, include_content: bool = False) -> dict[str, Any]: + """Build persisted metadata needed to reconstruct an Anthropic server-tool block.""" + block: dict[str, Any] = {"type": content_block.type} + if include_content: + block["content"] = _serialize_anthropic_payload(content_block.content) + caller = getattr(content_block, "caller", None) + if isinstance(caller, (BaseModel, Mapping)): + block["caller"] = _serialize_anthropic_payload(caller) + return {_ANTHROPIC_ADDITIONAL_PROPS_NAMESPACE: {_SERVER_TOOL_BLOCK_KEY: block}} + + # region Anthropic Chat Options TypedDict @@ -945,6 +978,8 @@ def _role_for_anthropic_content_block(self, content_block: Mapping[str, Any], de return "assistant" case {"type": "tool_result" | "mcp_tool_result"}: return "user" + case {"type": "web_fetch_tool_result" | "web_search_tool_result"}: + return "assistant" case _: return default_role @@ -964,6 +999,74 @@ def _message_has_tool_use(self, message: dict[str, Any]) -> bool: return False + @staticmethod + def _server_tool_block(content: Content) -> Mapping[str, Any] | None: + """Return validated persisted Anthropic server-tool metadata.""" + block = get_namespaced_value( + content.additional_properties, + _ANTHROPIC_ADDITIONAL_PROPS_NAMESPACE, + _SERVER_TOOL_BLOCK_KEY, + ) + if block is None: + return None + if not isinstance(block, Mapping): + raise ContentError("Anthropic server-tool replay metadata must be a mapping.") + return cast(Mapping[str, Any], block) + + def _prepare_server_tool_call_for_anthropic(self, content: Content) -> dict[str, Any] | None: + """Reconstruct an Anthropic server_tool_use block when this is a hosted call.""" + block = self._server_tool_block(content) + is_server_tool = bool(content.hosted) or bool( + content.call_id and content.call_id.startswith(_SERVER_TOOL_USE_ID_PREFIX) + ) + if block is not None: + block_type = block.get("type") + if block_type != "server_tool_use": + raise ContentError(f"Unsupported Anthropic server-tool call metadata type: {block_type!r}.") + is_server_tool = True + if not is_server_tool: + return None + + result: dict[str, Any] = { + "type": "server_tool_use", + "id": content.call_id, + "name": content.name, + "input": content.parse_arguments(), + } + if block is not None and (caller := block.get("caller")) is not None: + if not isinstance(caller, Mapping): + raise ContentError("Anthropic server-tool caller metadata must be a mapping.") + result["caller"] = dict(cast(Mapping[str, Any], caller)) + return result + + def _prepare_server_tool_result_for_anthropic(self, content: Content) -> dict[str, Any] | None: + """Reconstruct an Anthropic server-tool result block from persisted metadata.""" + block = self._server_tool_block(content) + if block is None: + if content.call_id and content.call_id.startswith(_SERVER_TOOL_USE_ID_PREFIX): + raise ContentError( + f"Anthropic server-tool result {content.call_id!r} cannot be replayed because its provider " + "metadata is missing." + ) + return None + + block_type = block.get("type") + if block_type not in _SERVER_TOOL_RESULT_TYPES: + raise ContentError(f"Unsupported Anthropic server-tool result metadata type: {block_type!r}.") + if "content" not in block: + raise ContentError("Anthropic server-tool result metadata is missing content.") + + result: dict[str, Any] = { + "type": block_type, + "tool_use_id": content.call_id, + "content": block["content"], + } + if (caller := block.get("caller")) is not None: + if not isinstance(caller, Mapping): + raise ContentError("Anthropic server-tool caller metadata must be a mapping.") + result["caller"] = dict(cast(Mapping[str, Any], caller)) + return result + def _prepare_message_for_anthropic(self, message: Message) -> dict[str, Any]: """Prepare a Message for the Anthropic client. @@ -1001,14 +1104,19 @@ def _prepare_message_for_anthropic(self, message: Message) -> dict[str, Any]: else: logger.debug(f"Ignoring unsupported data content media type: {content.media_type} for now") case "function_call": - a_content.append({ - "type": "tool_use", - "id": content.call_id, - "name": content.name, - "input": content.parse_arguments(), - }) + if server_tool_call := self._prepare_server_tool_call_for_anthropic(content): + a_content.append(server_tool_call) + else: + a_content.append({ + "type": "tool_use", + "id": content.call_id, + "name": content.name, + "input": content.parse_arguments(), + }) case "function_result": - if content.items: + if server_tool_result := self._prepare_server_tool_result_for_anthropic(content): + a_content.append(server_tool_result) + elif content.items: tool_content: list[dict[str, Any]] = [] for item in content.items: if item.type == "text": @@ -1449,6 +1557,11 @@ def _parse_contents_from_anthropic( informational_only=content_block.type == "server_tool_use", hosted=content_block.type == "server_tool_use", merge_mode="append" if streaming else None, + additional_properties=( + _server_tool_block_metadata(content_block) + if content_block.type == "server_tool_use" + else None + ), raw_representation=content_block, ) ) @@ -1486,6 +1599,7 @@ def _parse_contents_from_anthropic( Content.from_function_result( call_id=content_block.tool_use_id, result=content_block.content, + additional_properties=_server_tool_block_metadata(content_block, include_content=True), raw_representation=content_block, ) ) diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 7822da91600..78151042fa5 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -17,6 +17,7 @@ ChatResponse, ChatResponseUpdate, Content, + FileHistoryProvider, FunctionInvocationLayer, FunctionTool, InlineSkill, @@ -34,14 +35,18 @@ ChatClientException, ChatClientInvalidAuthException, ChatClientInvalidRequestException, + ContentError, ) from agent_framework.observability import ChatTelemetryLayer from anthropic.types.beta import ( BetaMessage, BetaMessageDeltaUsage, + BetaServerToolUseBlock, BetaTextBlock, BetaToolUseBlock, BetaUsage, + BetaWebSearchResultBlock, + BetaWebSearchToolResultBlock, ) from pydantic import BaseModel, Field @@ -346,6 +351,76 @@ def test_prepare_message_for_anthropic_function_call( assert result["content"][0]["input"] == {"location": "San Francisco"} +def test_prepare_message_for_anthropic_server_tool_call( + mock_anthropic_client: MagicMock, +) -> None: + """Hosted Anthropic calls must replay as server_tool_use blocks.""" + client = create_test_anthropic_client(mock_anthropic_client) + message = Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="srvtoolu_123", + name="web_search", + arguments={"query": "Agent Framework"}, + informational_only=True, + hosted=True, + additional_properties={ + "anthropic": { + "server_tool_block": { + "type": "server_tool_use", + "caller": {"type": "direct"}, + } + } + }, + ) + ], + ) + + result = client._prepare_message_for_anthropic(message) + + assert result == { + "role": "assistant", + "content": [ + { + "type": "server_tool_use", + "id": "srvtoolu_123", + "name": "web_search", + "input": {"query": "Agent Framework"}, + "caller": {"type": "direct"}, + } + ], + } + + +def test_prepare_message_for_anthropic_legacy_server_tool_call_uses_id_prefix( + mock_anthropic_client: MagicMock, +) -> None: + """Legacy history can recover server_tool_use from Anthropic's reserved ID prefix.""" + client = create_test_anthropic_client(mock_anthropic_client) + message = Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="srvtoolu_legacy", + name="web_search", + arguments={"query": "Agent Framework"}, + ) + ], + ) + + result = client._prepare_message_for_anthropic(message) + + assert result["content"] == [ + { + "type": "server_tool_use", + "id": "srvtoolu_legacy", + "name": "web_search", + "input": {"query": "Agent Framework"}, + } + ] + + def test_prepare_message_for_anthropic_function_result( mock_anthropic_client: MagicMock, ) -> None: @@ -376,6 +451,20 @@ def test_prepare_message_for_anthropic_function_result( assert result["content"][0]["is_error"] is False +def test_prepare_message_for_anthropic_rejects_unrecoverable_legacy_server_tool_result( + mock_anthropic_client: MagicMock, +) -> None: + """Legacy server results without structured provider metadata must fail before the request.""" + client = create_test_anthropic_client(mock_anthropic_client) + message = Message( + role="assistant", + contents=[Content.from_function_result(call_id="srvtoolu_legacy", result="lost structured result")], + ) + + with pytest.raises(ContentError, match="provider metadata is missing"): + client._prepare_message_for_anthropic(message) + + def test_prepare_message_for_anthropic_preserves_redacted_error_status( mock_anthropic_client: MagicMock, ) -> None: @@ -878,6 +967,41 @@ def test_prepare_messages_for_anthropic_splits_assistant_embedded_tool_results( assert result[1]["content"][0]["tool_use_id"] == "call_1" +def test_prepare_messages_preserves_parallel_server_tool_use( + mock_anthropic_client: MagicMock, +) -> None: + """A pending server tool remains server-owned while the local tool result is returned.""" + client = create_test_anthropic_client(mock_anthropic_client) + server_call = MagicMock() + server_call.type = "server_tool_use" + server_call.id = "srvtoolu_search" + server_call.name = "web_search" + server_call.input = {"query": "Agent Framework"} + server_call.caller = None + local_call = MagicMock() + local_call.type = "tool_use" + local_call.id = "toolu_local" + local_call.name = "local_value" + local_call.input = {} + + messages = [ + Message(role="user", contents=["Run both tools"]), + Message(role="assistant", contents=client._parse_contents_from_anthropic([server_call, local_call])), + Message( + role="tool", + contents=[Content.from_function_result(call_id="toolu_local", result="local-ok")], + ), + ] + + result = client._prepare_messages_for_anthropic(messages) + + assert [message["role"] for message in result] == ["user", "assistant", "user"] + assert [block["type"] for block in result[1]["content"]] == ["server_tool_use", "tool_use"] + assert result[1]["content"][0]["id"] == "srvtoolu_search" + assert result[2]["content"][0]["type"] == "tool_result" + assert result[2]["content"][0]["tool_use_id"] == "toolu_local" + + # Tool Conversion Tests @@ -1946,6 +2070,7 @@ def test_parse_contents_server_tool_use_input_json_delta_ignored( assert result[0].type == "function_call" assert result[0].informational_only is True assert result[0].hosted is True + assert result[0].additional_properties == {"anthropic": {"server_tool_block": {"type": "server_tool_use"}}} assert request_state.active_call_content_type == "server_tool_use" # input_json_delta events after server_tool_use must be silently ignored @@ -2199,6 +2324,7 @@ def _tool_start_event(*, block_type: str, call_id: str, name: str, index: int = event.content_block.id = call_id event.content_block.name = name event.content_block.input = {} + event.content_block.caller = None return event @@ -2297,6 +2423,9 @@ def test_non_streaming_hosted_tool_pause_turn_is_not_invalidated( assert response.finish_reason == "stop" assert response.messages[0].contents[0].type == "function_call" assert response.messages[0].contents[0].informational_only is True + restored = Message.from_dict(response.messages[0].to_dict()) + prepared = client._prepare_messages_for_anthropic([restored]) + assert prepared[0]["content"][0]["type"] == "server_tool_use" async def test_valid_streaming_local_tool_call_executes_and_continues( @@ -2485,6 +2614,9 @@ async def events() -> Any: assert len(hosted_calls) == 1 assert hosted_calls[0].informational_only is True assert response.finish_reason == "stop" + restored = Message.from_dict(response.messages[0].to_dict()) + prepared = client._prepare_messages_for_anthropic([restored]) + assert prepared[0]["content"][0]["type"] == "server_tool_use" async def test_concurrent_streams_isolate_tool_state_when_peer_fails( @@ -2948,6 +3080,167 @@ def test_process_stream_event_message_start_without_role_reproduces_bug() -> Non assert has_function_call, "Expected bug: function_call leaked into tool message" +@pytest.mark.filterwarnings("ignore::agent_framework._feature_stage.ExperimentalWarning") +async def test_agent_file_history_replays_server_tool_transcript_end_to_end(tmp_path: Path) -> None: + """Public agent APIs preserve a mixed server/local tool transcript through file history.""" + first_requests: list[dict[str, Any]] = [] + local_executions = 0 + + @tool(name="local_value", approval_mode="never_require") + def local_value() -> str: + nonlocal local_executions + local_executions += 1 + return "local-ok" + + first_responses = [ + BetaMessage( + id="msg_mixed_tools", + content=[ + BetaServerToolUseBlock( + type="server_tool_use", + id="srvtoolu_search", + name="web_search", + input={"query": "Microsoft Agent Framework"}, + ), + BetaToolUseBlock( + type="tool_use", + id="toolu_local", + name="local_value", + input={}, + ), + ], + model="claude-test", + role="assistant", + stop_reason="tool_use", + type="message", + usage=BetaUsage(input_tokens=1, output_tokens=1), + ), + BetaMessage( + id="msg_server_result", + content=[ + BetaWebSearchToolResultBlock( + type="web_search_tool_result", + tool_use_id="srvtoolu_search", + content=[ + BetaWebSearchResultBlock( + type="web_search_result", + url="https://github.com/microsoft/agent-framework", + title="Microsoft Agent Framework", + encrypted_content="opaque-search-state", + page_age=None, + ) + ], + ), + BetaTextBlock(type="text", text="first turn complete"), + ], + model="claude-test", + role="assistant", + stop_reason="end_turn", + type="message", + usage=BetaUsage(input_tokens=1, output_tokens=1), + ), + ] + + async def create_first_response(**kwargs: Any) -> BetaMessage: + first_requests.append(kwargs) + return first_responses[len(first_requests) - 1] + + first_transport = MagicMock() + first_transport.base_url = "https://example.invalid" + first_transport.beta.messages.create = create_first_response + first_history = FileHistoryProvider(tmp_path) + first_client = AnthropicClient(anthropic_client=first_transport, model="claude-test") + first_agent = first_client.as_agent( + tools=[first_client.get_web_search_tool(), local_value], + context_providers=[first_history], + default_options={"max_tokens": 64}, + ) + session_id = "anthropic-server-tool-history" + + async with first_agent: + first_response = await first_agent.run( + "Search for Agent Framework and call local_value.", + session=first_agent.create_session(session_id=session_id), + ) + + assert "first turn complete" in first_response.text + assert local_executions == 1 + assert len(first_requests) == 2 + continuation_messages = first_requests[1]["messages"] + assert [message["role"] for message in continuation_messages] == ["user", "assistant", "user"] + assert [block["type"] for block in continuation_messages[1]["content"]] == [ + "server_tool_use", + "tool_use", + ] + assert continuation_messages[2]["content"][0]["tool_use_id"] == "toolu_local" + + persisted = await first_history.get_messages(session_id) + persisted_types = [content.type for message in persisted for content in message.contents] + assert persisted_types.count("function_call") == 2 + assert "function_result" in persisted_types + + replay_requests: list[dict[str, Any]] = [] + + async def create_replay_response(**kwargs: Any) -> BetaMessage: + replay_requests.append(kwargs) + return BetaMessage( + id="msg_replay", + content=[BetaTextBlock(type="text", text="history replay complete")], + model="claude-test", + role="assistant", + stop_reason="end_turn", + type="message", + usage=BetaUsage(input_tokens=1, output_tokens=1), + ) + + replay_transport = MagicMock() + replay_transport.base_url = "https://example.invalid" + replay_transport.beta.messages.create = create_replay_response + replay_history = FileHistoryProvider(tmp_path) + replay_client = AnthropicClient(anthropic_client=replay_transport, model="claude-test") + replay_agent = replay_client.as_agent( + tools=[replay_client.get_web_search_tool(), local_value], + context_providers=[replay_history], + default_options={"max_tokens": 64}, + ) + + async with replay_agent: + replay_response = await replay_agent.run( + "Use the prior result and reply briefly.", + session=replay_agent.create_session(session_id=session_id), + ) + + assert replay_response.text == "history replay complete" + assert local_executions == 1 + assert len(replay_requests) == 1 + replayed_messages = replay_requests[0]["messages"] + server_call_message = next( + message + for message in replayed_messages + if isinstance(message["content"], list) + and any(block["type"] == "server_tool_use" for block in message["content"]) + ) + server_result_message = next( + message + for message in replayed_messages + if isinstance(message["content"], list) + and any(block["type"] == "web_search_tool_result" for block in message["content"]) + ) + local_result_message = next( + message + for message in replayed_messages + if isinstance(message["content"], list) and any(block["type"] == "tool_result" for block in message["content"]) + ) + + assert server_call_message["role"] == "assistant" + assert server_result_message["role"] == "assistant" + assert local_result_message["role"] == "user" + search_result = next( + block for block in server_result_message["content"] if block["type"] == "web_search_tool_result" + ) + assert search_result["content"][0]["encrypted_content"] == "opaque-search-state" + + # Integration Tests @@ -3051,6 +3344,61 @@ async def test_anthropic_client_integration_hosted_tools() -> None: assert response.text is not None +@pytest.mark.flaky +@pytest.mark.integration +@pytest.mark.filterwarnings("ignore::agent_framework._feature_stage.ExperimentalWarning") +@skip_if_anthropic_integration_tests_disabled +async def test_anthropic_client_integration_file_history_replays_web_search(tmp_path: Path) -> None: + """A fresh real-service agent can replay a persisted Anthropic web-search transcript.""" + session_id = "anthropic-web-search-history-integration" + tools = [AnthropicClient.get_web_search_tool()] + first_history = FileHistoryProvider(tmp_path) + first_client = AnthropicClient() + first_agent = first_client.as_agent( + tools=tools, + context_providers=[first_history], + default_options={ + "max_tokens": 512, + "tool_choice": {"mode": "required", "required_function_name": "web_search"}, + }, + ) + + async with first_agent: + first_response = await first_agent.run( + "Use web_search exactly once to find the official GitHub repository for Microsoft Agent Framework. " + "Return the repository URL.", + session=first_agent.create_session(session_id=session_id), + ) + + assert first_response.text + persisted = await first_history.get_messages(session_id) + server_blocks = [ + content.additional_properties.get("anthropic", {}).get("server_tool_block", {}) + for message in persisted + for content in message.contents + if content.type in {"function_call", "function_result"} + ] + assert any(block.get("type") == "server_tool_use" for block in server_blocks) + assert any(block.get("type") == "web_search_tool_result" for block in server_blocks) + + replay_history = FileHistoryProvider(tmp_path) + replay_client = AnthropicClient() + replay_agent = replay_client.as_agent( + tools=tools, + context_providers=[replay_history], + default_options={"max_tokens": 128}, + ) + + async with replay_agent: + replay_response = await replay_agent.run( + "Do not search again. From the prior result, reply with only the repository URL.", + session=replay_agent.create_session(session_id=session_id), + options={"tool_choice": "none"}, + ) + + assert "github.com/microsoft/agent-framework" in replay_response.text.lower() + + @pytest.mark.flaky @pytest.mark.integration @skip_if_anthropic_integration_tests_disabled @@ -3488,38 +3836,96 @@ def test_parse_contents_mcp_tool_result_object_content( assert result[0].type == "mcp_server_tool_result" -def test_parse_contents_web_search_tool_result( +@pytest.mark.parametrize( + ("block_type", "call_id", "block_content", "expected_content"), + [ + ( + "web_search_tool_result", + "srvtoolu_search", + [ + BetaWebSearchResultBlock( + type="web_search_result", + url="https://example.com", + title="Example", + encrypted_content="opaque", + page_age="today", + ) + ], + [ + { + "encrypted_content": "opaque", + "page_age": "today", + "title": "Example", + "type": "web_search_result", + "url": "https://example.com", + } + ], + ), + ( + "web_fetch_tool_result", + "srvtoolu_fetch", + { + "type": "web_fetch_result", + "url": "https://example.com", + "content": { + "type": "document", + "source": { + "type": "text", + "media_type": "text/plain", + "data": "Example", + }, + }, + }, + { + "type": "web_fetch_result", + "url": "https://example.com", + "content": { + "type": "document", + "source": { + "type": "text", + "media_type": "text/plain", + "data": "Example", + }, + }, + }, + ), + ], +) +def test_server_tool_result_survives_message_round_trip( mock_anthropic_client: MagicMock, + block_type: str, + call_id: str, + block_content: Any, + expected_content: Any, ) -> None: - """Test parsing web search tool result.""" + """Server-tool results retain their native type and structured content after persistence.""" client = create_test_anthropic_client(mock_anthropic_client) - # Create mock web search tool result mock_block = MagicMock() - mock_block.type = "web_search_tool_result" - mock_block.tool_use_id = "call_789" - mock_block.content = "Search results" - - result = client._parse_contents_from_anthropic([mock_block]) - - assert len(result) == 1 - assert result[0].type == "function_result" - - -def test_parse_contents_web_fetch_tool_result(mock_anthropic_client: MagicMock) -> None: - """Test parsing web fetch tool result.""" - client = create_test_anthropic_client(mock_anthropic_client) - - # Create mock web fetch tool result - mock_block = MagicMock() - mock_block.type = "web_fetch_tool_result" - mock_block.tool_use_id = "call_101" - mock_block.content = "Fetched content" - - result = client._parse_contents_from_anthropic([mock_block]) - - assert len(result) == 1 - assert result[0].type == "function_result" + mock_block.type = block_type + mock_block.tool_use_id = call_id + mock_block.content = block_content + mock_block.caller = {"type": "direct"} + + parsed = client._parse_contents_from_anthropic([mock_block]) + restored = Message.from_dict(Message(role="assistant", contents=parsed).to_dict()) + replay = client._prepare_message_groups_for_anthropic(restored) + + assert len(parsed) == 1 + assert parsed[0].type == "function_result" + assert replay == [ + { + "role": "assistant", + "content": [ + { + "type": block_type, + "tool_use_id": call_id, + "content": expected_content, + "caller": {"type": "direct"}, + } + ], + } + ] # MCP Tool Configuration Tests From 4dec8f5f73975502815fb5c49662cd49740cb24e Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 26 Oct 2026 17:32:30 +0100 Subject: [PATCH 2/2] Python: Preserve streamed Anthropic server-tool inputs --- .../agent_framework_anthropic/_chat_client.py | 13 +- .../anthropic/tests/test_anthropic_client.py | 133 +++++++++++++----- 2 files changed, 103 insertions(+), 43 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 881db46b56c..23db1c340c8 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -317,6 +317,7 @@ class _AnthropicRequestState: active_call_id: str | None = None active_call_content_type: str | None = None + active_call_is_function_call: bool = False tool_name_aliases: dict[str, str] = field(default_factory=dict[str, str]) @@ -1016,9 +1017,7 @@ def _server_tool_block(content: Content) -> Mapping[str, Any] | None: def _prepare_server_tool_call_for_anthropic(self, content: Content) -> dict[str, Any] | None: """Reconstruct an Anthropic server_tool_use block when this is a hosted call.""" block = self._server_tool_block(content) - is_server_tool = bool(content.hosted) or bool( - content.call_id and content.call_id.startswith(_SERVER_TOOL_USE_ID_PREFIX) - ) + is_server_tool = bool(content.call_id and content.call_id.startswith(_SERVER_TOOL_USE_ID_PREFIX)) if block is not None: block_type = block.get("type") if block_type != "server_tool_use": @@ -1523,6 +1522,7 @@ def _parse_contents_from_anthropic( case "tool_use" | "mcp_tool_use" | "server_tool_use": request_state.active_call_id = content_block.id request_state.active_call_content_type = content_block.type + request_state.active_call_is_function_call = False if content_block.type == "mcp_tool_use": contents.append( Content.from_mcp_server_tool_call( @@ -1548,6 +1548,7 @@ def _parse_contents_from_anthropic( ) ) else: + request_state.active_call_is_function_call = True resolved_tool_name = request_state.tool_name_aliases.get(content_block.name, content_block.name) contents.append( Content.from_function_call( @@ -1800,16 +1801,18 @@ def _parse_contents_from_anthropic( ) ) case "input_json_delta": - # Skip argument deltas for MCP and server tools — execution is handled server-side. - if request_state.active_call_content_type in ("mcp_tool_use", "server_tool_use"): + if not request_state.active_call_is_function_call: pass else: call_id = request_state.active_call_id or "" + hosted = request_state.active_call_content_type == "server_tool_use" contents.append( Content.from_function_call( call_id=call_id, name="", arguments=content_block.partial_json, + informational_only=hosted, + hosted=hosted, merge_mode="append" if streaming else None, raw_representation=content_block, ) diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 78151042fa5..3a5556e01ba 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -421,6 +421,36 @@ def test_prepare_message_for_anthropic_legacy_server_tool_call_uses_id_prefix( ] +def test_prepare_message_for_anthropic_does_not_infer_server_tool_from_hosted( + mock_anthropic_client: MagicMock, +) -> None: + """Provider-neutral hosted calls remain ordinary tool_use without Anthropic provenance.""" + client = create_test_anthropic_client(mock_anthropic_client) + message = Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="hosted-call", + name="external_hosted_tool", + arguments={"value": "test"}, + informational_only=True, + hosted=True, + ) + ], + ) + + result = client._prepare_message_for_anthropic(message) + + assert result["content"] == [ + { + "type": "tool_use", + "id": "hosted-call", + "name": "external_hosted_tool", + "input": {"value": "test"}, + } + ] + + def test_prepare_message_for_anthropic_function_result( mock_anthropic_client: MagicMock, ) -> None: @@ -2045,51 +2075,66 @@ def test_parse_contents_from_anthropic_input_json_delta_no_duplicate_name( assert result[0].arguments == '"San Francisco"}' -def test_parse_contents_server_tool_use_input_json_delta_ignored( +def test_parse_contents_server_tool_use_input_json_delta_is_hosted_fragment( mock_anthropic_client: MagicMock, ) -> None: - """Regression test: input_json_delta events are ignored after a server_tool_use block. - - Server-managed tools have their execution handled server-side, so streaming - input_json_delta events must not produce Content.from_function_call(name='') - entries that would cause Anthropic API 400 errors on subsequent turns. - """ + """Server-tool argument deltas remain hosted and survive finalization and persistence.""" client = create_test_anthropic_client(mock_anthropic_client) request_state = _AnthropicRequestState() - # Simulate a server_tool_use event that establishes the hosted call type. server_tool_content = MagicMock() server_tool_content.type = "server_tool_use" - server_tool_content.id = "srvtool_abc" + server_tool_content.id = "srvtoolu_abc" server_tool_content.name = "web_search" server_tool_content.input = {} - result = client._parse_contents_from_anthropic([server_tool_content], request_state) - # server_tool_use falls through to informational-only function_call (not mcp_tool_use / code_execution) - assert len(result) == 1 - assert result[0].type == "function_call" - assert result[0].informational_only is True - assert result[0].hosted is True - assert result[0].additional_properties == {"anthropic": {"server_tool_block": {"type": "server_tool_use"}}} + opening = client._parse_contents_from_anthropic([server_tool_content], request_state, streaming=True) + assert len(opening) == 1 + assert opening[0].type == "function_call" + assert opening[0].informational_only is True + assert opening[0].hosted is True + assert opening[0].additional_properties == {"anthropic": {"server_tool_block": {"type": "server_tool_use"}}} assert request_state.active_call_content_type == "server_tool_use" + assert request_state.active_call_is_function_call is True + + first_delta_content = MagicMock() + first_delta_content.type = "input_json_delta" + first_delta_content.partial_json = '{"query": "latest' + + first_delta = client._parse_contents_from_anthropic([first_delta_content], request_state, streaming=True) + assert len(first_delta) == 1 + assert first_delta[0].call_id == "srvtoolu_abc" + assert first_delta[0].informational_only is True + assert first_delta[0].hosted is True + assert first_delta[0].arguments == '{"query": "latest' + + second_delta_content = MagicMock() + second_delta_content.type = "input_json_delta" + second_delta_content.partial_json = ' news"}' + + second_delta = client._parse_contents_from_anthropic([second_delta_content], request_state, streaming=True) + response = ChatResponse.from_updates([ + ChatResponseUpdate(role="assistant", contents=opening), + ChatResponseUpdate(contents=first_delta), + ChatResponseUpdate(contents=second_delta), + ]) - # input_json_delta events after server_tool_use must be silently ignored - delta_content = MagicMock() - delta_content.type = "input_json_delta" - delta_content.partial_json = '{"query": "latest news"}' - - result = client._parse_contents_from_anthropic([delta_content], request_state) - assert result == [], "input_json_delta after server_tool_use should produce no content, but got: %r" % result - - # A second delta must also be ignored - delta_content_2 = MagicMock() - delta_content_2.type = "input_json_delta" - delta_content_2.partial_json = '{"extra": true}' + assert len(response.messages[0].contents) == 1 + finalized_call = response.messages[0].contents[0] + assert finalized_call.parse_arguments() == {"query": "latest news"} + assert finalized_call.informational_only is True + assert finalized_call.hosted is True - result = client._parse_contents_from_anthropic([delta_content_2], request_state) - assert result == [], ( - "subsequent input_json_delta after server_tool_use should also be ignored, but got: %r" % result - ) + restored = Message.from_dict(response.messages[0].to_dict()) + prepared = client._prepare_messages_for_anthropic([restored]) + assert prepared[0]["content"] == [ + { + "type": "server_tool_use", + "id": "srvtoolu_abc", + "name": "web_search", + "input": {"query": "latest news"}, + } + ] # Stream Processing Tests @@ -2594,6 +2639,8 @@ def local_tool(value: str) -> str: async def events() -> Any: yield _message_start_event("hosted-response") yield _tool_start_event(block_type="server_tool_use", call_id="hosted-call", name="local_tool") + yield _argument_delta_event('{"value":') + yield _argument_delta_event('"a"}') yield _content_block_stop_event() yield _message_delta_event("pause_turn") yield _message_stop_event() @@ -2610,13 +2657,23 @@ async def events() -> Any: updates = [update async for update in stream] response = await stream.get_final_response() - hosted_calls = [content for update in updates for content in update.contents if content.type == "function_call"] + assert all( + content.informational_only and content.hosted + for update in updates + for content in update.contents + if content.type == "function_call" + ) + hosted_calls = [ + content for message in response.messages for content in message.contents if content.type == "function_call" + ] assert len(hosted_calls) == 1 assert hosted_calls[0].informational_only is True + assert hosted_calls[0].parse_arguments() == {"value": "a"} assert response.finish_reason == "stop" restored = Message.from_dict(response.messages[0].to_dict()) prepared = client._prepare_messages_for_anthropic([restored]) assert prepared[0]["content"][0]["type"] == "server_tool_use" + assert prepared[0]["content"][0]["input"] == {"value": "a"} async def test_concurrent_streams_isolate_tool_state_when_peer_fails( @@ -2709,11 +2766,11 @@ async def consume_primary() -> tuple[list[ChatResponseUpdate], ChatResponse]: assert primary_calls[0].parse_arguments() == {"command": "pwd"} hosted_calls = [content for content in hosted_contents if content.type == "function_call"] - assert len(hosted_calls) == 1 - assert hosted_calls[0].name == "web_search" - assert hosted_calls[0].call_id == "shared-call" - assert hosted_calls[0].arguments == {} - assert hosted_calls[0].informational_only is True + assert [(content.name, content.call_id, content.arguments) for content in hosted_calls] == [ + ("web_search", "shared-call", {}), + ("", "shared-call", '{"query":"framework"}'), + ] + assert all(content.informational_only and content.hosted for content in hosted_calls) async def test_concurrent_streams_keep_approval_requests_request_local(