Skip to content

Commit f5a1b67

Browse files
authored
feat: support "defer" hook decision and ResultMessage.deferred_tool_use (#865)
## Summary Brings the Python SDK to parity with the TypeScript SDK for the PreToolUse `"defer"` round trip. The CLI has emitted `deferred_tool_use` on result messages since v2.1.89, but the Python SDK was dropping it. - `PreToolUseHookSpecificOutput.permissionDecision` now accepts `"defer"` alongside `"allow" | "deny" | "ask"` - New `DeferredToolUse` dataclass (`id`, `name`, `input`) — mirrors the TS `SDKDeferredToolUse` type - `ResultMessage.deferred_tool_use: DeferredToolUse | None` — populated when a PreToolUse hook deferred a tool call - `message_parser` extracts the field from CLI result output - `DeferredToolUse` exported from the package root `DeferredToolUse` is intentionally a separate type from `ToolUseBlock` (despite identical fields) to keep `ContentBlock` isinstance checks unambiguous and match the TS SDK's typing. ## Test plan - [x] `ruff check` / `ruff format` — clean - [x] `mypy src/` — clean - [x] `pytest tests/test_message_parser.py tests/test_types.py` — 94 passed - [x] New test `test_parse_result_message_with_deferred_tool_use` covers the parse path - [x] Existing `test_parse_result_message_optional_fields_absent` extended to assert `deferred_tool_use is None` when absent
1 parent 04a39ac commit f5a1b67

4 files changed

Lines changed: 52 additions & 1 deletion

File tree

src/claude_agent_sdk/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
ContentBlock,
6565
ContextUsageCategory,
6666
ContextUsageResponse,
67+
DeferredToolUse,
6768
HookCallback,
6869
HookContext,
6970
HookInput,
@@ -544,6 +545,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
544545
"TaskNotificationStatus",
545546
"TaskUsage",
546547
"ResultMessage",
548+
"DeferredToolUse",
547549
"RateLimitEvent",
548550
"RateLimitInfo",
549551
"RateLimitStatus",

src/claude_agent_sdk/_internal/message_parser.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from ..types import (
88
AssistantMessage,
99
ContentBlock,
10+
DeferredToolUse,
1011
Message,
1112
MirrorErrorMessage,
1213
RateLimitEvent,
@@ -221,6 +222,7 @@ def parse_message(data: dict[str, Any]) -> Message | None:
221222

222223
case "result":
223224
try:
225+
deferred = data.get("deferred_tool_use")
224226
return ResultMessage(
225227
subtype=data["subtype"],
226228
duration_ms=data["duration_ms"],
@@ -235,6 +237,13 @@ def parse_message(data: dict[str, Any]) -> Message | None:
235237
structured_output=data.get("structured_output"),
236238
model_usage=data.get("modelUsage"),
237239
permission_denials=data.get("permission_denials"),
240+
deferred_tool_use=DeferredToolUse(
241+
id=deferred["id"],
242+
name=deferred["name"],
243+
input=deferred["input"],
244+
)
245+
if deferred
246+
else None,
238247
errors=data.get("errors"),
239248
uuid=data.get("uuid"),
240249
)

src/claude_agent_sdk/types.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,7 @@ class PreToolUseHookSpecificOutput(TypedDict):
390390
"""Hook-specific output for PreToolUse events."""
391391

392392
hookEventName: Literal["PreToolUse"]
393-
permissionDecision: NotRequired[Literal["allow", "deny", "ask"]]
393+
permissionDecision: NotRequired[Literal["allow", "deny", "ask", "defer"]]
394394
permissionDecisionReason: NotRequired[str]
395395
updatedInput: NotRequired[dict[str, Any]]
396396
additionalContext: NotRequired[str]
@@ -1104,6 +1104,20 @@ class MirrorErrorMessage(SystemMessage):
11041104
error: str = ""
11051105

11061106

1107+
@dataclass
1108+
class DeferredToolUse:
1109+
"""Tool use that was deferred by a PreToolUse hook returning ``"defer"``.
1110+
1111+
When a PreToolUse hook returns ``permissionDecision: "defer"``, the run
1112+
stops and the result message carries the deferred tool call here so the
1113+
caller can inspect it and decide whether to resume.
1114+
"""
1115+
1116+
id: str
1117+
name: str
1118+
input: dict[str, Any]
1119+
1120+
11071121
@dataclass
11081122
class ResultMessage:
11091123
"""Result message with cost and usage information."""
@@ -1121,6 +1135,7 @@ class ResultMessage:
11211135
structured_output: Any = None
11221136
model_usage: dict[str, Any] | None = None
11231137
permission_denials: list[Any] | None = None
1138+
deferred_tool_use: DeferredToolUse | None = None
11241139
errors: list[str] | None = None
11251140
uuid: str | None = None
11261141

tests/test_message_parser.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from claude_agent_sdk._internal.message_parser import parse_message
77
from claude_agent_sdk.types import (
88
AssistantMessage,
9+
DeferredToolUse,
910
RateLimitEvent,
1011
ResultMessage,
1112
ServerToolResultBlock,
@@ -914,9 +915,33 @@ def test_parse_result_message_optional_fields_absent(self):
914915
assert isinstance(message, ResultMessage)
915916
assert message.model_usage is None
916917
assert message.permission_denials is None
918+
assert message.deferred_tool_use is None
917919
assert message.errors is None
918920
assert message.uuid is None
919921

922+
def test_parse_result_message_with_deferred_tool_use(self):
923+
"""ResultMessage parses deferred_tool_use into a DeferredToolUse."""
924+
data = {
925+
"type": "result",
926+
"subtype": "success",
927+
"duration_ms": 1200,
928+
"duration_api_ms": 900,
929+
"is_error": False,
930+
"num_turns": 1,
931+
"session_id": "session_123",
932+
"deferred_tool_use": {
933+
"id": "toolu_01abc",
934+
"name": "Bash",
935+
"input": {"command": "rm -rf /tmp/scratch"},
936+
},
937+
}
938+
message = parse_message(data)
939+
assert isinstance(message, ResultMessage)
940+
assert isinstance(message.deferred_tool_use, DeferredToolUse)
941+
assert message.deferred_tool_use.id == "toolu_01abc"
942+
assert message.deferred_tool_use.name == "Bash"
943+
assert message.deferred_tool_use.input == {"command": "rm -rf /tmp/scratch"}
944+
920945
def test_parse_result_message_with_errors(self):
921946
"""Test that ResultMessage preserves the errors field from error results.
922947

0 commit comments

Comments
 (0)