Skip to content

Commit 6ab97b4

Browse files
authored
fix: parse server_tool_use and advisor_tool_result content blocks (#836)
## Summary `server_tool_use` and `advisor_tool_result` blocks were silently dropped by `parse_message`, so messages that only carried a server-side tool call arrived as `AssistantMessage(content=[])`. This made it impossible to detect that the advisor (or any other server-side tool) had run from the assistant stream alone. This PR surfaces them as two new content block types: - `ServerToolUseBlock(id, name, input)` — mirrors the existing `ToolUseBlock` but for server-executed tools like `advisor`, `web_search`, `web_fetch`. - `AdvisorToolResultBlock(tool_use_id, content)` — `content` is a raw dict because the shape varies by outcome (`advisor_result` / `advisor_redacted_result` / `advisor_tool_result_error`) and callers need to discriminate. ## Test plan - 3 new unit tests in `tests/test_message_parser.py` covering the success, redacted, and server_tool_use cases. Shapes are taken from the canonical API schema (`anthropic` SDK's `BetaServerToolUseBlock` / `BetaAdvisorToolResultBlock`). - All 471 existing tests still pass. - Verified end-to-end against a live advisor call (`CLAUDE_CODE_ENABLE_EXPERIMENTAL_ADVISOR_TOOL=1 claude --advisor opus`): before this change, both blocks arrived as empty `AssistantMessage(content=[])`; after, `ServerToolUseBlock` and `AdvisorToolResultBlock` surface with populated fields. - The `advisor_redacted_result` content variant was exercised live; the plaintext `advisor_result` variant is covered by a synthetic unit test only.
1 parent aa3d023 commit 6ab97b4

File tree

4 files changed

+160
-1
lines changed

4 files changed

+160
-1
lines changed

src/claude_agent_sdk/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@
104104
SdkBeta,
105105
SdkPluginConfig,
106106
SDKSessionInfo,
107+
ServerToolName,
108+
ServerToolResultBlock,
109+
ServerToolUseBlock,
107110
SessionKey,
108111
SessionListSubkeysKey,
109112
SessionMessage,
@@ -555,6 +558,9 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
555558
"ThinkingConfigDisabled",
556559
"ToolUseBlock",
557560
"ToolResultBlock",
561+
"ServerToolName",
562+
"ServerToolUseBlock",
563+
"ServerToolResultBlock",
558564
"ContentBlock",
559565
"ContextUsageCategory",
560566
"ContextUsageResponse",

src/claude_agent_sdk/_internal/message_parser.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
RateLimitEvent,
1313
RateLimitInfo,
1414
ResultMessage,
15+
ServerToolResultBlock,
16+
ServerToolUseBlock,
1517
StreamEvent,
1618
SystemMessage,
1719
TaskNotificationMessage,
@@ -127,6 +129,21 @@ def parse_message(data: dict[str, Any]) -> Message | None:
127129
is_error=block.get("is_error"),
128130
)
129131
)
132+
case "server_tool_use":
133+
content_blocks.append(
134+
ServerToolUseBlock(
135+
id=block["id"],
136+
name=block["name"],
137+
input=block["input"],
138+
)
139+
)
140+
case "advisor_tool_result":
141+
content_blocks.append(
142+
ServerToolResultBlock(
143+
tool_use_id=block["tool_use_id"],
144+
content=block["content"],
145+
)
146+
)
130147

