Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions python/packages/anthropic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
135 changes: 126 additions & 9 deletions python/packages/anthropic/agent_framework_anthropic/_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
TextSpanRegion,
UsageDetails,
add_usage_details,
get_namespaced_value,
tool,
)
from agent_framework._settings import SecretString, load_settings
Expand All @@ -39,6 +40,7 @@
ChatClientException,
ChatClientInvalidAuthException,
ChatClientInvalidRequestException,
ContentError,
ResponseInvalidatedException,
)
from agent_framework.observability import ChatTelemetryLayer
Expand Down Expand Up @@ -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
Expand All @@ -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


Expand Down Expand Up @@ -284,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])


Expand Down Expand Up @@ -945,6 +979,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

Expand All @@ -964,6 +1000,72 @@ 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.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(),
Comment thread
eavanvalkenburg marked this conversation as resolved.
}
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.

Expand Down Expand Up @@ -1001,14 +1103,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":
Expand Down Expand Up @@ -1415,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(
Expand All @@ -1440,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(
Expand All @@ -1449,6 +1558,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=(
Comment thread
eavanvalkenburg marked this conversation as resolved.
_server_tool_block_metadata(content_block)
if content_block.type == "server_tool_use"
else None
),
raw_representation=content_block,
)
)
Expand Down Expand Up @@ -1486,6 +1600,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,
)
)
Expand Down Expand Up @@ -1686,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,
)
Expand Down
Loading
Loading