Skip to content

Commit a03b285

Browse files
Python: Correlate Anthropic server tool calls with results in transcript order
A call_id can be reused by a later occurrence, so a global set of result call IDs let an earlier result mark a later pending server tool call as settled. It was then sent as tool_use and Anthropic rejected the request for a missing tool_result. Walk the transcript in order instead: each result settles the open call occurrence named by its Content.id, otherwise the earliest open occurrence with that call_id, and never a call that appears after it. Pending hosted calls are tracked by content identity rather than call_id. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 5035339 commit a03b285

2 files changed

Lines changed: 132 additions & 22 deletions

File tree

‎python/packages/anthropic/agent_framework_anthropic/_chat_client.py‎

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -901,25 +901,11 @@ def _prepare_messages_for_anthropic(self, messages: Sequence[Message]) -> list[d
901901
else:
902902
msgs = list(messages)
903903

904-
# A hosted (server tool) call without a result is still running on Anthropic's side, for example
905-
# when Claude called a local tool in parallel. It has to be sent back as ``server_tool_use``, or
906-
# Anthropic expects a local ``tool_result`` for it.
907-
result_call_ids = {
908-
content.call_id for msg in msgs for content in msg.contents if content.type == "function_result"
909-
}
910-
pending_hosted_call_ids = {
911-
content.call_id
912-
for msg in msgs
913-
for content in msg.contents
914-
if content.type == "function_call"
915-
and content.hosted
916-
and content.call_id
917-
and content.call_id not in result_call_ids
918-
}
904+
pending_hosted_calls = self._pending_hosted_function_calls(msgs)
919905

920906
result: list[dict[str, Any]] = []
921907
for msg in msgs:
922-
result.extend(self._prepare_message_groups_for_anthropic(msg, pending_hosted_call_ids))
908+
result.extend(self._prepare_message_groups_for_anthropic(msg, pending_hosted_calls))
923909

924910
# Anthropic requires the conversation to end with a user message.
925911
# Append a synthetic user turn so chained agent outputs work as
@@ -929,11 +915,43 @@ def _prepare_messages_for_anthropic(self, messages: Sequence[Message]) -> list[d
929915

930916
return result
931917

918+
@staticmethod
919+
def _pending_hosted_function_calls(messages: Sequence[Message]) -> set[int]:
920+
"""Return the identities (``id()``) of hosted function calls that have no result yet.
921+
922+
A hosted (server tool) call without a result is still running on Anthropic's side, for example
923+
when Claude called a local tool in parallel. It has to be sent back as ``server_tool_use``, or
924+
Anthropic expects a local ``tool_result`` for it.
925+
926+
A ``call_id`` is not unique across a transcript: a later occurrence may reuse it. Calls and results
927+
are therefore correlated in transcript order. A result settles the open call occurrence with the
928+
same ``call_id`` whose ``Content.id`` it carries, and otherwise the earliest open occurrence with
929+
that ``call_id``. A result never settles a call that appears after it.
930+
"""
931+
open_calls_by_call_id: dict[str, list[Content]] = {}
932+
for message in messages:
933+
for content in message.contents:
934+
if not content.call_id:
935+
continue
936+
if content.type == "function_call":
937+
open_calls_by_call_id.setdefault(content.call_id, []).append(content)
938+
elif content.type == "function_result" and (open_calls := open_calls_by_call_id.get(content.call_id)):
939+
settled_index = next(
940+
(
941+
index
942+
for index, call in enumerate(open_calls)
943+
if content.id is not None and call.id == content.id
944+
),
945+
0,
946+
)
947+
open_calls.pop(settled_index)
948+
return {id(call) for open_calls in open_calls_by_call_id.values() for call in open_calls if call.hosted}
949+
932950
def _prepare_message_groups_for_anthropic(
933-
self, message: Message, pending_hosted_call_ids: Set[str] | None = None
951+
self, message: Message, pending_hosted_calls: Set[int] | None = None
934952
) -> list[dict[str, Any]]:
935953
"""Prepare a Message and split Anthropic content blocks into valid role groups."""
936-
prepared_message = self._prepare_message_for_anthropic(message, pending_hosted_call_ids)
954+
prepared_message = self._prepare_message_for_anthropic(message, pending_hosted_calls)
937955
content = prepared_message.get("content")
938956
if not isinstance(content, list):
939957
return [prepared_message]
@@ -983,14 +1001,16 @@ def _message_has_tool_use(self, message: dict[str, Any]) -> bool:
9831001
return False
9841002

9851003
def _prepare_message_for_anthropic(
986-
self, message: Message, pending_hosted_call_ids: Set[str] | None = None
1004+
self, message: Message, pending_hosted_calls: Set[int] | None = None
9871005
) -> dict[str, Any]:
9881006
"""Prepare a Message for the Anthropic client.
9891007
9901008
Args:
9911009
message: The Message to convert.
992-
pending_hosted_call_ids: Call IDs of hosted (server tool) calls that have no result yet.
993-
These are sent as ``server_tool_use`` blocks.
1010+
pending_hosted_calls: Identities (``id()``) of the hosted (server tool) call contents that have
1011+
no result yet, from ``_pending_hosted_function_calls``. These are sent as ``server_tool_use``
1012+
blocks. An identity rather than a ``call_id`` is used because a later occurrence may reuse
1013+
the ``call_id`` of a call that already has its result.
9941014
9951015
Returns:
9961016
A dictionary representing the message in Anthropic format.
@@ -1023,7 +1043,7 @@ def _prepare_message_for_anthropic(
10231043
else:
10241044
logger.debug(f"Ignoring unsupported data content media type: {content.media_type} for now")
10251045
case "function_call":
1026-
pending_hosted_call = content.hosted and content.call_id in (pending_hosted_call_ids or ())
1046+
pending_hosted_call = content.hosted and id(content) in (pending_hosted_calls or ())
10271047
a_content.append({
10281048
"type": "server_tool_use" if pending_hosted_call else "tool_use",
10291049
"id": content.call_id,

‎python/packages/anthropic/tests/test_anthropic_client.py‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -891,6 +891,96 @@ def test_prepare_messages_keeps_completed_server_tool_call_paired_with_its_resul
891891
assert tool_result_ids == ["toolu_local", "srvtoolu_search"]
892892

893893

894+
@pytest.mark.parametrize("reload_history", [False, True], ids=["in_memory", "reloaded"])
895+
def test_prepare_messages_keeps_later_server_tool_call_pending_when_call_id_is_reused(
896+
mock_anthropic_client: MagicMock,
897+
reload_history: bool,
898+
) -> None:
899+
"""An earlier result for a reused call_id does not settle a later pending server tool call."""
900+
client = create_test_anthropic_client(mock_anthropic_client)
901+
messages = [
902+
Message(role="user", contents=["Search the web."]),
903+
Message(
904+
role="assistant",
905+
contents=[
906+
Content.from_function_call(
907+
call_id="srvtoolu_search", name="web_search", arguments={"query": "first"}, hosted=True
908+
),
909+
Content.from_function_result(call_id="srvtoolu_search", result="first results"),
910+
Content.from_text("First search done."),
911+
],
912+
),
913+
Message(role="user", contents=["Call local_value and search again."]),
914+
Message(role="assistant", contents=_parallel_local_and_server_tool_call_contents(client)),
915+
Message(role="tool", contents=[Content.from_function_result(call_id="toolu_local", result="local-ok")]),
916+
]
917+
if reload_history:
918+
messages = [Message.from_dict(message.to_dict()) for message in messages]
919+
920+
result = client._prepare_messages_for_anthropic(messages)
921+
922+
blocks = [
923+
(block["type"], block.get("id") or block.get("tool_use_id"))
924+
for m in result
925+
for block in m["content"]
926+
if isinstance(m["content"], list) and block["type"] != "text"
927+
]
928+
assert blocks == [
929+
("tool_use", "srvtoolu_search"),
930+
("tool_result", "srvtoolu_search"),
931+
("tool_use", "toolu_local"),
932+
("server_tool_use", "srvtoolu_search"),
933+
("tool_result", "toolu_local"),
934+
]
935+
936+
937+
def test_prepare_messages_settles_the_server_tool_call_occurrence_named_by_the_result_id(
938+
mock_anthropic_client: MagicMock,
939+
) -> None:
940+
"""A result carrying Content.id settles that occurrence, not the earliest one sharing its call_id."""
941+
client = create_test_anthropic_client(mock_anthropic_client)
942+
messages = [
943+
Message(role="user", contents=["Search the web twice."]),
944+
Message(
945+
role="assistant",
946+
contents=[
947+
Content.from_function_call(
948+
call_id="srvtoolu_search", id="occurrence_1", name="web_search", arguments={}, hosted=True
949+
),
950+
Content.from_function_call(
951+
call_id="srvtoolu_search", id="occurrence_2", name="web_search", arguments={}, hosted=True
952+
),
953+
Content.from_function_result(call_id="srvtoolu_search", id="occurrence_2", result="results"),
954+
],
955+
),
956+
]
957+
958+
result = client._prepare_messages_for_anthropic(messages)
959+
960+
assert [block["type"] for block in result[1]["content"]] == ["server_tool_use", "tool_use"]
961+
962+
963+
def test_prepare_messages_does_not_settle_a_server_tool_call_with_an_earlier_result(
964+
mock_anthropic_client: MagicMock,
965+
) -> None:
966+
"""A result that precedes every call with its call_id leaves a later server tool call pending."""
967+
client = create_test_anthropic_client(mock_anthropic_client)
968+
messages = [
969+
Message(role="user", contents=["Search the web."]),
970+
Message(role="tool", contents=[Content.from_function_result(call_id="srvtoolu_search", result="stale")]),
971+
Message(
972+
role="assistant",
973+
contents=[
974+
Content.from_function_call(call_id="srvtoolu_search", name="web_search", arguments={}, hosted=True)
975+
],
976+
),
977+
]
978+
979+
result = client._prepare_messages_for_anthropic(messages)
980+
981+
assert [block["type"] for block in result[-1]["content"]] == ["server_tool_use"]
982+
983+
894984
def test_prepare_messages_for_anthropic_does_not_append_after_tool_use(
895985
mock_anthropic_client: MagicMock,
896986
) -> None:

0 commit comments

Comments
 (0)