131148
return AssistantMessage(
132149
content=content_blocks,

src/claude_agent_sdk/types.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -889,7 +889,54 @@ class ToolResultBlock:
889889
is_error: bool | None = None
890890

891891

892-
ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock
892+
ServerToolName = Literal[
893+
"advisor",
894+
"web_search",
895+
"web_fetch",
896+
"code_execution",
897+
"bash_code_execution",
898+
"text_editor_code_execution",
899+
"tool_search_tool_regex",
900+
"tool_search_tool_bm25",
901+
]
902+
903+
904+
@dataclass
905+
class ServerToolUseBlock:
906+
"""Server-side tool use block (e.g. advisor, web_search, web_fetch).
907+
908+
These are tools the API executes server-side on the model's behalf, so they
909+
appear in the message stream alongside regular `tool_use` blocks but the
910+
caller never needs to return a result. `name` is a discriminator — branch
911+
on it to know which server tool was invoked.
912+
"""
913+
914+
id: str
915+
name: ServerToolName
916+
input: dict[str, Any]
917+
918+
919+
@dataclass
920+
class ServerToolResultBlock:
921+
"""Result block returned for a server-side tool call.
922+
923+
Mirrors `ToolResultBlock`'s shape. `content` is the raw dict from the
924+
API, opaque to this layer — callers that care about a specific server
925+
tool's result schema can inspect `content["type"]`.
926+
"""
927+
928+
tool_use_id: str
929+
content: dict[str, Any]
930+
931+
932+
ContentBlock = (
933+
TextBlock
934+
| ThinkingBlock
935+
| ToolUseBlock
936+
| ToolResultBlock
937+
| ServerToolUseBlock
938+
| ServerToolResultBlock
939+
)
893940

894941

895942
# Message types

tests/test_message_parser.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
AssistantMessage,
99
RateLimitEvent,
1010
ResultMessage,
11+
ServerToolResultBlock,
12+
ServerToolUseBlock,
1113
SystemMessage,
1214
TaskNotificationMessage,
1315
TaskProgressMessage,
@@ -274,6 +276,93 @@ def test_parse_assistant_message_with_thinking(self):
274276
assert isinstance(message.content[1], TextBlock)
275277
assert message.content[1].text == "Here's my response"
276278

279+
def test_parse_assistant_message_with_server_tool_use(self):
280+
"""server_tool_use blocks (e.g. advisor, web_search) are preserved.
281+
282+
Previously these were dropped, leaving an empty content list on
283+
messages that only contained a server tool call.
284+
"""
285+
data = {
286+
"type": "assistant",
287+
"message": {
288+
"content": [
289+
{
290+
"type": "server_tool_use",
291+
"id": "srvtoolu_01ABC",
292+
"name": "advisor",
293+
"input": {},
294+
},
295+
],
296+
"model": "claude-sonnet-4-5",
297+
},
298+
}
299+
message = parse_message(data)
300+
assert isinstance(message, AssistantMessage)
301+
assert len(message.content) == 1
302+
assert isinstance(message.content[0], ServerToolUseBlock)
303+
assert message.content[0].id == "srvtoolu_01ABC"
304+
assert message.content[0].name == "advisor"
305+
assert message.content[0].input == {}
306+
307+
def test_parse_assistant_message_with_server_tool_result(self):
308+
"""Server-side tool result blocks (e.g. advisor) surface with their raw content dict.
309+
310+
`content` is passed through as a dict since its shape is tool-specific
311+
(advisor emits advisor_result / advisor_redacted_result /
312+
advisor_tool_result_error; other server tools use different shapes).
313+
"""
314+
data = {
315+
"type": "assistant",
316+
"message": {
317+
"content": [
318+
{
319+
"type": "advisor_tool_result",
320+
"tool_use_id": "srvtoolu_01ABC",
321+
"content": {
322+
"type": "advisor_result",
323+
"text": "Consider edge cases around empty input.",
324+
},
325+
},
326+
],
327+
"model": "claude-sonnet-4-5",
328+
},
329+
}
330+
message = parse_message(data)
331+
assert isinstance(message, AssistantMessage)
332+
assert len(message.content) == 1
333+
result_block = message.content[0]
334+
assert isinstance(result_block, ServerToolResultBlock)
335+
assert result_block.tool_use_id == "srvtoolu_01ABC"
336+
assert result_block.content == {
337+
"type": "advisor_result",
338+
"text": "Consider edge cases around empty input.",
339+
}
340+
341+
def test_parse_assistant_message_with_redacted_advisor_result(self):
342+
"""External API users get advisor output as an encrypted blob in the content dict."""
343+
data = {
344+
"type": "assistant",
345+
"message": {
346+
"content": [
347+
{
348+
"type": "advisor_tool_result",
349+
"tool_use_id": "srvtoolu_01ABC",
350+
"content": {
351+
"type": "advisor_redacted_result",
352+
"encrypted_content": "EuYDCioIDhgC...",
353+
},
354+
},
355+
],
356+
"model": "claude-sonnet-4-5",
357+
},
358+
}
359+
message = parse_message(data)
360+
assert isinstance(message, AssistantMessage)
361+
result_block = message.content[0]
362+
assert isinstance(result_block, ServerToolResultBlock)
363+
assert result_block.content["type"] == "advisor_redacted_result"
364+
assert result_block.content["encrypted_content"] == "EuYDCioIDhgC..."
365+
277366
def test_parse_assistant_message_with_usage(self):
278367
"""Per-turn usage is preserved on AssistantMessage.
279368

0 commit comments

Comments
 (0)