Fix project isolation: Make loadChatHistory respect active project sessions
- Modified loadChatHistory() to check for active project before fetching all sessions - When active project exists, use project.sessions instead of fetching from API - Added detailed console logging to debug session filtering - This prevents ALL sessions from appearing in every project's sidebar Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
365
.venv/lib/python3.11/site-packages/claude_agent_sdk/__init__.py
Normal file
365
.venv/lib/python3.11/site-packages/claude_agent_sdk/__init__.py
Normal file
@@ -0,0 +1,365 @@
|
||||
"""Claude SDK for Python."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from ._errors import (
|
||||
ClaudeSDKError,
|
||||
CLIConnectionError,
|
||||
CLIJSONDecodeError,
|
||||
CLINotFoundError,
|
||||
ProcessError,
|
||||
)
|
||||
from ._internal.transport import Transport
|
||||
from ._version import __version__
|
||||
from .client import ClaudeSDKClient
|
||||
from .query import query
|
||||
from .types import (
|
||||
AgentDefinition,
|
||||
AssistantMessage,
|
||||
BaseHookInput,
|
||||
CanUseTool,
|
||||
ClaudeAgentOptions,
|
||||
ContentBlock,
|
||||
HookCallback,
|
||||
HookContext,
|
||||
HookInput,
|
||||
HookJSONOutput,
|
||||
HookMatcher,
|
||||
McpSdkServerConfig,
|
||||
McpServerConfig,
|
||||
Message,
|
||||
PermissionMode,
|
||||
PermissionResult,
|
||||
PermissionResultAllow,
|
||||
PermissionResultDeny,
|
||||
PermissionUpdate,
|
||||
PostToolUseHookInput,
|
||||
PreCompactHookInput,
|
||||
PreToolUseHookInput,
|
||||
ResultMessage,
|
||||
SandboxIgnoreViolations,
|
||||
SandboxNetworkConfig,
|
||||
SandboxSettings,
|
||||
SdkBeta,
|
||||
SdkPluginConfig,
|
||||
SettingSource,
|
||||
StopHookInput,
|
||||
SubagentStopHookInput,
|
||||
SystemMessage,
|
||||
TextBlock,
|
||||
ThinkingBlock,
|
||||
ToolPermissionContext,
|
||||
ToolResultBlock,
|
||||
ToolUseBlock,
|
||||
UserMessage,
|
||||
UserPromptSubmitHookInput,
|
||||
)
|
||||
|
||||
# MCP Server Support
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SdkMcpTool(Generic[T]):
|
||||
"""Definition for an SDK MCP tool."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
input_schema: type[T] | dict[str, Any]
|
||||
handler: Callable[[T], Awaitable[dict[str, Any]]]
|
||||
|
||||
|
||||
def tool(
|
||||
name: str, description: str, input_schema: type | dict[str, Any]
|
||||
) -> Callable[[Callable[[Any], Awaitable[dict[str, Any]]]], SdkMcpTool[Any]]:
|
||||
"""Decorator for defining MCP tools with type safety.
|
||||
|
||||
Creates a tool that can be used with SDK MCP servers. The tool runs
|
||||
in-process within your Python application, providing better performance
|
||||
than external MCP servers.
|
||||
|
||||
Args:
|
||||
name: Unique identifier for the tool. This is what Claude will use
|
||||
to reference the tool in function calls.
|
||||
description: Human-readable description of what the tool does.
|
||||
This helps Claude understand when to use the tool.
|
||||
input_schema: Schema defining the tool's input parameters.
|
||||
Can be either:
|
||||
- A dictionary mapping parameter names to types (e.g., {"text": str})
|
||||
- A TypedDict class for more complex schemas
|
||||
- A JSON Schema dictionary for full validation
|
||||
|
||||
Returns:
|
||||
A decorator function that wraps the tool implementation and returns
|
||||
an SdkMcpTool instance ready for use with create_sdk_mcp_server().
|
||||
|
||||
Example:
|
||||
Basic tool with simple schema:
|
||||
>>> @tool("greet", "Greet a user", {"name": str})
|
||||
... async def greet(args):
|
||||
... return {"content": [{"type": "text", "text": f"Hello, {args['name']}!"}]}
|
||||
|
||||
Tool with multiple parameters:
|
||||
>>> @tool("add", "Add two numbers", {"a": float, "b": float})
|
||||
... async def add_numbers(args):
|
||||
... result = args["a"] + args["b"]
|
||||
... return {"content": [{"type": "text", "text": f"Result: {result}"}]}
|
||||
|
||||
Tool with error handling:
|
||||
>>> @tool("divide", "Divide two numbers", {"a": float, "b": float})
|
||||
... async def divide(args):
|
||||
... if args["b"] == 0:
|
||||
... return {"content": [{"type": "text", "text": "Error: Division by zero"}], "is_error": True}
|
||||
... return {"content": [{"type": "text", "text": f"Result: {args['a'] / args['b']}"}]}
|
||||
|
||||
Notes:
|
||||
- The tool function must be async (defined with async def)
|
||||
- The function receives a single dict argument with the input parameters
|
||||
- The function should return a dict with a "content" key containing the response
|
||||
- Errors can be indicated by including "is_error": True in the response
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
handler: Callable[[Any], Awaitable[dict[str, Any]]],
|
||||
) -> SdkMcpTool[Any]:
|
||||
return SdkMcpTool(
|
||||
name=name,
|
||||
description=description,
|
||||
input_schema=input_schema,
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def create_sdk_mcp_server(
|
||||
name: str, version: str = "1.0.0", tools: list[SdkMcpTool[Any]] | None = None
|
||||
) -> McpSdkServerConfig:
|
||||
"""Create an in-process MCP server that runs within your Python application.
|
||||
|
||||
Unlike external MCP servers that run as separate processes, SDK MCP servers
|
||||
run directly in your application's process. This provides:
|
||||
- Better performance (no IPC overhead)
|
||||
- Simpler deployment (single process)
|
||||
- Easier debugging (same process)
|
||||
- Direct access to your application's state
|
||||
|
||||
Args:
|
||||
name: Unique identifier for the server. This name is used to reference
|
||||
the server in the mcp_servers configuration.
|
||||
version: Server version string. Defaults to "1.0.0". This is for
|
||||
informational purposes and doesn't affect functionality.
|
||||
tools: List of SdkMcpTool instances created with the @tool decorator.
|
||||
These are the functions that Claude can call through this server.
|
||||
If None or empty, the server will have no tools (rarely useful).
|
||||
|
||||
Returns:
|
||||
McpSdkServerConfig: A configuration object that can be passed to
|
||||
ClaudeAgentOptions.mcp_servers. This config contains the server
|
||||
instance and metadata needed for the SDK to route tool calls.
|
||||
|
||||
Example:
|
||||
Simple calculator server:
|
||||
>>> @tool("add", "Add numbers", {"a": float, "b": float})
|
||||
... async def add(args):
|
||||
... return {"content": [{"type": "text", "text": f"Sum: {args['a'] + args['b']}"}]}
|
||||
>>>
|
||||
>>> @tool("multiply", "Multiply numbers", {"a": float, "b": float})
|
||||
... async def multiply(args):
|
||||
... return {"content": [{"type": "text", "text": f"Product: {args['a'] * args['b']}"}]}
|
||||
>>>
|
||||
>>> calculator = create_sdk_mcp_server(
|
||||
... name="calculator",
|
||||
... version="2.0.0",
|
||||
... tools=[add, multiply]
|
||||
... )
|
||||
>>>
|
||||
>>> # Use with Claude
|
||||
>>> options = ClaudeAgentOptions(
|
||||
... mcp_servers={"calc": calculator},
|
||||
... allowed_tools=["add", "multiply"]
|
||||
... )
|
||||
|
||||
Server with application state access:
|
||||
>>> class DataStore:
|
||||
... def __init__(self):
|
||||
... self.items = []
|
||||
...
|
||||
>>> store = DataStore()
|
||||
>>>
|
||||
>>> @tool("add_item", "Add item to store", {"item": str})
|
||||
... async def add_item(args):
|
||||
... store.items.append(args["item"])
|
||||
... return {"content": [{"type": "text", "text": f"Added: {args['item']}"}]}
|
||||
>>>
|
||||
>>> server = create_sdk_mcp_server("store", tools=[add_item])
|
||||
|
||||
Notes:
|
||||
- The server runs in the same process as your Python application
|
||||
- Tools have direct access to your application's variables and state
|
||||
- No subprocess or IPC overhead for tool calls
|
||||
- Server lifecycle is managed automatically by the SDK
|
||||
|
||||
See Also:
|
||||
- tool(): Decorator for creating tool functions
|
||||
- ClaudeAgentOptions: Configuration for using servers with query()
|
||||
"""
|
||||
from mcp.server import Server
|
||||
from mcp.types import ImageContent, TextContent, Tool
|
||||
|
||||
# Create MCP server instance
|
||||
server = Server(name, version=version)
|
||||
|
||||
# Register tools if provided
|
||||
if tools:
|
||||
# Store tools for access in handlers
|
||||
tool_map = {tool_def.name: tool_def for tool_def in tools}
|
||||
|
||||
# Register list_tools handler to expose available tools
|
||||
@server.list_tools() # type: ignore[no-untyped-call,untyped-decorator]
|
||||
async def list_tools() -> list[Tool]:
|
||||
"""Return the list of available tools."""
|
||||
tool_list = []
|
||||
for tool_def in tools:
|
||||
# Convert input_schema to JSON Schema format
|
||||
if isinstance(tool_def.input_schema, dict):
|
||||
# Check if it's already a JSON schema
|
||||
if (
|
||||
"type" in tool_def.input_schema
|
||||
and "properties" in tool_def.input_schema
|
||||
):
|
||||
schema = tool_def.input_schema
|
||||
else:
|
||||
# Simple dict mapping names to types - convert to JSON schema
|
||||
properties = {}
|
||||
for param_name, param_type in tool_def.input_schema.items():
|
||||
if param_type is str:
|
||||
properties[param_name] = {"type": "string"}
|
||||
elif param_type is int:
|
||||
properties[param_name] = {"type": "integer"}
|
||||
elif param_type is float:
|
||||
properties[param_name] = {"type": "number"}
|
||||
elif param_type is bool:
|
||||
properties[param_name] = {"type": "boolean"}
|
||||
else:
|
||||
properties[param_name] = {"type": "string"} # Default
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": list(properties.keys()),
|
||||
}
|
||||
else:
|
||||
# For TypedDict or other types, create basic schema
|
||||
schema = {"type": "object", "properties": {}}
|
||||
|
||||
tool_list.append(
|
||||
Tool(
|
||||
name=tool_def.name,
|
||||
description=tool_def.description,
|
||||
inputSchema=schema,
|
||||
)
|
||||
)
|
||||
return tool_list
|
||||
|
||||
# Register call_tool handler to execute tools
|
||||
@server.call_tool() # type: ignore[untyped-decorator]
|
||||
async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
|
||||
"""Execute a tool by name with given arguments."""
|
||||
if name not in tool_map:
|
||||
raise ValueError(f"Tool '{name}' not found")
|
||||
|
||||
tool_def = tool_map[name]
|
||||
# Call the tool's handler with arguments
|
||||
result = await tool_def.handler(arguments)
|
||||
|
||||
# Convert result to MCP format
|
||||
# The decorator expects us to return the content, not a CallToolResult
|
||||
# It will wrap our return value in CallToolResult
|
||||
content: list[TextContent | ImageContent] = []
|
||||
if "content" in result:
|
||||
for item in result["content"]:
|
||||
if item.get("type") == "text":
|
||||
content.append(TextContent(type="text", text=item["text"]))
|
||||
if item.get("type") == "image":
|
||||
content.append(
|
||||
ImageContent(
|
||||
type="image",
|
||||
data=item["data"],
|
||||
mimeType=item["mimeType"],
|
||||
)
|
||||
)
|
||||
|
||||
# Return just the content list - the decorator wraps it
|
||||
return content
|
||||
|
||||
# Return SDK server configuration
|
||||
return McpSdkServerConfig(type="sdk", name=name, instance=server)
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Main exports
|
||||
"query",
|
||||
"__version__",
|
||||
# Transport
|
||||
"Transport",
|
||||
"ClaudeSDKClient",
|
||||
# Types
|
||||
"PermissionMode",
|
||||
"McpServerConfig",
|
||||
"McpSdkServerConfig",
|
||||
"UserMessage",
|
||||
"AssistantMessage",
|
||||
"SystemMessage",
|
||||
"ResultMessage",
|
||||
"Message",
|
||||
"ClaudeAgentOptions",
|
||||
"TextBlock",
|
||||
"ThinkingBlock",
|
||||
"ToolUseBlock",
|
||||
"ToolResultBlock",
|
||||
"ContentBlock",
|
||||
# Tool callbacks
|
||||
"CanUseTool",
|
||||
"ToolPermissionContext",
|
||||
"PermissionResult",
|
||||
"PermissionResultAllow",
|
||||
"PermissionResultDeny",
|
||||
"PermissionUpdate",
|
||||
# Hook support
|
||||
"HookCallback",
|
||||
"HookContext",
|
||||
"HookInput",
|
||||
"BaseHookInput",
|
||||
"PreToolUseHookInput",
|
||||
"PostToolUseHookInput",
|
||||
"UserPromptSubmitHookInput",
|
||||
"StopHookInput",
|
||||
"SubagentStopHookInput",
|
||||
"PreCompactHookInput",
|
||||
"HookJSONOutput",
|
||||
"HookMatcher",
|
||||
# Agent support
|
||||
"AgentDefinition",
|
||||
"SettingSource",
|
||||
# Plugin support
|
||||
"SdkPluginConfig",
|
||||
# Beta support
|
||||
"SdkBeta",
|
||||
# Sandbox support
|
||||
"SandboxSettings",
|
||||
"SandboxNetworkConfig",
|
||||
"SandboxIgnoreViolations",
|
||||
# MCP Server Support
|
||||
"create_sdk_mcp_server",
|
||||
"tool",
|
||||
"SdkMcpTool",
|
||||
# Errors
|
||||
"ClaudeSDKError",
|
||||
"CLIConnectionError",
|
||||
"CLINotFoundError",
|
||||
"ProcessError",
|
||||
"CLIJSONDecodeError",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
3
.venv/lib/python3.11/site-packages/claude_agent_sdk/_bundled/.gitignore
vendored
Normal file
3
.venv/lib/python3.11/site-packages/claude_agent_sdk/_bundled/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Ignore bundled CLI binaries (downloaded during build)
|
||||
claude
|
||||
claude.exe
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Bundled Claude Code CLI version."""
|
||||
|
||||
__cli_version__ = "2.1.15"
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Error types for Claude SDK."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ClaudeSDKError(Exception):
|
||||
"""Base exception for all Claude SDK errors."""
|
||||
|
||||
|
||||
class CLIConnectionError(ClaudeSDKError):
|
||||
"""Raised when unable to connect to Claude Code."""
|
||||
|
||||
|
||||
class CLINotFoundError(CLIConnectionError):
|
||||
"""Raised when Claude Code is not found or not installed."""
|
||||
|
||||
def __init__(
|
||||
self, message: str = "Claude Code not found", cli_path: str | None = None
|
||||
):
|
||||
if cli_path:
|
||||
message = f"{message}: {cli_path}"
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class ProcessError(ClaudeSDKError):
|
||||
"""Raised when the CLI process fails."""
|
||||
|
||||
def __init__(
|
||||
self, message: str, exit_code: int | None = None, stderr: str | None = None
|
||||
):
|
||||
self.exit_code = exit_code
|
||||
self.stderr = stderr
|
||||
|
||||
if exit_code is not None:
|
||||
message = f"{message} (exit code: {exit_code})"
|
||||
if stderr:
|
||||
message = f"{message}\nError output: {stderr}"
|
||||
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class CLIJSONDecodeError(ClaudeSDKError):
|
||||
"""Raised when unable to decode JSON from CLI output."""
|
||||
|
||||
def __init__(self, line: str, original_error: Exception):
|
||||
self.line = line
|
||||
self.original_error = original_error
|
||||
super().__init__(f"Failed to decode JSON: {line[:100]}...")
|
||||
|
||||
|
||||
class MessageParseError(ClaudeSDKError):
|
||||
"""Raised when unable to parse a message from CLI output."""
|
||||
|
||||
def __init__(self, message: str, data: dict[str, Any] | None = None):
|
||||
self.data = data
|
||||
super().__init__(message)
|
||||
@@ -0,0 +1 @@
|
||||
"""Internal implementation details."""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,124 @@
|
||||
"""Internal client implementation."""
|
||||
|
||||
from collections.abc import AsyncIterable, AsyncIterator
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
from ..types import (
|
||||
ClaudeAgentOptions,
|
||||
HookEvent,
|
||||
HookMatcher,
|
||||
Message,
|
||||
)
|
||||
from .message_parser import parse_message
|
||||
from .query import Query
|
||||
from .transport import Transport
|
||||
from .transport.subprocess_cli import SubprocessCLITransport
|
||||
|
||||
|
||||
class InternalClient:
|
||||
"""Internal client implementation."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the internal client."""
|
||||
|
||||
def _convert_hooks_to_internal_format(
|
||||
self, hooks: dict[HookEvent, list[HookMatcher]]
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Convert HookMatcher format to internal Query format."""
|
||||
internal_hooks: dict[str, list[dict[str, Any]]] = {}
|
||||
for event, matchers in hooks.items():
|
||||
internal_hooks[event] = []
|
||||
for matcher in matchers:
|
||||
# Convert HookMatcher to internal dict format
|
||||
internal_matcher: dict[str, Any] = {
|
||||
"matcher": matcher.matcher if hasattr(matcher, "matcher") else None,
|
||||
"hooks": matcher.hooks if hasattr(matcher, "hooks") else [],
|
||||
}
|
||||
if hasattr(matcher, "timeout") and matcher.timeout is not None:
|
||||
internal_matcher["timeout"] = matcher.timeout
|
||||
internal_hooks[event].append(internal_matcher)
|
||||
return internal_hooks
|
||||
|
||||
async def process_query(
|
||||
self,
|
||||
prompt: str | AsyncIterable[dict[str, Any]],
|
||||
options: ClaudeAgentOptions,
|
||||
transport: Transport | None = None,
|
||||
) -> AsyncIterator[Message]:
|
||||
"""Process a query through transport and Query."""
|
||||
|
||||
# Validate and configure permission settings (matching TypeScript SDK logic)
|
||||
configured_options = options
|
||||
if options.can_use_tool:
|
||||
# canUseTool callback requires streaming mode (AsyncIterable prompt)
|
||||
if isinstance(prompt, str):
|
||||
raise ValueError(
|
||||
"can_use_tool callback requires streaming mode. "
|
||||
"Please provide prompt as an AsyncIterable instead of a string."
|
||||
)
|
||||
|
||||
# canUseTool and permission_prompt_tool_name are mutually exclusive
|
||||
if options.permission_prompt_tool_name:
|
||||
raise ValueError(
|
||||
"can_use_tool callback cannot be used with permission_prompt_tool_name. "
|
||||
"Please use one or the other."
|
||||
)
|
||||
|
||||
# Automatically set permission_prompt_tool_name to "stdio" for control protocol
|
||||
configured_options = replace(options, permission_prompt_tool_name="stdio")
|
||||
|
||||
# Use provided transport or create subprocess transport
|
||||
if transport is not None:
|
||||
chosen_transport = transport
|
||||
else:
|
||||
chosen_transport = SubprocessCLITransport(
|
||||
prompt=prompt,
|
||||
options=configured_options,
|
||||
)
|
||||
|
||||
# Connect transport
|
||||
await chosen_transport.connect()
|
||||
|
||||
# Extract SDK MCP servers from configured options
|
||||
sdk_mcp_servers = {}
|
||||
if configured_options.mcp_servers and isinstance(
|
||||
configured_options.mcp_servers, dict
|
||||
):
|
||||
for name, config in configured_options.mcp_servers.items():
|
||||
if isinstance(config, dict) and config.get("type") == "sdk":
|
||||
sdk_mcp_servers[name] = config["instance"] # type: ignore[typeddict-item]
|
||||
|
||||
# Create Query to handle control protocol
|
||||
is_streaming = not isinstance(prompt, str)
|
||||
query = Query(
|
||||
transport=chosen_transport,
|
||||
is_streaming_mode=is_streaming,
|
||||
can_use_tool=configured_options.can_use_tool,
|
||||
hooks=self._convert_hooks_to_internal_format(configured_options.hooks)
|
||||
if configured_options.hooks
|
||||
else None,
|
||||
sdk_mcp_servers=sdk_mcp_servers,
|
||||
)
|
||||
|
||||
try:
|
||||
# Start reading messages
|
||||
await query.start()
|
||||
|
||||
# Initialize if streaming
|
||||
if is_streaming:
|
||||
await query.initialize()
|
||||
|
||||
# Stream input if it's an AsyncIterable
|
||||
if isinstance(prompt, AsyncIterable) and query._tg:
|
||||
# Start streaming in background
|
||||
# Create a task that will run in the background
|
||||
query._tg.start_soon(query.stream_input, prompt)
|
||||
# For string prompts, the prompt is already passed via CLI args
|
||||
|
||||
# Yield parsed messages
|
||||
async for data in query.receive_messages():
|
||||
yield parse_message(data)
|
||||
|
||||
finally:
|
||||
await query.close()
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Message parser for Claude Code SDK responses."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from .._errors import MessageParseError
|
||||
from ..types import (
|
||||
AssistantMessage,
|
||||
ContentBlock,
|
||||
Message,
|
||||
ResultMessage,
|
||||
StreamEvent,
|
||||
SystemMessage,
|
||||
TextBlock,
|
||||
ThinkingBlock,
|
||||
ToolResultBlock,
|
||||
ToolUseBlock,
|
||||
UserMessage,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_message(data: dict[str, Any]) -> Message:
|
||||
"""
|
||||
Parse message from CLI output into typed Message objects.
|
||||
|
||||
Args:
|
||||
data: Raw message dictionary from CLI output
|
||||
|
||||
Returns:
|
||||
Parsed Message object
|
||||
|
||||
Raises:
|
||||
MessageParseError: If parsing fails or message type is unrecognized
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
raise MessageParseError(
|
||||
f"Invalid message data type (expected dict, got {type(data).__name__})",
|
||||
data,
|
||||
)
|
||||
|
||||
message_type = data.get("type")
|
||||
if not message_type:
|
||||
raise MessageParseError("Message missing 'type' field", data)
|
||||
|
||||
match message_type:
|
||||
case "user":
|
||||
try:
|
||||
parent_tool_use_id = data.get("parent_tool_use_id")
|
||||
uuid = data.get("uuid")
|
||||
if isinstance(data["message"]["content"], list):
|
||||
user_content_blocks: list[ContentBlock] = []
|
||||
for block in data["message"]["content"]:
|
||||
match block["type"]:
|
||||
case "text":
|
||||
user_content_blocks.append(
|
||||
TextBlock(text=block["text"])
|
||||
)
|
||||
case "tool_use":
|
||||
user_content_blocks.append(
|
||||
ToolUseBlock(
|
||||
id=block["id"],
|
||||
name=block["name"],
|
||||
input=block["input"],
|
||||
)
|
||||
)
|
||||
case "tool_result":
|
||||
user_content_blocks.append(
|
||||
ToolResultBlock(
|
||||
tool_use_id=block["tool_use_id"],
|
||||
content=block.get("content"),
|
||||
is_error=block.get("is_error"),
|
||||
)
|
||||
)
|
||||
return UserMessage(
|
||||
content=user_content_blocks,
|
||||
uuid=uuid,
|
||||
parent_tool_use_id=parent_tool_use_id,
|
||||
)
|
||||
return UserMessage(
|
||||
content=data["message"]["content"],
|
||||
uuid=uuid,
|
||||
parent_tool_use_id=parent_tool_use_id,
|
||||
)
|
||||
except KeyError as e:
|
||||
raise MessageParseError(
|
||||
f"Missing required field in user message: {e}", data
|
||||
) from e
|
||||
|
||||
case "assistant":
|
||||
try:
|
||||
content_blocks: list[ContentBlock] = []
|
||||
for block in data["message"]["content"]:
|
||||
match block["type"]:
|
||||
case "text":
|
||||
content_blocks.append(TextBlock(text=block["text"]))
|
||||
case "thinking":
|
||||
content_blocks.append(
|
||||
ThinkingBlock(
|
||||
thinking=block["thinking"],
|
||||
signature=block["signature"],
|
||||
)
|
||||
)
|
||||
case "tool_use":
|
||||
content_blocks.append(
|
||||
ToolUseBlock(
|
||||
id=block["id"],
|
||||
name=block["name"],
|
||||
input=block["input"],
|
||||
)
|
||||
)
|
||||
case "tool_result":
|
||||
content_blocks.append(
|
||||
ToolResultBlock(
|
||||
tool_use_id=block["tool_use_id"],
|
||||
content=block.get("content"),
|
||||
is_error=block.get("is_error"),
|
||||
)
|
||||
)
|
||||
|
||||
return AssistantMessage(
|
||||
content=content_blocks,
|
||||
model=data["message"]["model"],
|
||||
parent_tool_use_id=data.get("parent_tool_use_id"),
|
||||
error=data["message"].get("error"),
|
||||
)
|
||||
except KeyError as e:
|
||||
raise MessageParseError(
|
||||
f"Missing required field in assistant message: {e}", data
|
||||
) from e
|
||||
|
||||
case "system":
|
||||
try:
|
||||
return SystemMessage(
|
||||
subtype=data["subtype"],
|
||||
data=data,
|
||||
)
|
||||
except KeyError as e:
|
||||
raise MessageParseError(
|
||||
f"Missing required field in system message: {e}", data
|
||||
) from e
|
||||
|
||||
case "result":
|
||||
try:
|
||||
return ResultMessage(
|
||||
subtype=data["subtype"],
|
||||
duration_ms=data["duration_ms"],
|
||||
duration_api_ms=data["duration_api_ms"],
|
||||
is_error=data["is_error"],
|
||||
num_turns=data["num_turns"],
|
||||
session_id=data["session_id"],
|
||||
total_cost_usd=data.get("total_cost_usd"),
|
||||
usage=data.get("usage"),
|
||||
result=data.get("result"),
|
||||
structured_output=data.get("structured_output"),
|
||||
)
|
||||
except KeyError as e:
|
||||
raise MessageParseError(
|
||||
f"Missing required field in result message: {e}", data
|
||||
) from e
|
||||
|
||||
case "stream_event":
|
||||
try:
|
||||
return StreamEvent(
|
||||
uuid=data["uuid"],
|
||||
session_id=data["session_id"],
|
||||
event=data["event"],
|
||||
parent_tool_use_id=data.get("parent_tool_use_id"),
|
||||
)
|
||||
except KeyError as e:
|
||||
raise MessageParseError(
|
||||
f"Missing required field in stream_event message: {e}", data
|
||||
) from e
|
||||
|
||||
case _:
|
||||
raise MessageParseError(f"Unknown message type: {message_type}", data)
|
||||
@@ -0,0 +1,621 @@
|
||||
"""Query class for handling bidirectional control protocol."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable
|
||||
from contextlib import suppress
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import anyio
|
||||
from mcp.types import (
|
||||
CallToolRequest,
|
||||
CallToolRequestParams,
|
||||
ListToolsRequest,
|
||||
)
|
||||
|
||||
from ..types import (
|
||||
PermissionResultAllow,
|
||||
PermissionResultDeny,
|
||||
SDKControlPermissionRequest,
|
||||
SDKControlRequest,
|
||||
SDKControlResponse,
|
||||
SDKHookCallbackRequest,
|
||||
ToolPermissionContext,
|
||||
)
|
||||
from .transport import Transport
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server import Server as McpServer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _convert_hook_output_for_cli(hook_output: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Convert Python-safe field names to CLI-expected field names.
|
||||
|
||||
The Python SDK uses `async_` and `continue_` to avoid keyword conflicts,
|
||||
but the CLI expects `async` and `continue`. This function performs the
|
||||
necessary conversion.
|
||||
"""
|
||||
converted = {}
|
||||
for key, value in hook_output.items():
|
||||
# Convert Python-safe names to JavaScript names
|
||||
if key == "async_":
|
||||
converted["async"] = value
|
||||
elif key == "continue_":
|
||||
converted["continue"] = value
|
||||
else:
|
||||
converted[key] = value
|
||||
return converted
|
||||
|
||||
|
||||
class Query:
|
||||
"""Handles bidirectional control protocol on top of Transport.
|
||||
|
||||
This class manages:
|
||||
- Control request/response routing
|
||||
- Hook callbacks
|
||||
- Tool permission callbacks
|
||||
- Message streaming
|
||||
- Initialization handshake
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
transport: Transport,
|
||||
is_streaming_mode: bool,
|
||||
can_use_tool: Callable[
|
||||
[str, dict[str, Any], ToolPermissionContext],
|
||||
Awaitable[PermissionResultAllow | PermissionResultDeny],
|
||||
]
|
||||
| None = None,
|
||||
hooks: dict[str, list[dict[str, Any]]] | None = None,
|
||||
sdk_mcp_servers: dict[str, "McpServer"] | None = None,
|
||||
initialize_timeout: float = 60.0,
|
||||
):
|
||||
"""Initialize Query with transport and callbacks.
|
||||
|
||||
Args:
|
||||
transport: Low-level transport for I/O
|
||||
is_streaming_mode: Whether using streaming (bidirectional) mode
|
||||
can_use_tool: Optional callback for tool permission requests
|
||||
hooks: Optional hook configurations
|
||||
sdk_mcp_servers: Optional SDK MCP server instances
|
||||
initialize_timeout: Timeout in seconds for the initialize request
|
||||
"""
|
||||
self._initialize_timeout = initialize_timeout
|
||||
self.transport = transport
|
||||
self.is_streaming_mode = is_streaming_mode
|
||||
self.can_use_tool = can_use_tool
|
||||
self.hooks = hooks or {}
|
||||
self.sdk_mcp_servers = sdk_mcp_servers or {}
|
||||
|
||||
# Control protocol state
|
||||
self.pending_control_responses: dict[str, anyio.Event] = {}
|
||||
self.pending_control_results: dict[str, dict[str, Any] | Exception] = {}
|
||||
self.hook_callbacks: dict[str, Callable[..., Any]] = {}
|
||||
self.next_callback_id = 0
|
||||
self._request_counter = 0
|
||||
|
||||
# Message stream
|
||||
self._message_send, self._message_receive = anyio.create_memory_object_stream[
|
||||
dict[str, Any]
|
||||
](max_buffer_size=100)
|
||||
self._tg: anyio.abc.TaskGroup | None = None
|
||||
self._initialized = False
|
||||
self._closed = False
|
||||
self._initialization_result: dict[str, Any] | None = None
|
||||
|
||||
# Track first result for proper stream closure with SDK MCP servers
|
||||
self._first_result_event = anyio.Event()
|
||||
self._stream_close_timeout = (
|
||||
float(os.environ.get("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "60000")) / 1000.0
|
||||
) # Convert ms to seconds
|
||||
|
||||
async def initialize(self) -> dict[str, Any] | None:
|
||||
"""Initialize control protocol if in streaming mode.
|
||||
|
||||
Returns:
|
||||
Initialize response with supported commands, or None if not streaming
|
||||
"""
|
||||
if not self.is_streaming_mode:
|
||||
return None
|
||||
|
||||
# Build hooks configuration for initialization
|
||||
hooks_config: dict[str, Any] = {}
|
||||
if self.hooks:
|
||||
for event, matchers in self.hooks.items():
|
||||
if matchers:
|
||||
hooks_config[event] = []
|
||||
for matcher in matchers:
|
||||
callback_ids = []
|
||||
for callback in matcher.get("hooks", []):
|
||||
callback_id = f"hook_{self.next_callback_id}"
|
||||
self.next_callback_id += 1
|
||||
self.hook_callbacks[callback_id] = callback
|
||||
callback_ids.append(callback_id)
|
||||
hook_matcher_config: dict[str, Any] = {
|
||||
"matcher": matcher.get("matcher"),
|
||||
"hookCallbackIds": callback_ids,
|
||||
}
|
||||
if matcher.get("timeout") is not None:
|
||||
hook_matcher_config["timeout"] = matcher.get("timeout")
|
||||
hooks_config[event].append(hook_matcher_config)
|
||||
|
||||
# Send initialize request
|
||||
request = {
|
||||
"subtype": "initialize",
|
||||
"hooks": hooks_config if hooks_config else None,
|
||||
}
|
||||
|
||||
# Use longer timeout for initialize since MCP servers may take time to start
|
||||
response = await self._send_control_request(
|
||||
request, timeout=self._initialize_timeout
|
||||
)
|
||||
self._initialized = True
|
||||
self._initialization_result = response # Store for later access
|
||||
return response
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start reading messages from transport."""
|
||||
if self._tg is None:
|
||||
self._tg = anyio.create_task_group()
|
||||
await self._tg.__aenter__()
|
||||
self._tg.start_soon(self._read_messages)
|
||||
|
||||
async def _read_messages(self) -> None:
|
||||
"""Read messages from transport and route them."""
|
||||
try:
|
||||
async for message in self.transport.read_messages():
|
||||
if self._closed:
|
||||
break
|
||||
|
||||
msg_type = message.get("type")
|
||||
|
||||
# Route control messages
|
||||
if msg_type == "control_response":
|
||||
response = message.get("response", {})
|
||||
request_id = response.get("request_id")
|
||||
if request_id in self.pending_control_responses:
|
||||
event = self.pending_control_responses[request_id]
|
||||
if response.get("subtype") == "error":
|
||||
self.pending_control_results[request_id] = Exception(
|
||||
response.get("error", "Unknown error")
|
||||
)
|
||||
else:
|
||||
self.pending_control_results[request_id] = response
|
||||
event.set()
|
||||
continue
|
||||
|
||||
elif msg_type == "control_request":
|
||||
# Handle incoming control requests from CLI
|
||||
# Cast message to SDKControlRequest for type safety
|
||||
request: SDKControlRequest = message # type: ignore[assignment]
|
||||
if self._tg:
|
||||
self._tg.start_soon(self._handle_control_request, request)
|
||||
continue
|
||||
|
||||
elif msg_type == "control_cancel_request":
|
||||
# Handle cancel requests
|
||||
# TODO: Implement cancellation support
|
||||
continue
|
||||
|
||||
# Track results for proper stream closure
|
||||
if msg_type == "result":
|
||||
self._first_result_event.set()
|
||||
|
||||
# Regular SDK messages go to the stream
|
||||
await self._message_send.send(message)
|
||||
|
||||
except anyio.get_cancelled_exc_class():
|
||||
# Task was cancelled - this is expected behavior
|
||||
logger.debug("Read task cancelled")
|
||||
raise # Re-raise to properly handle cancellation
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal error in message reader: {e}")
|
||||
# Signal all pending control requests so they fail fast instead of timing out
|
||||
for request_id, event in list(self.pending_control_responses.items()):
|
||||
if request_id not in self.pending_control_results:
|
||||
self.pending_control_results[request_id] = e
|
||||
event.set()
|
||||
# Put error in stream so iterators can handle it
|
||||
await self._message_send.send({"type": "error", "error": str(e)})
|
||||
finally:
|
||||
# Always signal end of stream
|
||||
await self._message_send.send({"type": "end"})
|
||||
|
||||
async def _handle_control_request(self, request: SDKControlRequest) -> None:
|
||||
"""Handle incoming control request from CLI."""
|
||||
request_id = request["request_id"]
|
||||
request_data = request["request"]
|
||||
subtype = request_data["subtype"]
|
||||
|
||||
try:
|
||||
response_data: dict[str, Any] = {}
|
||||
|
||||
if subtype == "can_use_tool":
|
||||
permission_request: SDKControlPermissionRequest = request_data # type: ignore[assignment]
|
||||
original_input = permission_request["input"]
|
||||
# Handle tool permission request
|
||||
if not self.can_use_tool:
|
||||
raise Exception("canUseTool callback is not provided")
|
||||
|
||||
context = ToolPermissionContext(
|
||||
signal=None, # TODO: Add abort signal support
|
||||
suggestions=permission_request.get("permission_suggestions", [])
|
||||
or [],
|
||||
)
|
||||
|
||||
response = await self.can_use_tool(
|
||||
permission_request["tool_name"],
|
||||
permission_request["input"],
|
||||
context,
|
||||
)
|
||||
|
||||
# Convert PermissionResult to expected dict format
|
||||
if isinstance(response, PermissionResultAllow):
|
||||
response_data = {
|
||||
"behavior": "allow",
|
||||
"updatedInput": (
|
||||
response.updated_input
|
||||
if response.updated_input is not None
|
||||
else original_input
|
||||
),
|
||||
}
|
||||
if response.updated_permissions is not None:
|
||||
response_data["updatedPermissions"] = [
|
||||
permission.to_dict()
|
||||
for permission in response.updated_permissions
|
||||
]
|
||||
elif isinstance(response, PermissionResultDeny):
|
||||
response_data = {"behavior": "deny", "message": response.message}
|
||||
if response.interrupt:
|
||||
response_data["interrupt"] = response.interrupt
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Tool permission callback must return PermissionResult (PermissionResultAllow or PermissionResultDeny), got {type(response)}"
|
||||
)
|
||||
|
||||
elif subtype == "hook_callback":
|
||||
hook_callback_request: SDKHookCallbackRequest = request_data # type: ignore[assignment]
|
||||
# Handle hook callback
|
||||
callback_id = hook_callback_request["callback_id"]
|
||||
callback = self.hook_callbacks.get(callback_id)
|
||||
if not callback:
|
||||
raise Exception(f"No hook callback found for ID: {callback_id}")
|
||||
|
||||
hook_output = await callback(
|
||||
request_data.get("input"),
|
||||
request_data.get("tool_use_id"),
|
||||
{"signal": None}, # TODO: Add abort signal support
|
||||
)
|
||||
# Convert Python-safe field names (async_, continue_) to CLI-expected names (async, continue)
|
||||
response_data = _convert_hook_output_for_cli(hook_output)
|
||||
|
||||
elif subtype == "mcp_message":
|
||||
# Handle SDK MCP request
|
||||
server_name = request_data.get("server_name")
|
||||
mcp_message = request_data.get("message")
|
||||
|
||||
if not server_name or not mcp_message:
|
||||
raise Exception("Missing server_name or message for MCP request")
|
||||
|
||||
# Type narrowing - we've verified these are not None above
|
||||
assert isinstance(server_name, str)
|
||||
assert isinstance(mcp_message, dict)
|
||||
mcp_response = await self._handle_sdk_mcp_request(
|
||||
server_name, mcp_message
|
||||
)
|
||||
# Wrap the MCP response as expected by the control protocol
|
||||
response_data = {"mcp_response": mcp_response}
|
||||
|
||||
else:
|
||||
raise Exception(f"Unsupported control request subtype: {subtype}")
|
||||
|
||||
# Send success response
|
||||
success_response: SDKControlResponse = {
|
||||
"type": "control_response",
|
||||
"response": {
|
||||
"subtype": "success",
|
||||
"request_id": request_id,
|
||||
"response": response_data,
|
||||
},
|
||||
}
|
||||
await self.transport.write(json.dumps(success_response) + "\n")
|
||||
|
||||
except Exception as e:
|
||||
# Send error response
|
||||
error_response: SDKControlResponse = {
|
||||
"type": "control_response",
|
||||
"response": {
|
||||
"subtype": "error",
|
||||
"request_id": request_id,
|
||||
"error": str(e),
|
||||
},
|
||||
}
|
||||
await self.transport.write(json.dumps(error_response) + "\n")
|
||||
|
||||
async def _send_control_request(
|
||||
self, request: dict[str, Any], timeout: float = 60.0
|
||||
) -> dict[str, Any]:
|
||||
"""Send control request to CLI and wait for response.
|
||||
|
||||
Args:
|
||||
request: The control request to send
|
||||
timeout: Timeout in seconds to wait for response (default 60s)
|
||||
"""
|
||||
if not self.is_streaming_mode:
|
||||
raise Exception("Control requests require streaming mode")
|
||||
|
||||
# Generate unique request ID
|
||||
self._request_counter += 1
|
||||
request_id = f"req_{self._request_counter}_{os.urandom(4).hex()}"
|
||||
|
||||
# Create event for response
|
||||
event = anyio.Event()
|
||||
self.pending_control_responses[request_id] = event
|
||||
|
||||
# Build and send request
|
||||
control_request = {
|
||||
"type": "control_request",
|
||||
"request_id": request_id,
|
||||
"request": request,
|
||||
}
|
||||
|
||||
await self.transport.write(json.dumps(control_request) + "\n")
|
||||
|
||||
# Wait for response
|
||||
try:
|
||||
with anyio.fail_after(timeout):
|
||||
await event.wait()
|
||||
|
||||
result = self.pending_control_results.pop(request_id)
|
||||
self.pending_control_responses.pop(request_id, None)
|
||||
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
|
||||
response_data = result.get("response", {})
|
||||
return response_data if isinstance(response_data, dict) else {}
|
||||
except TimeoutError as e:
|
||||
self.pending_control_responses.pop(request_id, None)
|
||||
self.pending_control_results.pop(request_id, None)
|
||||
raise Exception(f"Control request timeout: {request.get('subtype')}") from e
|
||||
|
||||
async def _handle_sdk_mcp_request(
|
||||
self, server_name: str, message: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Handle an MCP request for an SDK server.
|
||||
|
||||
This acts as a bridge between JSONRPC messages from the CLI
|
||||
and the in-process MCP server. Ideally the MCP SDK would provide
|
||||
a method to handle raw JSONRPC, but for now we route manually.
|
||||
|
||||
Args:
|
||||
server_name: Name of the SDK MCP server
|
||||
message: The JSONRPC message
|
||||
|
||||
Returns:
|
||||
The response message
|
||||
"""
|
||||
if server_name not in self.sdk_mcp_servers:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": message.get("id"),
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": f"Server '{server_name}' not found",
|
||||
},
|
||||
}
|
||||
|
||||
server = self.sdk_mcp_servers[server_name]
|
||||
method = message.get("method")
|
||||
params = message.get("params", {})
|
||||
|
||||
try:
|
||||
# TODO: Python MCP SDK lacks the Transport abstraction that TypeScript has.
|
||||
# TypeScript: server.connect(transport) allows custom transports
|
||||
# Python: server.run(read_stream, write_stream) requires actual streams
|
||||
#
|
||||
# This forces us to manually route methods. When Python MCP adds Transport
|
||||
# support, we can refactor to match the TypeScript approach.
|
||||
if method == "initialize":
|
||||
# Handle MCP initialization - hardcoded for tools only, no listChanged
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": message.get("id"),
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {
|
||||
"tools": {} # Tools capability without listChanged
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": server.name,
|
||||
"version": server.version or "1.0.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
elif method == "tools/list":
|
||||
request = ListToolsRequest(method=method)
|
||||
handler = server.request_handlers.get(ListToolsRequest)
|
||||
if handler:
|
||||
result = await handler(request)
|
||||
# Convert MCP result to JSONRPC response
|
||||
tools_data = [
|
||||
{
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"inputSchema": (
|
||||
tool.inputSchema.model_dump()
|
||||
if hasattr(tool.inputSchema, "model_dump")
|
||||
else tool.inputSchema
|
||||
)
|
||||
if tool.inputSchema
|
||||
else {},
|
||||
}
|
||||
for tool in result.root.tools # type: ignore[union-attr]
|
||||
]
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": message.get("id"),
|
||||
"result": {"tools": tools_data},
|
||||
}
|
||||
|
||||
elif method == "tools/call":
|
||||
call_request = CallToolRequest(
|
||||
method=method,
|
||||
params=CallToolRequestParams(
|
||||
name=params.get("name"), arguments=params.get("arguments", {})
|
||||
),
|
||||
)
|
||||
handler = server.request_handlers.get(CallToolRequest)
|
||||
if handler:
|
||||
result = await handler(call_request)
|
||||
# Convert MCP result to JSONRPC response
|
||||
content = []
|
||||
for item in result.root.content: # type: ignore[union-attr]
|
||||
if hasattr(item, "text"):
|
||||
content.append({"type": "text", "text": item.text})
|
||||
elif hasattr(item, "data") and hasattr(item, "mimeType"):
|
||||
content.append(
|
||||
{
|
||||
"type": "image",
|
||||
"data": item.data,
|
||||
"mimeType": item.mimeType,
|
||||
}
|
||||
)
|
||||
|
||||
response_data = {"content": content}
|
||||
if hasattr(result.root, "is_error") and result.root.is_error:
|
||||
response_data["is_error"] = True # type: ignore[assignment]
|
||||
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": message.get("id"),
|
||||
"result": response_data,
|
||||
}
|
||||
|
||||
elif method == "notifications/initialized":
|
||||
# Handle initialized notification - just acknowledge it
|
||||
return {"jsonrpc": "2.0", "result": {}}
|
||||
|
||||
# Add more methods here as MCP SDK adds them (resources, prompts, etc.)
|
||||
# This is the limitation Ashwin pointed out - we have to manually update
|
||||
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": message.get("id"),
|
||||
"error": {"code": -32601, "message": f"Method '{method}' not found"},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": message.get("id"),
|
||||
"error": {"code": -32603, "message": str(e)},
|
||||
}
|
||||
|
||||
async def interrupt(self) -> None:
|
||||
"""Send interrupt control request."""
|
||||
await self._send_control_request({"subtype": "interrupt"})
|
||||
|
||||
async def set_permission_mode(self, mode: str) -> None:
|
||||
"""Change permission mode."""
|
||||
await self._send_control_request(
|
||||
{
|
||||
"subtype": "set_permission_mode",
|
||||
"mode": mode,
|
||||
}
|
||||
)
|
||||
|
||||
async def set_model(self, model: str | None) -> None:
|
||||
"""Change the AI model."""
|
||||
await self._send_control_request(
|
||||
{
|
||||
"subtype": "set_model",
|
||||
"model": model,
|
||||
}
|
||||
)
|
||||
|
||||
async def rewind_files(self, user_message_id: str) -> None:
|
||||
"""Rewind tracked files to their state at a specific user message.
|
||||
|
||||
Requires file checkpointing to be enabled via the `enable_file_checkpointing` option.
|
||||
|
||||
Args:
|
||||
user_message_id: UUID of the user message to rewind to
|
||||
"""
|
||||
await self._send_control_request(
|
||||
{
|
||||
"subtype": "rewind_files",
|
||||
"user_message_id": user_message_id,
|
||||
}
|
||||
)
|
||||
|
||||
async def stream_input(self, stream: AsyncIterable[dict[str, Any]]) -> None:
|
||||
"""Stream input messages to transport.
|
||||
|
||||
If SDK MCP servers or hooks are present, waits for the first result
|
||||
before closing stdin to allow bidirectional control protocol communication.
|
||||
"""
|
||||
try:
|
||||
async for message in stream:
|
||||
if self._closed:
|
||||
break
|
||||
await self.transport.write(json.dumps(message) + "\n")
|
||||
|
||||
# If we have SDK MCP servers or hooks that need bidirectional communication,
|
||||
# wait for first result before closing the channel
|
||||
has_hooks = bool(self.hooks)
|
||||
if self.sdk_mcp_servers or has_hooks:
|
||||
logger.debug(
|
||||
f"Waiting for first result before closing stdin "
|
||||
f"(sdk_mcp_servers={len(self.sdk_mcp_servers)}, has_hooks={has_hooks})"
|
||||
)
|
||||
try:
|
||||
with anyio.move_on_after(self._stream_close_timeout):
|
||||
await self._first_result_event.wait()
|
||||
logger.debug("Received first result, closing input stream")
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Timed out waiting for first result, closing input stream"
|
||||
)
|
||||
|
||||
# After all messages sent (and result received if needed), end input
|
||||
await self.transport.end_input()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error streaming input: {e}")
|
||||
|
||||
async def receive_messages(self) -> AsyncIterator[dict[str, Any]]:
|
||||
"""Receive SDK messages (not control messages)."""
|
||||
async for message in self._message_receive:
|
||||
# Check for special messages
|
||||
if message.get("type") == "end":
|
||||
break
|
||||
elif message.get("type") == "error":
|
||||
raise Exception(message.get("error", "Unknown error"))
|
||||
|
||||
yield message
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the query and transport."""
|
||||
self._closed = True
|
||||
if self._tg:
|
||||
self._tg.cancel_scope.cancel()
|
||||
# Wait for task group to complete cancellation
|
||||
with suppress(anyio.get_cancelled_exc_class()):
|
||||
await self._tg.__aexit__(None, None, None)
|
||||
await self.transport.close()
|
||||
|
||||
# Make Query an async iterator
|
||||
def __aiter__(self) -> AsyncIterator[dict[str, Any]]:
|
||||
"""Return async iterator for messages."""
|
||||
return self.receive_messages()
|
||||
|
||||
async def __anext__(self) -> dict[str, Any]:
|
||||
"""Get next message."""
|
||||
async for message in self.receive_messages():
|
||||
return message
|
||||
raise StopAsyncIteration
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Transport implementations for Claude SDK."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
|
||||
class Transport(ABC):
|
||||
"""Abstract transport for Claude communication.
|
||||
|
||||
WARNING: This internal API is exposed for custom transport implementations
|
||||
(e.g., remote Claude Code connections). The Claude Code team may change or
|
||||
or remove this abstract class in any future release. Custom implementations
|
||||
must be updated to match interface changes.
|
||||
|
||||
This is a low-level transport interface that handles raw I/O with the Claude
|
||||
process or service. The Query class builds on top of this to implement the
|
||||
control protocol and message routing.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def connect(self) -> None:
|
||||
"""Connect the transport and prepare for communication.
|
||||
|
||||
For subprocess transports, this starts the process.
|
||||
For network transports, this establishes the connection.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def write(self, data: str) -> None:
|
||||
"""Write raw data to the transport.
|
||||
|
||||
Args:
|
||||
data: Raw string data to write (typically JSON + newline)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def read_messages(self) -> AsyncIterator[dict[str, Any]]:
|
||||
"""Read and parse messages from the transport.
|
||||
|
||||
Yields:
|
||||
Parsed JSON messages from the transport
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
"""Close the transport connection and clean up resources."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_ready(self) -> bool:
|
||||
"""Check if transport is ready for communication.
|
||||
|
||||
Returns:
|
||||
True if transport is ready to send/receive messages
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def end_input(self) -> None:
|
||||
"""End the input stream (close stdin for process transports)."""
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["Transport"]
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,672 @@
|
||||
"""Subprocess transport implementation using Claude Code CLI."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from collections.abc import AsyncIterable, AsyncIterator
|
||||
from contextlib import suppress
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from subprocess import PIPE
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import anyio.abc
|
||||
from anyio.abc import Process
|
||||
from anyio.streams.text import TextReceiveStream, TextSendStream
|
||||
|
||||
from ..._errors import CLIConnectionError, CLINotFoundError, ProcessError
|
||||
from ..._errors import CLIJSONDecodeError as SDKJSONDecodeError
|
||||
from ..._version import __version__
|
||||
from ...types import ClaudeAgentOptions
|
||||
from . import Transport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
|
||||
MINIMUM_CLAUDE_CODE_VERSION = "2.0.0"
|
||||
|
||||
# Platform-specific command line length limits
|
||||
# Windows cmd.exe has a limit of 8191 characters, use 8000 for safety
|
||||
# Other platforms have much higher limits
|
||||
_CMD_LENGTH_LIMIT = 8000 if platform.system() == "Windows" else 100000
|
||||
|
||||
|
||||
class SubprocessCLITransport(Transport):
|
||||
"""Subprocess transport using Claude Code CLI."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
prompt: str | AsyncIterable[dict[str, Any]],
|
||||
options: ClaudeAgentOptions,
|
||||
):
|
||||
self._prompt = prompt
|
||||
self._is_streaming = not isinstance(prompt, str)
|
||||
self._options = options
|
||||
self._cli_path = (
|
||||
str(options.cli_path) if options.cli_path is not None else self._find_cli()
|
||||
)
|
||||
self._cwd = str(options.cwd) if options.cwd else None
|
||||
self._process: Process | None = None
|
||||
self._stdout_stream: TextReceiveStream | None = None
|
||||
self._stdin_stream: TextSendStream | None = None
|
||||
self._stderr_stream: TextReceiveStream | None = None
|
||||
self._stderr_task_group: anyio.abc.TaskGroup | None = None
|
||||
self._ready = False
|
||||
self._exit_error: Exception | None = None # Track process exit errors
|
||||
self._max_buffer_size = (
|
||||
options.max_buffer_size
|
||||
if options.max_buffer_size is not None
|
||||
else _DEFAULT_MAX_BUFFER_SIZE
|
||||
)
|
||||
self._temp_files: list[str] = [] # Track temporary files for cleanup
|
||||
self._write_lock: anyio.Lock = anyio.Lock()
|
||||
|
||||
def _find_cli(self) -> str:
|
||||
"""Find Claude Code CLI binary."""
|
||||
# First, check for bundled CLI
|
||||
bundled_cli = self._find_bundled_cli()
|
||||
if bundled_cli:
|
||||
return bundled_cli
|
||||
|
||||
# Fall back to system-wide search
|
||||
if cli := shutil.which("claude"):
|
||||
return cli
|
||||
|
||||
locations = [
|
||||
Path.home() / ".npm-global/bin/claude",
|
||||
Path("/usr/local/bin/claude"),
|
||||
Path.home() / ".local/bin/claude",
|
||||
Path.home() / "node_modules/.bin/claude",
|
||||
Path.home() / ".yarn/bin/claude",
|
||||
Path.home() / ".claude/local/claude",
|
||||
]
|
||||
|
||||
for path in locations:
|
||||
if path.exists() and path.is_file():
|
||||
return str(path)
|
||||
|
||||
raise CLINotFoundError(
|
||||
"Claude Code not found. Install with:\n"
|
||||
" npm install -g @anthropic-ai/claude-code\n"
|
||||
"\nIf already installed locally, try:\n"
|
||||
' export PATH="$HOME/node_modules/.bin:$PATH"\n'
|
||||
"\nOr provide the path via ClaudeAgentOptions:\n"
|
||||
" ClaudeAgentOptions(cli_path='/path/to/claude')"
|
||||
)
|
||||
|
||||
def _find_bundled_cli(self) -> str | None:
|
||||
"""Find bundled CLI binary if it exists."""
|
||||
# Determine the CLI binary name based on platform
|
||||
cli_name = "claude.exe" if platform.system() == "Windows" else "claude"
|
||||
|
||||
# Get the path to the bundled CLI
|
||||
# The _bundled directory is in the same package as this module
|
||||
bundled_path = Path(__file__).parent.parent.parent / "_bundled" / cli_name
|
||||
|
||||
if bundled_path.exists() and bundled_path.is_file():
|
||||
logger.info(f"Using bundled Claude Code CLI: {bundled_path}")
|
||||
return str(bundled_path)
|
||||
|
||||
return None
|
||||
|
||||
def _build_settings_value(self) -> str | None:
|
||||
"""Build settings value, merging sandbox settings if provided.
|
||||
|
||||
Returns the settings value as either:
|
||||
- A JSON string (if sandbox is provided or settings is JSON)
|
||||
- A file path (if only settings path is provided without sandbox)
|
||||
- None if neither settings nor sandbox is provided
|
||||
"""
|
||||
has_settings = self._options.settings is not None
|
||||
has_sandbox = self._options.sandbox is not None
|
||||
|
||||
if not has_settings and not has_sandbox:
|
||||
return None
|
||||
|
||||
# If only settings path and no sandbox, pass through as-is
|
||||
if has_settings and not has_sandbox:
|
||||
return self._options.settings
|
||||
|
||||
# If we have sandbox settings, we need to merge into a JSON object
|
||||
settings_obj: dict[str, Any] = {}
|
||||
|
||||
if has_settings:
|
||||
assert self._options.settings is not None
|
||||
settings_str = self._options.settings.strip()
|
||||
# Check if settings is a JSON string or a file path
|
||||
if settings_str.startswith("{") and settings_str.endswith("}"):
|
||||
# Parse JSON string
|
||||
try:
|
||||
settings_obj = json.loads(settings_str)
|
||||
except json.JSONDecodeError:
|
||||
# If parsing fails, treat as file path
|
||||
logger.warning(
|
||||
f"Failed to parse settings as JSON, treating as file path: {settings_str}"
|
||||
)
|
||||
# Read the file
|
||||
settings_path = Path(settings_str)
|
||||
if settings_path.exists():
|
||||
with settings_path.open(encoding="utf-8") as f:
|
||||
settings_obj = json.load(f)
|
||||
else:
|
||||
# It's a file path - read and parse
|
||||
settings_path = Path(settings_str)
|
||||
if settings_path.exists():
|
||||
with settings_path.open(encoding="utf-8") as f:
|
||||
settings_obj = json.load(f)
|
||||
else:
|
||||
logger.warning(f"Settings file not found: {settings_path}")
|
||||
|
||||
# Merge sandbox settings
|
||||
if has_sandbox:
|
||||
settings_obj["sandbox"] = self._options.sandbox
|
||||
|
||||
return json.dumps(settings_obj)
|
||||
|
||||
def _build_command(self) -> list[str]:
|
||||
"""Build CLI command with arguments."""
|
||||
cmd = [self._cli_path, "--output-format", "stream-json", "--verbose"]
|
||||
|
||||
if self._options.system_prompt is None:
|
||||
cmd.extend(["--system-prompt", ""])
|
||||
elif isinstance(self._options.system_prompt, str):
|
||||
cmd.extend(["--system-prompt", self._options.system_prompt])
|
||||
else:
|
||||
if (
|
||||
self._options.system_prompt.get("type") == "preset"
|
||||
and "append" in self._options.system_prompt
|
||||
):
|
||||
cmd.extend(
|
||||
["--append-system-prompt", self._options.system_prompt["append"]]
|
||||
)
|
||||
|
||||
# Handle tools option (base set of tools)
|
||||
if self._options.tools is not None:
|
||||
tools = self._options.tools
|
||||
if isinstance(tools, list):
|
||||
if len(tools) == 0:
|
||||
cmd.extend(["--tools", ""])
|
||||
else:
|
||||
cmd.extend(["--tools", ",".join(tools)])
|
||||
else:
|
||||
# Preset object - 'claude_code' preset maps to 'default'
|
||||
cmd.extend(["--tools", "default"])
|
||||
|
||||
if self._options.allowed_tools:
|
||||
cmd.extend(["--allowedTools", ",".join(self._options.allowed_tools)])
|
||||
|
||||
if self._options.max_turns:
|
||||
cmd.extend(["--max-turns", str(self._options.max_turns)])
|
||||
|
||||
if self._options.max_budget_usd is not None:
|
||||
cmd.extend(["--max-budget-usd", str(self._options.max_budget_usd)])
|
||||
|
||||
if self._options.disallowed_tools:
|
||||
cmd.extend(["--disallowedTools", ",".join(self._options.disallowed_tools)])
|
||||
|
||||
if self._options.model:
|
||||
cmd.extend(["--model", self._options.model])
|
||||
|
||||
if self._options.fallback_model:
|
||||
cmd.extend(["--fallback-model", self._options.fallback_model])
|
||||
|
||||
if self._options.betas:
|
||||
cmd.extend(["--betas", ",".join(self._options.betas)])
|
||||
|
||||
if self._options.permission_prompt_tool_name:
|
||||
cmd.extend(
|
||||
["--permission-prompt-tool", self._options.permission_prompt_tool_name]
|
||||
)
|
||||
|
||||
if self._options.permission_mode:
|
||||
cmd.extend(["--permission-mode", self._options.permission_mode])
|
||||
|
||||
if self._options.continue_conversation:
|
||||
cmd.append("--continue")
|
||||
|
||||
if self._options.resume:
|
||||
cmd.extend(["--resume", self._options.resume])
|
||||
|
||||
# Handle settings and sandbox: merge sandbox into settings if both are provided
|
||||
settings_value = self._build_settings_value()
|
||||
if settings_value:
|
||||
cmd.extend(["--settings", settings_value])
|
||||
|
||||
if self._options.add_dirs:
|
||||
# Convert all paths to strings and add each directory
|
||||
for directory in self._options.add_dirs:
|
||||
cmd.extend(["--add-dir", str(directory)])
|
||||
|
||||
if self._options.mcp_servers:
|
||||
if isinstance(self._options.mcp_servers, dict):
|
||||
# Process all servers, stripping instance field from SDK servers
|
||||
servers_for_cli: dict[str, Any] = {}
|
||||
for name, config in self._options.mcp_servers.items():
|
||||
if isinstance(config, dict) and config.get("type") == "sdk":
|
||||
# For SDK servers, pass everything except the instance field
|
||||
sdk_config: dict[str, object] = {
|
||||
k: v for k, v in config.items() if k != "instance"
|
||||
}
|
||||
servers_for_cli[name] = sdk_config
|
||||
else:
|
||||
# For external servers, pass as-is
|
||||
servers_for_cli[name] = config
|
||||
|
||||
# Pass all servers to CLI
|
||||
if servers_for_cli:
|
||||
cmd.extend(
|
||||
[
|
||||
"--mcp-config",
|
||||
json.dumps({"mcpServers": servers_for_cli}),
|
||||
]
|
||||
)
|
||||
else:
|
||||
# String or Path format: pass directly as file path or JSON string
|
||||
cmd.extend(["--mcp-config", str(self._options.mcp_servers)])
|
||||
|
||||
if self._options.include_partial_messages:
|
||||
cmd.append("--include-partial-messages")
|
||||
|
||||
if self._options.fork_session:
|
||||
cmd.append("--fork-session")
|
||||
|
||||
if self._options.agents:
|
||||
agents_dict = {
|
||||
name: {k: v for k, v in asdict(agent_def).items() if v is not None}
|
||||
for name, agent_def in self._options.agents.items()
|
||||
}
|
||||
agents_json = json.dumps(agents_dict)
|
||||
cmd.extend(["--agents", agents_json])
|
||||
|
||||
sources_value = (
|
||||
",".join(self._options.setting_sources)
|
||||
if self._options.setting_sources is not None
|
||||
else ""
|
||||
)
|
||||
cmd.extend(["--setting-sources", sources_value])
|
||||
|
||||
# Add plugin directories
|
||||
if self._options.plugins:
|
||||
for plugin in self._options.plugins:
|
||||
if plugin["type"] == "local":
|
||||
cmd.extend(["--plugin-dir", plugin["path"]])
|
||||
else:
|
||||
raise ValueError(f"Unsupported plugin type: {plugin['type']}")
|
||||
|
||||
# Add extra args for future CLI flags
|
||||
for flag, value in self._options.extra_args.items():
|
||||
if value is None:
|
||||
# Boolean flag without value
|
||||
cmd.append(f"--{flag}")
|
||||
else:
|
||||
# Flag with value
|
||||
cmd.extend([f"--{flag}", str(value)])
|
||||
|
||||
if self._options.max_thinking_tokens is not None:
|
||||
cmd.extend(
|
||||
["--max-thinking-tokens", str(self._options.max_thinking_tokens)]
|
||||
)
|
||||
|
||||
# Extract schema from output_format structure if provided
|
||||
# Expected: {"type": "json_schema", "schema": {...}}
|
||||
if (
|
||||
self._options.output_format is not None
|
||||
and isinstance(self._options.output_format, dict)
|
||||
and self._options.output_format.get("type") == "json_schema"
|
||||
):
|
||||
schema = self._options.output_format.get("schema")
|
||||
if schema is not None:
|
||||
cmd.extend(["--json-schema", json.dumps(schema)])
|
||||
|
||||
# Add prompt handling based on mode
|
||||
# IMPORTANT: This must come AFTER all flags because everything after "--" is treated as arguments
|
||||
if self._is_streaming:
|
||||
# Streaming mode: use --input-format stream-json
|
||||
cmd.extend(["--input-format", "stream-json"])
|
||||
else:
|
||||
# String mode: use --print with the prompt
|
||||
cmd.extend(["--print", "--", str(self._prompt)])
|
||||
|
||||
# Check if command line is too long (Windows limitation)
|
||||
cmd_str = " ".join(cmd)
|
||||
if len(cmd_str) > _CMD_LENGTH_LIMIT and self._options.agents:
|
||||
# Command is too long - use temp file for agents
|
||||
# Find the --agents argument and replace its value with @filepath
|
||||
try:
|
||||
agents_idx = cmd.index("--agents")
|
||||
agents_json_value = cmd[agents_idx + 1]
|
||||
|
||||
# Create a temporary file
|
||||
# ruff: noqa: SIM115
|
||||
temp_file = tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".json", delete=False, encoding="utf-8"
|
||||
)
|
||||
temp_file.write(agents_json_value)
|
||||
temp_file.close()
|
||||
|
||||
# Track for cleanup
|
||||
self._temp_files.append(temp_file.name)
|
||||
|
||||
# Replace agents JSON with @filepath reference
|
||||
cmd[agents_idx + 1] = f"@{temp_file.name}"
|
||||
|
||||
logger.info(
|
||||
f"Command line length ({len(cmd_str)}) exceeds limit ({_CMD_LENGTH_LIMIT}). "
|
||||
f"Using temp file for --agents: {temp_file.name}"
|
||||
)
|
||||
except (ValueError, IndexError) as e:
|
||||
# This shouldn't happen, but log it just in case
|
||||
logger.warning(f"Failed to optimize command line length: {e}")
|
||||
|
||||
return cmd
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Start subprocess."""
|
||||
if self._process:
|
||||
return
|
||||
|
||||
if not os.environ.get("CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK"):
|
||||
await self._check_claude_version()
|
||||
|
||||
cmd = self._build_command()
|
||||
try:
|
||||
# Merge environment variables: system -> user -> SDK required
|
||||
process_env = {
|
||||
**os.environ,
|
||||
**self._options.env, # User-provided env vars
|
||||
"CLAUDE_CODE_ENTRYPOINT": "sdk-py",
|
||||
"CLAUDE_AGENT_SDK_VERSION": __version__,
|
||||
}
|
||||
|
||||
# Enable file checkpointing if requested
|
||||
if self._options.enable_file_checkpointing:
|
||||
process_env["CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING"] = "true"
|
||||
|
||||
if self._cwd:
|
||||
process_env["PWD"] = self._cwd
|
||||
|
||||
# Pipe stderr if we have a callback OR debug mode is enabled
|
||||
should_pipe_stderr = (
|
||||
self._options.stderr is not None
|
||||
or "debug-to-stderr" in self._options.extra_args
|
||||
)
|
||||
|
||||
# For backward compat: use debug_stderr file object if no callback and debug is on
|
||||
stderr_dest = PIPE if should_pipe_stderr else None
|
||||
|
||||
self._process = await anyio.open_process(
|
||||
cmd,
|
||||
stdin=PIPE,
|
||||
stdout=PIPE,
|
||||
stderr=stderr_dest,
|
||||
cwd=self._cwd,
|
||||
env=process_env,
|
||||
user=self._options.user,
|
||||
)
|
||||
|
||||
if self._process.stdout:
|
||||
self._stdout_stream = TextReceiveStream(self._process.stdout)
|
||||
|
||||
# Setup stderr stream if piped
|
||||
if should_pipe_stderr and self._process.stderr:
|
||||
self._stderr_stream = TextReceiveStream(self._process.stderr)
|
||||
# Start async task to read stderr
|
||||
self._stderr_task_group = anyio.create_task_group()
|
||||
await self._stderr_task_group.__aenter__()
|
||||
self._stderr_task_group.start_soon(self._handle_stderr)
|
||||
|
||||
# Setup stdin for streaming mode
|
||||
if self._is_streaming and self._process.stdin:
|
||||
self._stdin_stream = TextSendStream(self._process.stdin)
|
||||
elif not self._is_streaming and self._process.stdin:
|
||||
# String mode: close stdin immediately
|
||||
await self._process.stdin.aclose()
|
||||
|
||||
self._ready = True
|
||||
|
||||
except FileNotFoundError as e:
|
||||
# Check if the error comes from the working directory or the CLI
|
||||
if self._cwd and not Path(self._cwd).exists():
|
||||
error = CLIConnectionError(
|
||||
f"Working directory does not exist: {self._cwd}"
|
||||
)
|
||||
self._exit_error = error
|
||||
raise error from e
|
||||
error = CLINotFoundError(f"Claude Code not found at: {self._cli_path}")
|
||||
self._exit_error = error
|
||||
raise error from e
|
||||
except Exception as e:
|
||||
error = CLIConnectionError(f"Failed to start Claude Code: {e}")
|
||||
self._exit_error = error
|
||||
raise error from e
|
||||
|
||||
async def _handle_stderr(self) -> None:
|
||||
"""Handle stderr stream - read and invoke callbacks."""
|
||||
if not self._stderr_stream:
|
||||
return
|
||||
|
||||
try:
|
||||
async for line in self._stderr_stream:
|
||||
line_str = line.rstrip()
|
||||
if not line_str:
|
||||
continue
|
||||
|
||||
# Call the stderr callback if provided
|
||||
if self._options.stderr:
|
||||
self._options.stderr(line_str)
|
||||
|
||||
# For backward compatibility: write to debug_stderr if in debug mode
|
||||
elif (
|
||||
"debug-to-stderr" in self._options.extra_args
|
||||
and self._options.debug_stderr
|
||||
):
|
||||
self._options.debug_stderr.write(line_str + "\n")
|
||||
if hasattr(self._options.debug_stderr, "flush"):
|
||||
self._options.debug_stderr.flush()
|
||||
except anyio.ClosedResourceError:
|
||||
pass # Stream closed, exit normally
|
||||
except Exception:
|
||||
pass # Ignore other errors during stderr reading
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the transport and clean up resources."""
|
||||
# Clean up temporary files first (before early return)
|
||||
for temp_file in self._temp_files:
|
||||
with suppress(Exception):
|
||||
Path(temp_file).unlink(missing_ok=True)
|
||||
self._temp_files.clear()
|
||||
|
||||
if not self._process:
|
||||
self._ready = False
|
||||
return
|
||||
|
||||
# Close stderr task group if active
|
||||
if self._stderr_task_group:
|
||||
with suppress(Exception):
|
||||
self._stderr_task_group.cancel_scope.cancel()
|
||||
await self._stderr_task_group.__aexit__(None, None, None)
|
||||
self._stderr_task_group = None
|
||||
|
||||
# Close stdin stream (acquire lock to prevent race with concurrent writes)
|
||||
async with self._write_lock:
|
||||
self._ready = False # Set inside lock to prevent TOCTOU with write()
|
||||
if self._stdin_stream:
|
||||
with suppress(Exception):
|
||||
await self._stdin_stream.aclose()
|
||||
self._stdin_stream = None
|
||||
|
||||
if self._stderr_stream:
|
||||
with suppress(Exception):
|
||||
await self._stderr_stream.aclose()
|
||||
self._stderr_stream = None
|
||||
|
||||
# Terminate and wait for process
|
||||
if self._process.returncode is None:
|
||||
with suppress(ProcessLookupError):
|
||||
self._process.terminate()
|
||||
# Wait for process to finish with timeout
|
||||
with suppress(Exception):
|
||||
# Just try to wait, but don't block if it fails
|
||||
await self._process.wait()
|
||||
|
||||
self._process = None
|
||||
self._stdout_stream = None
|
||||
self._stdin_stream = None
|
||||
self._stderr_stream = None
|
||||
self._exit_error = None
|
||||
|
||||
async def write(self, data: str) -> None:
|
||||
"""Write raw data to the transport."""
|
||||
async with self._write_lock:
|
||||
# All checks inside lock to prevent TOCTOU races with close()/end_input()
|
||||
if not self._ready or not self._stdin_stream:
|
||||
raise CLIConnectionError("ProcessTransport is not ready for writing")
|
||||
|
||||
if self._process and self._process.returncode is not None:
|
||||
raise CLIConnectionError(
|
||||
f"Cannot write to terminated process (exit code: {self._process.returncode})"
|
||||
)
|
||||
|
||||
if self._exit_error:
|
||||
raise CLIConnectionError(
|
||||
f"Cannot write to process that exited with error: {self._exit_error}"
|
||||
) from self._exit_error
|
||||
|
||||
try:
|
||||
await self._stdin_stream.send(data)
|
||||
except Exception as e:
|
||||
self._ready = False
|
||||
self._exit_error = CLIConnectionError(
|
||||
f"Failed to write to process stdin: {e}"
|
||||
)
|
||||
raise self._exit_error from e
|
||||
|
||||
async def end_input(self) -> None:
|
||||
"""End the input stream (close stdin)."""
|
||||
async with self._write_lock:
|
||||
if self._stdin_stream:
|
||||
with suppress(Exception):
|
||||
await self._stdin_stream.aclose()
|
||||
self._stdin_stream = None
|
||||
|
||||
def read_messages(self) -> AsyncIterator[dict[str, Any]]:
|
||||
"""Read and parse messages from the transport."""
|
||||
return self._read_messages_impl()
|
||||
|
||||
async def _read_messages_impl(self) -> AsyncIterator[dict[str, Any]]:
|
||||
"""Internal implementation of read_messages."""
|
||||
if not self._process or not self._stdout_stream:
|
||||
raise CLIConnectionError("Not connected")
|
||||
|
||||
json_buffer = ""
|
||||
|
||||
# Process stdout messages
|
||||
try:
|
||||
async for line in self._stdout_stream:
|
||||
line_str = line.strip()
|
||||
if not line_str:
|
||||
continue
|
||||
|
||||
# Accumulate partial JSON until we can parse it
|
||||
# Note: TextReceiveStream can truncate long lines, so we need to buffer
|
||||
# and speculatively parse until we get a complete JSON object
|
||||
json_lines = line_str.split("\n")
|
||||
|
||||
for json_line in json_lines:
|
||||
json_line = json_line.strip()
|
||||
if not json_line:
|
||||
continue
|
||||
|
||||
# Keep accumulating partial JSON until we can parse it
|
||||
json_buffer += json_line
|
||||
|
||||
if len(json_buffer) > self._max_buffer_size:
|
||||
buffer_length = len(json_buffer)
|
||||
json_buffer = ""
|
||||
raise SDKJSONDecodeError(
|
||||
f"JSON message exceeded maximum buffer size of {self._max_buffer_size} bytes",
|
||||
ValueError(
|
||||
f"Buffer size {buffer_length} exceeds limit {self._max_buffer_size}"
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
data = json.loads(json_buffer)
|
||||
json_buffer = ""
|
||||
yield data
|
||||
except json.JSONDecodeError:
|
||||
# We are speculatively decoding the buffer until we get
|
||||
# a full JSON object. If there is an actual issue, we
|
||||
# raise an error after exceeding the configured limit.
|
||||
continue
|
||||
|
||||
except anyio.ClosedResourceError:
|
||||
pass
|
||||
except GeneratorExit:
|
||||
# Client disconnected
|
||||
pass
|
||||
|
||||
# Check process completion and handle errors
|
||||
try:
|
||||
returncode = await self._process.wait()
|
||||
except Exception:
|
||||
returncode = -1
|
||||
|
||||
# Use exit code for error detection
|
||||
if returncode is not None and returncode != 0:
|
||||
self._exit_error = ProcessError(
|
||||
f"Command failed with exit code {returncode}",
|
||||
exit_code=returncode,
|
||||
stderr="Check stderr output for details",
|
||||
)
|
||||
raise self._exit_error
|
||||
|
||||
async def _check_claude_version(self) -> None:
|
||||
"""Check Claude Code version and warn if below minimum."""
|
||||
version_process = None
|
||||
try:
|
||||
with anyio.fail_after(2): # 2 second timeout
|
||||
version_process = await anyio.open_process(
|
||||
[self._cli_path, "-v"],
|
||||
stdout=PIPE,
|
||||
stderr=PIPE,
|
||||
)
|
||||
|
||||
if version_process.stdout:
|
||||
stdout_bytes = await version_process.stdout.receive()
|
||||
version_output = stdout_bytes.decode().strip()
|
||||
|
||||
match = re.match(r"([0-9]+\.[0-9]+\.[0-9]+)", version_output)
|
||||
if match:
|
||||
version = match.group(1)
|
||||
version_parts = [int(x) for x in version.split(".")]
|
||||
min_parts = [
|
||||
int(x) for x in MINIMUM_CLAUDE_CODE_VERSION.split(".")
|
||||
]
|
||||
|
||||
if version_parts < min_parts:
|
||||
warning = (
|
||||
f"Warning: Claude Code version {version} is unsupported in the Agent SDK. "
|
||||
f"Minimum required version is {MINIMUM_CLAUDE_CODE_VERSION}. "
|
||||
"Some features may not work correctly."
|
||||
)
|
||||
logger.warning(warning)
|
||||
print(warning, file=sys.stderr)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if version_process:
|
||||
with suppress(Exception):
|
||||
version_process.terminate()
|
||||
with suppress(Exception):
|
||||
await version_process.wait()
|
||||
|
||||
def is_ready(self) -> bool:
|
||||
"""Check if transport is ready for communication."""
|
||||
return self._ready
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Version information for claude-agent-sdk."""
|
||||
|
||||
__version__ = "0.1.21"
|
||||
377
.venv/lib/python3.11/site-packages/claude_agent_sdk/client.py
Normal file
377
.venv/lib/python3.11/site-packages/claude_agent_sdk/client.py
Normal file
@@ -0,0 +1,377 @@
|
||||
"""Claude SDK Client for interacting with Claude Code."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import AsyncIterable, AsyncIterator
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
from . import Transport
|
||||
from ._errors import CLIConnectionError
|
||||
from .types import ClaudeAgentOptions, HookEvent, HookMatcher, Message, ResultMessage
|
||||
|
||||
|
||||
class ClaudeSDKClient:
|
||||
"""
|
||||
Client for bidirectional, interactive conversations with Claude Code.
|
||||
|
||||
This client provides full control over the conversation flow with support
|
||||
for streaming, interrupts, and dynamic message sending. For simple one-shot
|
||||
queries, consider using the query() function instead.
|
||||
|
||||
Key features:
|
||||
- **Bidirectional**: Send and receive messages at any time
|
||||
- **Stateful**: Maintains conversation context across messages
|
||||
- **Interactive**: Send follow-ups based on responses
|
||||
- **Control flow**: Support for interrupts and session management
|
||||
|
||||
When to use ClaudeSDKClient:
|
||||
- Building chat interfaces or conversational UIs
|
||||
- Interactive debugging or exploration sessions
|
||||
- Multi-turn conversations with context
|
||||
- When you need to react to Claude's responses
|
||||
- Real-time applications with user input
|
||||
- When you need interrupt capabilities
|
||||
|
||||
When to use query() instead:
|
||||
- Simple one-off questions
|
||||
- Batch processing of prompts
|
||||
- Fire-and-forget automation scripts
|
||||
- When all inputs are known upfront
|
||||
- Stateless operations
|
||||
|
||||
See examples/streaming_mode.py for full examples of ClaudeSDKClient in
|
||||
different scenarios.
|
||||
|
||||
Caveat: As of v0.0.20, you cannot use a ClaudeSDKClient instance across
|
||||
different async runtime contexts (e.g., different trio nurseries or asyncio
|
||||
task groups). The client internally maintains a persistent anyio task group
|
||||
for reading messages that remains active from connect() until disconnect().
|
||||
This means you must complete all operations with the client within the same
|
||||
async context where it was connected. Ideally, this limitation should not
|
||||
exist.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
options: ClaudeAgentOptions | None = None,
|
||||
transport: Transport | None = None,
|
||||
):
|
||||
"""Initialize Claude SDK client."""
|
||||
if options is None:
|
||||
options = ClaudeAgentOptions()
|
||||
self.options = options
|
||||
self._custom_transport = transport
|
||||
self._transport: Transport | None = None
|
||||
self._query: Any | None = None
|
||||
os.environ["CLAUDE_CODE_ENTRYPOINT"] = "sdk-py-client"
|
||||
|
||||
def _convert_hooks_to_internal_format(
|
||||
self, hooks: dict[HookEvent, list[HookMatcher]]
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Convert HookMatcher format to internal Query format."""
|
||||
internal_hooks: dict[str, list[dict[str, Any]]] = {}
|
||||
for event, matchers in hooks.items():
|
||||
internal_hooks[event] = []
|
||||
for matcher in matchers:
|
||||
# Convert HookMatcher to internal dict format
|
||||
internal_matcher: dict[str, Any] = {
|
||||
"matcher": matcher.matcher if hasattr(matcher, "matcher") else None,
|
||||
"hooks": matcher.hooks if hasattr(matcher, "hooks") else [],
|
||||
}
|
||||
if hasattr(matcher, "timeout") and matcher.timeout is not None:
|
||||
internal_matcher["timeout"] = matcher.timeout
|
||||
internal_hooks[event].append(internal_matcher)
|
||||
return internal_hooks
|
||||
|
||||
async def connect(
|
||||
self, prompt: str | AsyncIterable[dict[str, Any]] | None = None
|
||||
) -> None:
|
||||
"""Connect to Claude with a prompt or message stream."""
|
||||
|
||||
from ._internal.query import Query
|
||||
from ._internal.transport.subprocess_cli import SubprocessCLITransport
|
||||
|
||||
# Auto-connect with empty async iterable if no prompt is provided
|
||||
async def _empty_stream() -> AsyncIterator[dict[str, Any]]:
|
||||
# Never yields, but indicates that this function is an iterator and
|
||||
# keeps the connection open.
|
||||
# This yield is never reached but makes this an async generator
|
||||
return
|
||||
yield {} # type: ignore[unreachable]
|
||||
|
||||
actual_prompt = _empty_stream() if prompt is None else prompt
|
||||
|
||||
# Validate and configure permission settings (matching TypeScript SDK logic)
|
||||
if self.options.can_use_tool:
|
||||
# canUseTool callback requires streaming mode (AsyncIterable prompt)
|
||||
if isinstance(prompt, str):
|
||||
raise ValueError(
|
||||
"can_use_tool callback requires streaming mode. "
|
||||
"Please provide prompt as an AsyncIterable instead of a string."
|
||||
)
|
||||
|
||||
# canUseTool and permission_prompt_tool_name are mutually exclusive
|
||||
if self.options.permission_prompt_tool_name:
|
||||
raise ValueError(
|
||||
"can_use_tool callback cannot be used with permission_prompt_tool_name. "
|
||||
"Please use one or the other."
|
||||
)
|
||||
|
||||
# Automatically set permission_prompt_tool_name to "stdio" for control protocol
|
||||
options = replace(self.options, permission_prompt_tool_name="stdio")
|
||||
else:
|
||||
options = self.options
|
||||
|
||||
# Use provided custom transport or create subprocess transport
|
||||
if self._custom_transport:
|
||||
self._transport = self._custom_transport
|
||||
else:
|
||||
self._transport = SubprocessCLITransport(
|
||||
prompt=actual_prompt,
|
||||
options=options,
|
||||
)
|
||||
await self._transport.connect()
|
||||
|
||||
# Extract SDK MCP servers from options
|
||||
sdk_mcp_servers = {}
|
||||
if self.options.mcp_servers and isinstance(self.options.mcp_servers, dict):
|
||||
for name, config in self.options.mcp_servers.items():
|
||||
if isinstance(config, dict) and config.get("type") == "sdk":
|
||||
sdk_mcp_servers[name] = config["instance"] # type: ignore[typeddict-item]
|
||||
|
||||
# Calculate initialize timeout from CLAUDE_CODE_STREAM_CLOSE_TIMEOUT env var if set
|
||||
# CLAUDE_CODE_STREAM_CLOSE_TIMEOUT is in milliseconds, convert to seconds
|
||||
initialize_timeout_ms = int(
|
||||
os.environ.get("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "60000")
|
||||
)
|
||||
initialize_timeout = max(initialize_timeout_ms / 1000.0, 60.0)
|
||||
|
||||
# Create Query to handle control protocol
|
||||
self._query = Query(
|
||||
transport=self._transport,
|
||||
is_streaming_mode=True, # ClaudeSDKClient always uses streaming mode
|
||||
can_use_tool=self.options.can_use_tool,
|
||||
hooks=self._convert_hooks_to_internal_format(self.options.hooks)
|
||||
if self.options.hooks
|
||||
else None,
|
||||
sdk_mcp_servers=sdk_mcp_servers,
|
||||
initialize_timeout=initialize_timeout,
|
||||
)
|
||||
|
||||
# Start reading messages and initialize
|
||||
await self._query.start()
|
||||
await self._query.initialize()
|
||||
|
||||
# If we have an initial prompt stream, start streaming it
|
||||
if prompt is not None and isinstance(prompt, AsyncIterable) and self._query._tg:
|
||||
self._query._tg.start_soon(self._query.stream_input, prompt)
|
||||
|
||||
async def receive_messages(self) -> AsyncIterator[Message]:
|
||||
"""Receive all messages from Claude."""
|
||||
if not self._query:
|
||||
raise CLIConnectionError("Not connected. Call connect() first.")
|
||||
|
||||
from ._internal.message_parser import parse_message
|
||||
|
||||
async for data in self._query.receive_messages():
|
||||
yield parse_message(data)
|
||||
|
||||
async def query(
|
||||
self, prompt: str | AsyncIterable[dict[str, Any]], session_id: str = "default"
|
||||
) -> None:
|
||||
"""
|
||||
Send a new request in streaming mode.
|
||||
|
||||
Args:
|
||||
prompt: Either a string message or an async iterable of message dictionaries
|
||||
session_id: Session identifier for the conversation
|
||||
"""
|
||||
if not self._query or not self._transport:
|
||||
raise CLIConnectionError("Not connected. Call connect() first.")
|
||||
|
||||
# Handle string prompts
|
||||
if isinstance(prompt, str):
|
||||
message = {
|
||||
"type": "user",
|
||||
"message": {"role": "user", "content": prompt},
|
||||
"parent_tool_use_id": None,
|
||||
"session_id": session_id,
|
||||
}
|
||||
await self._transport.write(json.dumps(message) + "\n")
|
||||
else:
|
||||
# Handle AsyncIterable prompts - stream them
|
||||
async for msg in prompt:
|
||||
# Ensure session_id is set on each message
|
||||
if "session_id" not in msg:
|
||||
msg["session_id"] = session_id
|
||||
await self._transport.write(json.dumps(msg) + "\n")
|
||||
|
||||
async def interrupt(self) -> None:
|
||||
"""Send interrupt signal (only works with streaming mode)."""
|
||||
if not self._query:
|
||||
raise CLIConnectionError("Not connected. Call connect() first.")
|
||||
await self._query.interrupt()
|
||||
|
||||
async def set_permission_mode(self, mode: str) -> None:
|
||||
"""Change permission mode during conversation (only works with streaming mode).
|
||||
|
||||
Args:
|
||||
mode: The permission mode to set. Valid options:
|
||||
- 'default': CLI prompts for dangerous tools
|
||||
- 'acceptEdits': Auto-accept file edits
|
||||
- 'bypassPermissions': Allow all tools (use with caution)
|
||||
|
||||
Example:
|
||||
```python
|
||||
async with ClaudeSDKClient() as client:
|
||||
# Start with default permissions
|
||||
await client.query("Help me analyze this codebase")
|
||||
|
||||
# Review mode done, switch to auto-accept edits
|
||||
await client.set_permission_mode('acceptEdits')
|
||||
await client.query("Now implement the fix we discussed")
|
||||
```
|
||||
"""
|
||||
if not self._query:
|
||||
raise CLIConnectionError("Not connected. Call connect() first.")
|
||||
await self._query.set_permission_mode(mode)
|
||||
|
||||
async def set_model(self, model: str | None = None) -> None:
|
||||
"""Change the AI model during conversation (only works with streaming mode).
|
||||
|
||||
Args:
|
||||
model: The model to use, or None to use default. Examples:
|
||||
- 'claude-sonnet-4-5'
|
||||
- 'claude-opus-4-1-20250805'
|
||||
- 'claude-opus-4-20250514'
|
||||
|
||||
Example:
|
||||
```python
|
||||
async with ClaudeSDKClient() as client:
|
||||
# Start with default model
|
||||
await client.query("Help me understand this problem")
|
||||
|
||||
# Switch to a different model for implementation
|
||||
await client.set_model('claude-sonnet-4-5')
|
||||
await client.query("Now implement the solution")
|
||||
```
|
||||
"""
|
||||
if not self._query:
|
||||
raise CLIConnectionError("Not connected. Call connect() first.")
|
||||
await self._query.set_model(model)
|
||||
|
||||
async def rewind_files(self, user_message_id: str) -> None:
|
||||
"""Rewind tracked files to their state at a specific user message.
|
||||
|
||||
Requires:
|
||||
- `enable_file_checkpointing=True` to track file changes
|
||||
- `extra_args={"replay-user-messages": None}` to receive UserMessage
|
||||
objects with `uuid` in the response stream
|
||||
|
||||
Args:
|
||||
user_message_id: UUID of the user message to rewind to. This should be
|
||||
the `uuid` field from a `UserMessage` received during the conversation.
|
||||
|
||||
Example:
|
||||
```python
|
||||
options = ClaudeAgentOptions(
|
||||
enable_file_checkpointing=True,
|
||||
extra_args={"replay-user-messages": None},
|
||||
)
|
||||
async with ClaudeSDKClient(options) as client:
|
||||
await client.query("Make some changes to my files")
|
||||
async for msg in client.receive_response():
|
||||
if isinstance(msg, UserMessage) and msg.uuid:
|
||||
checkpoint_id = msg.uuid # Save this for later
|
||||
|
||||
# Later, rewind to that point
|
||||
await client.rewind_files(checkpoint_id)
|
||||
```
|
||||
"""
|
||||
if not self._query:
|
||||
raise CLIConnectionError("Not connected. Call connect() first.")
|
||||
await self._query.rewind_files(user_message_id)
|
||||
|
||||
async def get_server_info(self) -> dict[str, Any] | None:
|
||||
"""Get server initialization info including available commands and output styles.
|
||||
|
||||
Returns initialization information from the Claude Code server including:
|
||||
- Available commands (slash commands, system commands, etc.)
|
||||
- Current and available output styles
|
||||
- Server capabilities
|
||||
|
||||
Returns:
|
||||
Dictionary with server info, or None if not in streaming mode
|
||||
|
||||
Example:
|
||||
```python
|
||||
async with ClaudeSDKClient() as client:
|
||||
info = await client.get_server_info()
|
||||
if info:
|
||||
print(f"Commands available: {len(info.get('commands', []))}")
|
||||
print(f"Output style: {info.get('output_style', 'default')}")
|
||||
```
|
||||
"""
|
||||
if not self._query:
|
||||
raise CLIConnectionError("Not connected. Call connect() first.")
|
||||
# Return the initialization result that was already obtained during connect
|
||||
return getattr(self._query, "_initialization_result", None)
|
||||
|
||||
async def receive_response(self) -> AsyncIterator[Message]:
|
||||
"""
|
||||
Receive messages from Claude until and including a ResultMessage.
|
||||
|
||||
This async iterator yields all messages in sequence and automatically terminates
|
||||
after yielding a ResultMessage (which indicates the response is complete).
|
||||
It's a convenience method over receive_messages() for single-response workflows.
|
||||
|
||||
**Stopping Behavior:**
|
||||
- Yields each message as it's received
|
||||
- Terminates immediately after yielding a ResultMessage
|
||||
- The ResultMessage IS included in the yielded messages
|
||||
- If no ResultMessage is received, the iterator continues indefinitely
|
||||
|
||||
Yields:
|
||||
Message: Each message received (UserMessage, AssistantMessage, SystemMessage, ResultMessage)
|
||||
|
||||
Example:
|
||||
```python
|
||||
async with ClaudeSDKClient() as client:
|
||||
await client.query("What's the capital of France?")
|
||||
|
||||
async for msg in client.receive_response():
|
||||
if isinstance(msg, AssistantMessage):
|
||||
for block in msg.content:
|
||||
if isinstance(block, TextBlock):
|
||||
print(f"Claude: {block.text}")
|
||||
elif isinstance(msg, ResultMessage):
|
||||
print(f"Cost: ${msg.total_cost_usd:.4f}")
|
||||
# Iterator will terminate after this message
|
||||
```
|
||||
|
||||
Note:
|
||||
To collect all messages: `messages = [msg async for msg in client.receive_response()]`
|
||||
The final message in the list will always be a ResultMessage.
|
||||
"""
|
||||
async for message in self.receive_messages():
|
||||
yield message
|
||||
if isinstance(message, ResultMessage):
|
||||
return
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from Claude."""
|
||||
if self._query:
|
||||
await self._query.close()
|
||||
self._query = None
|
||||
self._transport = None
|
||||
|
||||
async def __aenter__(self) -> "ClaudeSDKClient":
|
||||
"""Enter async context - automatically connects with empty stream for interactive use."""
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool:
|
||||
"""Exit async context - always disconnects."""
|
||||
await self.disconnect()
|
||||
return False
|
||||
126
.venv/lib/python3.11/site-packages/claude_agent_sdk/query.py
Normal file
126
.venv/lib/python3.11/site-packages/claude_agent_sdk/query.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Query function for one-shot interactions with Claude Code."""
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncIterable, AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from ._internal.client import InternalClient
|
||||
from ._internal.transport import Transport
|
||||
from .types import ClaudeAgentOptions, Message
|
||||
|
||||
|
||||
async def query(
|
||||
*,
|
||||
prompt: str | AsyncIterable[dict[str, Any]],
|
||||
options: ClaudeAgentOptions | None = None,
|
||||
transport: Transport | None = None,
|
||||
) -> AsyncIterator[Message]:
|
||||
"""
|
||||
Query Claude Code for one-shot or unidirectional streaming interactions.
|
||||
|
||||
This function is ideal for simple, stateless queries where you don't need
|
||||
bidirectional communication or conversation management. For interactive,
|
||||
stateful conversations, use ClaudeSDKClient instead.
|
||||
|
||||
Key differences from ClaudeSDKClient:
|
||||
- **Unidirectional**: Send all messages upfront, receive all responses
|
||||
- **Stateless**: Each query is independent, no conversation state
|
||||
- **Simple**: Fire-and-forget style, no connection management
|
||||
- **No interrupts**: Cannot interrupt or send follow-up messages
|
||||
|
||||
When to use query():
|
||||
- Simple one-off questions ("What is 2+2?")
|
||||
- Batch processing of independent prompts
|
||||
- Code generation or analysis tasks
|
||||
- Automated scripts and CI/CD pipelines
|
||||
- When you know all inputs upfront
|
||||
|
||||
When to use ClaudeSDKClient:
|
||||
- Interactive conversations with follow-ups
|
||||
- Chat applications or REPL-like interfaces
|
||||
- When you need to send messages based on responses
|
||||
- When you need interrupt capabilities
|
||||
- Long-running sessions with state
|
||||
|
||||
Args:
|
||||
prompt: The prompt to send to Claude. Can be a string for single-shot queries
|
||||
or an AsyncIterable[dict] for streaming mode with continuous interaction.
|
||||
In streaming mode, each dict should have the structure:
|
||||
{
|
||||
"type": "user",
|
||||
"message": {"role": "user", "content": "..."},
|
||||
"parent_tool_use_id": None,
|
||||
"session_id": "..."
|
||||
}
|
||||
options: Optional configuration (defaults to ClaudeAgentOptions() if None).
|
||||
Set options.permission_mode to control tool execution:
|
||||
- 'default': CLI prompts for dangerous tools
|
||||
- 'acceptEdits': Auto-accept file edits
|
||||
- 'bypassPermissions': Allow all tools (use with caution)
|
||||
Set options.cwd for working directory.
|
||||
transport: Optional transport implementation. If provided, this will be used
|
||||
instead of the default transport selection based on options.
|
||||
The transport will be automatically configured with the prompt and options.
|
||||
|
||||
Yields:
|
||||
Messages from the conversation
|
||||
|
||||
Example - Simple query:
|
||||
```python
|
||||
# One-off question
|
||||
async for message in query(prompt="What is the capital of France?"):
|
||||
print(message)
|
||||
```
|
||||
|
||||
Example - With options:
|
||||
```python
|
||||
# Code generation with specific settings
|
||||
async for message in query(
|
||||
prompt="Create a Python web server",
|
||||
options=ClaudeAgentOptions(
|
||||
system_prompt="You are an expert Python developer",
|
||||
cwd="/home/user/project"
|
||||
)
|
||||
):
|
||||
print(message)
|
||||
```
|
||||
|
||||
Example - Streaming mode (still unidirectional):
|
||||
```python
|
||||
async def prompts():
|
||||
yield {"type": "user", "message": {"role": "user", "content": "Hello"}}
|
||||
yield {"type": "user", "message": {"role": "user", "content": "How are you?"}}
|
||||
|
||||
# All prompts are sent, then all responses received
|
||||
async for message in query(prompt=prompts()):
|
||||
print(message)
|
||||
```
|
||||
|
||||
Example - With custom transport:
|
||||
```python
|
||||
from claude_agent_sdk import query, Transport
|
||||
|
||||
class MyCustomTransport(Transport):
|
||||
# Implement custom transport logic
|
||||
pass
|
||||
|
||||
transport = MyCustomTransport()
|
||||
async for message in query(
|
||||
prompt="Hello",
|
||||
transport=transport
|
||||
):
|
||||
print(message)
|
||||
```
|
||||
|
||||
"""
|
||||
if options is None:
|
||||
options = ClaudeAgentOptions()
|
||||
|
||||
os.environ["CLAUDE_CODE_ENTRYPOINT"] = "sdk-py"
|
||||
|
||||
client = InternalClient()
|
||||
|
||||
async for message in client.process_query(
|
||||
prompt=prompt, options=options, transport=transport
|
||||
):
|
||||
yield message
|
||||
754
.venv/lib/python3.11/site-packages/claude_agent_sdk/types.py
Normal file
754
.venv/lib/python3.11/site-packages/claude_agent_sdk/types.py
Normal file
@@ -0,0 +1,754 @@
|
||||
"""Type definitions for Claude SDK."""
|
||||
|
||||
import sys
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal, TypedDict
|
||||
|
||||
from typing_extensions import NotRequired
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server import Server as McpServer
|
||||
else:
|
||||
# Runtime placeholder for forward reference resolution in Pydantic 2.12+
|
||||
McpServer = Any
|
||||
|
||||
# Permission modes
|
||||
PermissionMode = Literal["default", "acceptEdits", "plan", "bypassPermissions"]
|
||||
|
||||
# SDK Beta features - see https://docs.anthropic.com/en/api/beta-headers
|
||||
SdkBeta = Literal["context-1m-2025-08-07"]
|
||||
|
||||
# Agent definitions
|
||||
SettingSource = Literal["user", "project", "local"]
|
||||
|
||||
|
||||
class SystemPromptPreset(TypedDict):
|
||||
"""System prompt preset configuration."""
|
||||
|
||||
type: Literal["preset"]
|
||||
preset: Literal["claude_code"]
|
||||
append: NotRequired[str]
|
||||
|
||||
|
||||
class ToolsPreset(TypedDict):
|
||||
"""Tools preset configuration."""
|
||||
|
||||
type: Literal["preset"]
|
||||
preset: Literal["claude_code"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentDefinition:
|
||||
"""Agent definition configuration."""
|
||||
|
||||
description: str
|
||||
prompt: str
|
||||
tools: list[str] | None = None
|
||||
model: Literal["sonnet", "opus", "haiku", "inherit"] | None = None
|
||||
|
||||
|
||||
# Permission Update types (matching TypeScript SDK)
|
||||
PermissionUpdateDestination = Literal[
|
||||
"userSettings", "projectSettings", "localSettings", "session"
|
||||
]
|
||||
|
||||
PermissionBehavior = Literal["allow", "deny", "ask"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PermissionRuleValue:
|
||||
"""Permission rule value."""
|
||||
|
||||
tool_name: str
|
||||
rule_content: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PermissionUpdate:
|
||||
"""Permission update configuration."""
|
||||
|
||||
type: Literal[
|
||||
"addRules",
|
||||
"replaceRules",
|
||||
"removeRules",
|
||||
"setMode",
|
||||
"addDirectories",
|
||||
"removeDirectories",
|
||||
]
|
||||
rules: list[PermissionRuleValue] | None = None
|
||||
behavior: PermissionBehavior | None = None
|
||||
mode: PermissionMode | None = None
|
||||
directories: list[str] | None = None
|
||||
destination: PermissionUpdateDestination | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert PermissionUpdate to dictionary format matching TypeScript control protocol."""
|
||||
result: dict[str, Any] = {
|
||||
"type": self.type,
|
||||
}
|
||||
|
||||
# Add destination for all variants
|
||||
if self.destination is not None:
|
||||
result["destination"] = self.destination
|
||||
|
||||
# Handle different type variants
|
||||
if self.type in ["addRules", "replaceRules", "removeRules"]:
|
||||
# Rules-based variants require rules and behavior
|
||||
if self.rules is not None:
|
||||
result["rules"] = [
|
||||
{
|
||||
"toolName": rule.tool_name,
|
||||
"ruleContent": rule.rule_content,
|
||||
}
|
||||
for rule in self.rules
|
||||
]
|
||||
if self.behavior is not None:
|
||||
result["behavior"] = self.behavior
|
||||
|
||||
elif self.type == "setMode":
|
||||
# Mode variant requires mode
|
||||
if self.mode is not None:
|
||||
result["mode"] = self.mode
|
||||
|
||||
elif self.type in ["addDirectories", "removeDirectories"]:
|
||||
# Directory variants require directories
|
||||
if self.directories is not None:
|
||||
result["directories"] = self.directories
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# Tool callback types
|
||||
@dataclass
|
||||
class ToolPermissionContext:
|
||||
"""Context information for tool permission callbacks."""
|
||||
|
||||
signal: Any | None = None # Future: abort signal support
|
||||
suggestions: list[PermissionUpdate] = field(
|
||||
default_factory=list
|
||||
) # Permission suggestions from CLI
|
||||
|
||||
|
||||
# Match TypeScript's PermissionResult structure
|
||||
@dataclass
|
||||
class PermissionResultAllow:
|
||||
"""Allow permission result."""
|
||||
|
||||
behavior: Literal["allow"] = "allow"
|
||||
updated_input: dict[str, Any] | None = None
|
||||
updated_permissions: list[PermissionUpdate] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PermissionResultDeny:
|
||||
"""Deny permission result."""
|
||||
|
||||
behavior: Literal["deny"] = "deny"
|
||||
message: str = ""
|
||||
interrupt: bool = False
|
||||
|
||||
|
||||
PermissionResult = PermissionResultAllow | PermissionResultDeny
|
||||
|
||||
CanUseTool = Callable[
|
||||
[str, dict[str, Any], ToolPermissionContext], Awaitable[PermissionResult]
|
||||
]
|
||||
|
||||
|
||||
##### Hook types
|
||||
# Supported hook event types. Due to setup limitations, the Python SDK does not
|
||||
# support SessionStart, SessionEnd, and Notification hooks.
|
||||
HookEvent = (
|
||||
Literal["PreToolUse"]
|
||||
| Literal["PostToolUse"]
|
||||
| Literal["UserPromptSubmit"]
|
||||
| Literal["Stop"]
|
||||
| Literal["SubagentStop"]
|
||||
| Literal["PreCompact"]
|
||||
)
|
||||
|
||||
|
||||
# Hook input types - strongly typed for each hook event
|
||||
class BaseHookInput(TypedDict):
|
||||
"""Base hook input fields present across many hook events."""
|
||||
|
||||
session_id: str
|
||||
transcript_path: str
|
||||
cwd: str
|
||||
permission_mode: NotRequired[str]
|
||||
|
||||
|
||||
class PreToolUseHookInput(BaseHookInput):
|
||||
"""Input data for PreToolUse hook events."""
|
||||
|
||||
hook_event_name: Literal["PreToolUse"]
|
||||
tool_name: str
|
||||
tool_input: dict[str, Any]
|
||||
|
||||
|
||||
class PostToolUseHookInput(BaseHookInput):
|
||||
"""Input data for PostToolUse hook events."""
|
||||
|
||||
hook_event_name: Literal["PostToolUse"]
|
||||
tool_name: str
|
||||
tool_input: dict[str, Any]
|
||||
tool_response: Any
|
||||
|
||||
|
||||
class UserPromptSubmitHookInput(BaseHookInput):
|
||||
"""Input data for UserPromptSubmit hook events."""
|
||||
|
||||
hook_event_name: Literal["UserPromptSubmit"]
|
||||
prompt: str
|
||||
|
||||
|
||||
class StopHookInput(BaseHookInput):
|
||||
"""Input data for Stop hook events."""
|
||||
|
||||
hook_event_name: Literal["Stop"]
|
||||
stop_hook_active: bool
|
||||
|
||||
|
||||
class SubagentStopHookInput(BaseHookInput):
|
||||
"""Input data for SubagentStop hook events."""
|
||||
|
||||
hook_event_name: Literal["SubagentStop"]
|
||||
stop_hook_active: bool
|
||||
|
||||
|
||||
class PreCompactHookInput(BaseHookInput):
|
||||
"""Input data for PreCompact hook events."""
|
||||
|
||||
hook_event_name: Literal["PreCompact"]
|
||||
trigger: Literal["manual", "auto"]
|
||||
custom_instructions: str | None
|
||||
|
||||
|
||||
# Union type for all hook inputs
|
||||
HookInput = (
|
||||
PreToolUseHookInput
|
||||
| PostToolUseHookInput
|
||||
| UserPromptSubmitHookInput
|
||||
| StopHookInput
|
||||
| SubagentStopHookInput
|
||||
| PreCompactHookInput
|
||||
)
|
||||
|
||||
|
||||
# Hook-specific output types
|
||||
class PreToolUseHookSpecificOutput(TypedDict):
|
||||
"""Hook-specific output for PreToolUse events."""
|
||||
|
||||
hookEventName: Literal["PreToolUse"]
|
||||
permissionDecision: NotRequired[Literal["allow", "deny", "ask"]]
|
||||
permissionDecisionReason: NotRequired[str]
|
||||
updatedInput: NotRequired[dict[str, Any]]
|
||||
|
||||
|
||||
class PostToolUseHookSpecificOutput(TypedDict):
|
||||
"""Hook-specific output for PostToolUse events."""
|
||||
|
||||
hookEventName: Literal["PostToolUse"]
|
||||
additionalContext: NotRequired[str]
|
||||
|
||||
|
||||
class UserPromptSubmitHookSpecificOutput(TypedDict):
|
||||
"""Hook-specific output for UserPromptSubmit events."""
|
||||
|
||||
hookEventName: Literal["UserPromptSubmit"]
|
||||
additionalContext: NotRequired[str]
|
||||
|
||||
|
||||
class SessionStartHookSpecificOutput(TypedDict):
|
||||
"""Hook-specific output for SessionStart events."""
|
||||
|
||||
hookEventName: Literal["SessionStart"]
|
||||
additionalContext: NotRequired[str]
|
||||
|
||||
|
||||
HookSpecificOutput = (
|
||||
PreToolUseHookSpecificOutput
|
||||
| PostToolUseHookSpecificOutput
|
||||
| UserPromptSubmitHookSpecificOutput
|
||||
| SessionStartHookSpecificOutput
|
||||
)
|
||||
|
||||
|
||||
# See https://docs.anthropic.com/en/docs/claude-code/hooks#advanced%3A-json-output
|
||||
# for documentation of the output types.
|
||||
#
|
||||
# IMPORTANT: The Python SDK uses `async_` and `continue_` (with underscores) to avoid
|
||||
# Python keyword conflicts. These fields are automatically converted to `async` and
|
||||
# `continue` when sent to the CLI. You should use the underscore versions in your
|
||||
# Python code.
|
||||
class AsyncHookJSONOutput(TypedDict):
|
||||
"""Async hook output that defers hook execution.
|
||||
|
||||
Fields:
|
||||
async_: Set to True to defer hook execution. Note: This is converted to
|
||||
"async" when sent to the CLI - use "async_" in your Python code.
|
||||
asyncTimeout: Optional timeout in milliseconds for the async operation.
|
||||
"""
|
||||
|
||||
async_: Literal[
|
||||
True
|
||||
] # Using async_ to avoid Python keyword (converted to "async" for CLI)
|
||||
asyncTimeout: NotRequired[int]
|
||||
|
||||
|
||||
class SyncHookJSONOutput(TypedDict):
|
||||
"""Synchronous hook output with control and decision fields.
|
||||
|
||||
This defines the structure for hook callbacks to control execution and provide
|
||||
feedback to Claude.
|
||||
|
||||
Common Control Fields:
|
||||
continue_: Whether Claude should proceed after hook execution (default: True).
|
||||
Note: This is converted to "continue" when sent to the CLI.
|
||||
suppressOutput: Hide stdout from transcript mode (default: False).
|
||||
stopReason: Message shown when continue is False.
|
||||
|
||||
Decision Fields:
|
||||
decision: Set to "block" to indicate blocking behavior.
|
||||
systemMessage: Warning message displayed to the user.
|
||||
reason: Feedback message for Claude about the decision.
|
||||
|
||||
Hook-Specific Output:
|
||||
hookSpecificOutput: Event-specific controls (e.g., permissionDecision for
|
||||
PreToolUse, additionalContext for PostToolUse).
|
||||
|
||||
Note: The CLI documentation shows field names without underscores ("async", "continue"),
|
||||
but Python code should use the underscore versions ("async_", "continue_") as they
|
||||
are automatically converted.
|
||||
"""
|
||||
|
||||
# Common control fields
|
||||
continue_: NotRequired[
|
||||
bool
|
||||
] # Using continue_ to avoid Python keyword (converted to "continue" for CLI)
|
||||
suppressOutput: NotRequired[bool]
|
||||
stopReason: NotRequired[str]
|
||||
|
||||
# Decision fields
|
||||
# Note: "approve" is deprecated for PreToolUse (use permissionDecision instead)
|
||||
# For other hooks, only "block" is meaningful
|
||||
decision: NotRequired[Literal["block"]]
|
||||
systemMessage: NotRequired[str]
|
||||
reason: NotRequired[str]
|
||||
|
||||
# Hook-specific outputs
|
||||
hookSpecificOutput: NotRequired[HookSpecificOutput]
|
||||
|
||||
|
||||
HookJSONOutput = AsyncHookJSONOutput | SyncHookJSONOutput
|
||||
|
||||
|
||||
class HookContext(TypedDict):
|
||||
"""Context information for hook callbacks.
|
||||
|
||||
Fields:
|
||||
signal: Reserved for future abort signal support. Currently always None.
|
||||
"""
|
||||
|
||||
signal: Any | None # Future: abort signal support
|
||||
|
||||
|
||||
HookCallback = Callable[
|
||||
# HookCallback input parameters:
|
||||
# - input: Strongly-typed hook input with discriminated unions based on hook_event_name
|
||||
# - tool_use_id: Optional tool use identifier
|
||||
# - context: Hook context with abort signal support (currently placeholder)
|
||||
[HookInput, str | None, HookContext],
|
||||
Awaitable[HookJSONOutput],
|
||||
]
|
||||
|
||||
|
||||
# Hook matcher configuration
|
||||
@dataclass
|
||||
class HookMatcher:
|
||||
"""Hook matcher configuration."""
|
||||
|
||||
# See https://docs.anthropic.com/en/docs/claude-code/hooks#structure for the
|
||||
# expected string value. For example, for PreToolUse, the matcher can be
|
||||
# a tool name like "Bash" or a combination of tool names like
|
||||
# "Write|MultiEdit|Edit".
|
||||
matcher: str | None = None
|
||||
|
||||
# A list of Python functions with function signature HookCallback
|
||||
hooks: list[HookCallback] = field(default_factory=list)
|
||||
|
||||
# Timeout in seconds for all hooks in this matcher (default: 60)
|
||||
timeout: float | None = None
|
||||
|
||||
|
||||
# MCP Server config
|
||||
class McpStdioServerConfig(TypedDict):
|
||||
"""MCP stdio server configuration."""
|
||||
|
||||
type: NotRequired[Literal["stdio"]] # Optional for backwards compatibility
|
||||
command: str
|
||||
args: NotRequired[list[str]]
|
||||
env: NotRequired[dict[str, str]]
|
||||
|
||||
|
||||
class McpSSEServerConfig(TypedDict):
|
||||
"""MCP SSE server configuration."""
|
||||
|
||||
type: Literal["sse"]
|
||||
url: str
|
||||
headers: NotRequired[dict[str, str]]
|
||||
|
||||
|
||||
class McpHttpServerConfig(TypedDict):
|
||||
"""MCP HTTP server configuration."""
|
||||
|
||||
type: Literal["http"]
|
||||
url: str
|
||||
headers: NotRequired[dict[str, str]]
|
||||
|
||||
|
||||
class McpSdkServerConfig(TypedDict):
|
||||
"""SDK MCP server configuration."""
|
||||
|
||||
type: Literal["sdk"]
|
||||
name: str
|
||||
instance: "McpServer"
|
||||
|
||||
|
||||
McpServerConfig = (
|
||||
McpStdioServerConfig | McpSSEServerConfig | McpHttpServerConfig | McpSdkServerConfig
|
||||
)
|
||||
|
||||
|
||||
class SdkPluginConfig(TypedDict):
|
||||
"""SDK plugin configuration.
|
||||
|
||||
Currently only local plugins are supported via the 'local' type.
|
||||
"""
|
||||
|
||||
type: Literal["local"]
|
||||
path: str
|
||||
|
||||
|
||||
# Sandbox configuration types
|
||||
class SandboxNetworkConfig(TypedDict, total=False):
|
||||
"""Network configuration for sandbox.
|
||||
|
||||
Attributes:
|
||||
allowUnixSockets: Unix socket paths accessible in sandbox (e.g., SSH agents).
|
||||
allowAllUnixSockets: Allow all Unix sockets (less secure).
|
||||
allowLocalBinding: Allow binding to localhost ports (macOS only).
|
||||
httpProxyPort: HTTP proxy port if bringing your own proxy.
|
||||
socksProxyPort: SOCKS5 proxy port if bringing your own proxy.
|
||||
"""
|
||||
|
||||
allowUnixSockets: list[str]
|
||||
allowAllUnixSockets: bool
|
||||
allowLocalBinding: bool
|
||||
httpProxyPort: int
|
||||
socksProxyPort: int
|
||||
|
||||
|
||||
class SandboxIgnoreViolations(TypedDict, total=False):
|
||||
"""Violations to ignore in sandbox.
|
||||
|
||||
Attributes:
|
||||
file: File paths for which violations should be ignored.
|
||||
network: Network hosts for which violations should be ignored.
|
||||
"""
|
||||
|
||||
file: list[str]
|
||||
network: list[str]
|
||||
|
||||
|
||||
class SandboxSettings(TypedDict, total=False):
|
||||
"""Sandbox settings configuration.
|
||||
|
||||
This controls how Claude Code sandboxes bash commands for filesystem
|
||||
and network isolation.
|
||||
|
||||
**Important:** Filesystem and network restrictions are configured via permission
|
||||
rules, not via these sandbox settings:
|
||||
- Filesystem read restrictions: Use Read deny rules
|
||||
- Filesystem write restrictions: Use Edit allow/deny rules
|
||||
- Network restrictions: Use WebFetch allow/deny rules
|
||||
|
||||
Attributes:
|
||||
enabled: Enable bash sandboxing (macOS/Linux only). Default: False
|
||||
autoAllowBashIfSandboxed: Auto-approve bash commands when sandboxed. Default: True
|
||||
excludedCommands: Commands that should run outside the sandbox (e.g., ["git", "docker"])
|
||||
allowUnsandboxedCommands: Allow commands to bypass sandbox via dangerouslyDisableSandbox.
|
||||
When False, all commands must run sandboxed (or be in excludedCommands). Default: True
|
||||
network: Network configuration for sandbox.
|
||||
ignoreViolations: Violations to ignore.
|
||||
enableWeakerNestedSandbox: Enable weaker sandbox for unprivileged Docker environments
|
||||
(Linux only). Reduces security. Default: False
|
||||
|
||||
Example:
|
||||
```python
|
||||
sandbox_settings: SandboxSettings = {
|
||||
"enabled": True,
|
||||
"autoAllowBashIfSandboxed": True,
|
||||
"excludedCommands": ["docker"],
|
||||
"network": {
|
||||
"allowUnixSockets": ["/var/run/docker.sock"],
|
||||
"allowLocalBinding": True
|
||||
}
|
||||
}
|
||||
```
|
||||
"""
|
||||
|
||||
enabled: bool
|
||||
autoAllowBashIfSandboxed: bool
|
||||
excludedCommands: list[str]
|
||||
allowUnsandboxedCommands: bool
|
||||
network: SandboxNetworkConfig
|
||||
ignoreViolations: SandboxIgnoreViolations
|
||||
enableWeakerNestedSandbox: bool
|
||||
|
||||
|
||||
# Content block types
|
||||
@dataclass
|
||||
class TextBlock:
|
||||
"""Text content block."""
|
||||
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThinkingBlock:
|
||||
"""Thinking content block."""
|
||||
|
||||
thinking: str
|
||||
signature: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolUseBlock:
|
||||
"""Tool use content block."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
input: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolResultBlock:
|
||||
"""Tool result content block."""
|
||||
|
||||
tool_use_id: str
|
||||
content: str | list[dict[str, Any]] | None = None
|
||||
is_error: bool | None = None
|
||||
|
||||
|
||||
ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock
|
||||
|
||||
|
||||
# Message types
|
||||
AssistantMessageError = Literal[
|
||||
"authentication_failed",
|
||||
"billing_error",
|
||||
"rate_limit",
|
||||
"invalid_request",
|
||||
"server_error",
|
||||
"unknown",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserMessage:
|
||||
"""User message."""
|
||||
|
||||
content: str | list[ContentBlock]
|
||||
uuid: str | None = None
|
||||
parent_tool_use_id: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssistantMessage:
|
||||
"""Assistant message with content blocks."""
|
||||
|
||||
content: list[ContentBlock]
|
||||
model: str
|
||||
parent_tool_use_id: str | None = None
|
||||
error: AssistantMessageError | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemMessage:
|
||||
"""System message with metadata."""
|
||||
|
||||
subtype: str
|
||||
data: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResultMessage:
|
||||
"""Result message with cost and usage information."""
|
||||
|
||||
subtype: str
|
||||
duration_ms: int
|
||||
duration_api_ms: int
|
||||
is_error: bool
|
||||
num_turns: int
|
||||
session_id: str
|
||||
total_cost_usd: float | None = None
|
||||
usage: dict[str, Any] | None = None
|
||||
result: str | None = None
|
||||
structured_output: Any = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamEvent:
|
||||
"""Stream event for partial message updates during streaming."""
|
||||
|
||||
uuid: str
|
||||
session_id: str
|
||||
event: dict[str, Any] # The raw Anthropic API stream event
|
||||
parent_tool_use_id: str | None = None
|
||||
|
||||
|
||||
Message = UserMessage | AssistantMessage | SystemMessage | ResultMessage | StreamEvent
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClaudeAgentOptions:
|
||||
"""Query options for Claude SDK."""
|
||||
|
||||
tools: list[str] | ToolsPreset | None = None
|
||||
allowed_tools: list[str] = field(default_factory=list)
|
||||
system_prompt: str | SystemPromptPreset | None = None
|
||||
mcp_servers: dict[str, McpServerConfig] | str | Path = field(default_factory=dict)
|
||||
permission_mode: PermissionMode | None = None
|
||||
continue_conversation: bool = False
|
||||
resume: str | None = None
|
||||
max_turns: int | None = None
|
||||
max_budget_usd: float | None = None
|
||||
disallowed_tools: list[str] = field(default_factory=list)
|
||||
model: str | None = None
|
||||
fallback_model: str | None = None
|
||||
# Beta features - see https://docs.anthropic.com/en/api/beta-headers
|
||||
betas: list[SdkBeta] = field(default_factory=list)
|
||||
permission_prompt_tool_name: str | None = None
|
||||
cwd: str | Path | None = None
|
||||
cli_path: str | Path | None = None
|
||||
settings: str | None = None
|
||||
add_dirs: list[str | Path] = field(default_factory=list)
|
||||
env: dict[str, str] = field(default_factory=dict)
|
||||
extra_args: dict[str, str | None] = field(
|
||||
default_factory=dict
|
||||
) # Pass arbitrary CLI flags
|
||||
max_buffer_size: int | None = None # Max bytes when buffering CLI stdout
|
||||
debug_stderr: Any = (
|
||||
sys.stderr
|
||||
) # Deprecated: File-like object for debug output. Use stderr callback instead.
|
||||
stderr: Callable[[str], None] | None = None # Callback for stderr output from CLI
|
||||
|
||||
# Tool permission callback
|
||||
can_use_tool: CanUseTool | None = None
|
||||
|
||||
# Hook configurations
|
||||
hooks: dict[HookEvent, list[HookMatcher]] | None = None
|
||||
|
||||
user: str | None = None
|
||||
|
||||
# Partial message streaming support
|
||||
include_partial_messages: bool = False
|
||||
# When true resumed sessions will fork to a new session ID rather than
|
||||
# continuing the previous session.
|
||||
fork_session: bool = False
|
||||
# Agent definitions for custom agents
|
||||
agents: dict[str, AgentDefinition] | None = None
|
||||
# Setting sources to load (user, project, local)
|
||||
setting_sources: list[SettingSource] | None = None
|
||||
# Sandbox configuration for bash command isolation.
|
||||
# Filesystem and network restrictions are derived from permission rules (Read/Edit/WebFetch),
|
||||
# not from these sandbox settings.
|
||||
sandbox: SandboxSettings | None = None
|
||||
# Plugin configurations for custom plugins
|
||||
plugins: list[SdkPluginConfig] = field(default_factory=list)
|
||||
# Max tokens for thinking blocks
|
||||
max_thinking_tokens: int | None = None
|
||||
# Output format for structured outputs (matches Messages API structure)
|
||||
# Example: {"type": "json_schema", "schema": {"type": "object", "properties": {...}}}
|
||||
output_format: dict[str, Any] | None = None
|
||||
# Enable file checkpointing to track file changes during the session.
|
||||
# When enabled, files can be rewound to their state at any user message
|
||||
# using `ClaudeSDKClient.rewind_files()`.
|
||||
enable_file_checkpointing: bool = False
|
||||
|
||||
|
||||
# SDK Control Protocol
|
||||
class SDKControlInterruptRequest(TypedDict):
|
||||
subtype: Literal["interrupt"]
|
||||
|
||||
|
||||
class SDKControlPermissionRequest(TypedDict):
|
||||
subtype: Literal["can_use_tool"]
|
||||
tool_name: str
|
||||
input: dict[str, Any]
|
||||
# TODO: Add PermissionUpdate type here
|
||||
permission_suggestions: list[Any] | None
|
||||
blocked_path: str | None
|
||||
|
||||
|
||||
class SDKControlInitializeRequest(TypedDict):
|
||||
subtype: Literal["initialize"]
|
||||
hooks: dict[HookEvent, Any] | None
|
||||
|
||||
|
||||
class SDKControlSetPermissionModeRequest(TypedDict):
|
||||
subtype: Literal["set_permission_mode"]
|
||||
# TODO: Add PermissionMode
|
||||
mode: str
|
||||
|
||||
|
||||
class SDKHookCallbackRequest(TypedDict):
|
||||
subtype: Literal["hook_callback"]
|
||||
callback_id: str
|
||||
input: Any
|
||||
tool_use_id: str | None
|
||||
|
||||
|
||||
class SDKControlMcpMessageRequest(TypedDict):
|
||||
subtype: Literal["mcp_message"]
|
||||
server_name: str
|
||||
message: Any
|
||||
|
||||
|
||||
class SDKControlRewindFilesRequest(TypedDict):
|
||||
subtype: Literal["rewind_files"]
|
||||
user_message_id: str
|
||||
|
||||
|
||||
class SDKControlRequest(TypedDict):
|
||||
type: Literal["control_request"]
|
||||
request_id: str
|
||||
request: (
|
||||
SDKControlInterruptRequest
|
||||
| SDKControlPermissionRequest
|
||||
| SDKControlInitializeRequest
|
||||
| SDKControlSetPermissionModeRequest
|
||||
| SDKHookCallbackRequest
|
||||
| SDKControlMcpMessageRequest
|
||||
| SDKControlRewindFilesRequest
|
||||
)
|
||||
|
||||
|
||||
class ControlResponse(TypedDict):
|
||||
subtype: Literal["success"]
|
||||
request_id: str
|
||||
response: dict[str, Any] | None
|
||||
|
||||
|
||||
class ControlErrorResponse(TypedDict):
|
||||
subtype: Literal["error"]
|
||||
request_id: str
|
||||
error: str
|
||||
|
||||
|
||||
class SDKControlResponse(TypedDict):
|
||||
type: Literal["control_response"]
|
||||
response: ControlResponse | ControlErrorResponse
|
||||
Reference in New Issue
Block a user