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
2 changes: 2 additions & 0 deletions src/claude_agent_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
TERMINAL_TASK_STATUSES,
AgentDefinition,
AssistantMessage,
BackgroundTaskLateCompletionEvent,
BaseHookInput,
CanUseTool,
ClaudeAgentOptions,
Expand Down Expand Up @@ -554,6 +555,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
"TERMINAL_TASK_STATUSES",
"TaskUsage",
"ResultMessage",
"BackgroundTaskLateCompletionEvent",
"DeferredToolUse",
"RateLimitEvent",
"RateLimitInfo",
Expand Down
51 changes: 49 additions & 2 deletions src/claude_agent_sdk/_internal/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""

Expand Down Expand Up @@ -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()
Expand Down
101 changes: 101 additions & 0 deletions src/claude_agent_sdk/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1320,6 +1366,7 @@ class HookEventMessage(SystemMessage):
| ResultMessage
| StreamEvent
| RateLimitEvent
| BackgroundTaskLateCompletionEvent
)


Expand Down Expand Up @@ -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):
Expand Down