diff --git a/src/claude_agent_sdk/__init__.py b/src/claude_agent_sdk/__init__.py index 31f3df5f1..52564186b 100644 --- a/src/claude_agent_sdk/__init__.py +++ b/src/claude_agent_sdk/__init__.py @@ -59,6 +59,7 @@ TERMINAL_TASK_STATUSES, AgentDefinition, AssistantMessage, + BackgroundTaskLateCompletionEvent, BaseHookInput, CanUseTool, ClaudeAgentOptions, @@ -554,6 +555,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any: "TERMINAL_TASK_STATUSES", "TaskUsage", "ResultMessage", + "BackgroundTaskLateCompletionEvent", "DeferredToolUse", "RateLimitEvent", "RateLimitInfo", diff --git a/src/claude_agent_sdk/_internal/client.py b/src/claude_agent_sdk/_internal/client.py index 010025b94..a8e5b769a 100644 --- a/src/claude_agent_sdk/_internal/client.py +++ b/src/claude_agent_sdk/_internal/client.py @@ -7,10 +7,15 @@ from typing import Any from ..types import ( + TERMINAL_TASK_STATUSES, + BackgroundTaskLateCompletionEvent, ClaudeAgentOptions, HookEvent, HookMatcher, Message, + ResultMessage, + TaskNotificationMessage, + TaskUpdatedMessage, ) from .message_parser import parse_message from .query import Query @@ -25,6 +30,32 @@ from .transport.subprocess_cli import SubprocessCLITransport +def _maybe_late_completion( + message: Message, +) -> BackgroundTaskLateCompletionEvent | None: + """Return BackgroundTaskLateCompletionEvent if message is a terminal + background-task lifecycle event that arrived after turn boundary, else None. + """ + if isinstance(message, TaskNotificationMessage): + if message.status in TERMINAL_TASK_STATUSES: + return BackgroundTaskLateCompletionEvent( + task_id=message.task_id, + status=message.status, + source_message=message, + ) + elif ( + isinstance(message, TaskUpdatedMessage) + and message.status is not None + and message.status in TERMINAL_TASK_STATUSES + ): + return BackgroundTaskLateCompletionEvent( + task_id=message.task_id, + status=message.status, + source_message=message, + ) + return None + + class InternalClient: """Internal client implementation.""" @@ -219,11 +250,27 @@ async def _on_mirror_error(key: Any, error: str) -> None: # Stream input in background for async iterables query.spawn_task(query.stream_input(prompt)) - # Yield parsed messages, skipping unknown message types + # Yield parsed messages, skipping unknown message types. + # Track whether we've passed the turn boundary (ResultMessage) + # so we can detect background tasks that complete post-turn. + past_turn_boundary = False async for data in query.receive_messages(): message = parse_message(data) - if message is not None: + if message is None: + continue + + if isinstance(message, ResultMessage): + past_turn_boundary = True yield message + continue + + if past_turn_boundary: + late_event = _maybe_late_completion(message) + if late_event is not None: + yield late_event + continue + + yield message finally: await query.close() diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index 705648030..d3802c77b 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -193,6 +193,30 @@ def from_dict(cls, data: dict[str, Any]) -> "PermissionUpdate": ) +# Governance hook types +class GovernanceDecision(TypedDict, total=False): + """Decision returned by a governance hook for a pending tool call. + + Fields: + allowed: Whether the tool call is permitted to proceed. Required. + reason: Optional human-readable explanation for the decision. When the + tool call is blocked (``allowed=False``) this message is forwarded + to the model as a rejection notice. + modified_input: If provided and ``allowed=True``, the tool executes with + this dict instead of the original input. Ignored when ``allowed=False``. + """ + + allowed: Required[bool] + reason: str + modified_input: dict[str, Any] + + +GovernanceHook = Callable[ + [str, dict[str, Any], "ToolPermissionContext"], + "GovernanceDecision | Awaitable[GovernanceDecision]", +] + + # Tool callback types @dataclass class ToolPermissionContext: @@ -1165,6 +1189,28 @@ class TaskUpdatedMessage(SystemMessage): uuid: str | None = None +@dataclass +class BackgroundTaskLateCompletionEvent: + """Synthetic SDK event when a background task completes after the turn boundary. + + Background tasks can finish after the main agent emits its ResultMessage. + Without this event, those completions silently drop. This event is synthesised + whenever a terminal task lifecycle message arrives after ResultMessage. + + Consumers can call ClaudeSDKClient.send_message() upon receiving this event + to re-enter the agent loop. + + Attributes: + task_id: Unique identifier of the background task that completed. + status: Terminal status: "completed", "failed", "stopped", or "killed". + source_message: The raw typed message that triggered this event. + """ + + task_id: str + status: str + source_message: "TaskNotificationMessage | TaskUpdatedMessage" + + @dataclass class MirrorErrorMessage(SystemMessage): """System message emitted when a :meth:`SessionStore.append` call fails. @@ -1320,6 +1366,7 @@ class HookEventMessage(SystemMessage): | ResultMessage | StreamEvent | RateLimitEvent + | BackgroundTaskLateCompletionEvent ) @@ -1993,6 +2040,60 @@ class ClaudeAgentOptions: header. """ + on_compaction_start: "Callable[[CompactionEvent], Awaitable[None]] | None" = None + """Async callback invoked just before context compaction begins.""" + + on_compaction_end: "Callable[[CompactionEvent], Awaitable[None]] | None" = None + """Async callback invoked just after context compaction completes.""" + + on_context_window_threshold: "Callable[[ContextWindowThresholdEvent], Awaitable[None]] | None" = None + """Async callback invoked when context window usage exceeds the configured threshold.""" + + context_window_threshold_pct: float = 0.8 + """Fraction (0.0–1.0) at which on_context_window_threshold fires. Default 0.8.""" + + context_window_size: int | None = None + """Model's maximum context window size in tokens. Required for threshold tracking.""" + + +# --------------------------------------------------------------------------- +# Session lifecycle event types (used by on_compaction_* / on_context_window_*) +# --------------------------------------------------------------------------- + + +@dataclass +class CompactionEvent: + """Event passed to on_compaction_start and on_compaction_end callbacks. + + Attributes: + trigger: Why compaction was triggered: "auto" or "manual". + custom_instructions: Custom compaction instructions if any. + session_id: Session identifier. + raw: Raw data dict from the CLI PreCompact hook payload. + """ + + trigger: str + session_id: str | None = None + raw: dict[str, Any] = field(default_factory=dict) + custom_instructions: str | None = None + + +@dataclass +class ContextWindowThresholdEvent: + """Event passed to on_context_window_threshold callback. + + Attributes: + pct_used: Fraction of context window used (0.0–1.0). + tokens_used: Total tokens currently filling the context window. + session_id: Session identifier. + raw_usage: Raw usage dict from the AssistantMessage. + """ + + pct_used: float + tokens_used: int + session_id: str | None = None + raw_usage: dict[str, Any] = field(default_factory=dict) + # SDK Control Protocol class SDKControlInterruptRequest(TypedDict):