diff --git a/backend/apps/agents/core/error_classify.py b/backend/apps/agents/core/error_classify.py index cc3008b9..27e915d3 100644 --- a/backend/apps/agents/core/error_classify.py +++ b/backend/apps/agents/core/error_classify.py @@ -1,7 +1,6 @@ import re from typing import Optional, Tuple -import anthropic import httpx from typeguard import typechecked @@ -207,9 +206,19 @@ def parse_retry_after(exc: BaseException, extra_text: str = "") -> int | None: # anthropic.APIConnectionError stringifies to the bare "Connection error.", so the patterns above # score it NON-transient and one network hiccup throws away a whole run (measured live, twice). A # transport failure is transient by construction, so classify by TYPE, which no rewording breaks. -P_TRANSIENT_EXC_TYPES: Tuple[type, ...] = ( - anthropic.APIConnectionError, anthropic.InternalServerError, # APITimeoutError subclasses the first - httpx.TransportError, ConnectionError, TimeoutError) # connect/read/pool timeouts, protocol errors +# Built lazily: importing the anthropic SDK at module scope cost 224ms of every backend boot. +p_transient_exc_types: Optional[Tuple[type, ...]] = None + + +def p_get_transient_exc_types() -> Tuple[type, ...]: + global p_transient_exc_types + if p_transient_exc_types is None: + import anthropic + + p_transient_exc_types = ( + anthropic.APIConnectionError, anthropic.InternalServerError, # APITimeoutError subclasses the first + httpx.TransportError, ConnectionError, TimeoutError) # connect/read/pool timeouts, protocol errors + return p_transient_exc_types @typechecked @@ -222,7 +231,7 @@ def is_transient_capacity_error(exc: BaseException, extra_text: str = "") -> boo if is_context_overflow_error(exc, extra_text): return False # Ahead of the empty-string bail on purpose: what the exception IS doesn't depend on whether it bothered to say anything. - if isinstance(exc, P_TRANSIENT_EXC_TYPES): + if isinstance(exc, p_get_transient_exc_types()): return True if not combined: return False diff --git a/backend/apps/agents/manager/permissions/gate_hooks.py b/backend/apps/agents/manager/permissions/gate_hooks.py index d85b683b..c83725fb 100644 --- a/backend/apps/agents/manager/permissions/gate_hooks.py +++ b/backend/apps/agents/manager/permissions/gate_hooks.py @@ -11,7 +11,13 @@ import time from typing import Dict, Optional, Union from typeguard import typechecked -from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny +# Runtime aliases stay `object` so the 350ms claude_agent_sdk+mcp chain stays off the boot graph; the hook body re-imports the real classes at call time, when the SDK is already resident. +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny +else: + PermissionResultAllow = PermissionResultDeny = object from backend.apps.agents.core.ws_manager import ws_manager from backend.apps.settings.settings import load_settings @@ -36,6 +42,8 @@ logger = logging.getLogger(__name__) async def can_use_tool( ctx: HookContext, tool_name: str, input_data: object, context: object ) -> Union[PermissionResultAllow, PermissionResultDeny]: + from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny + if is_claude_schedule_skill(tool_name, input_data): note_tool_used(ctx.session_id, tool_name, False) return PermissionResultDeny( diff --git a/backend/apps/agents/manager/streaming/handle_assistant_message.py b/backend/apps/agents/manager/streaming/handle_assistant_message.py index e7a4dfc4..fb9d0c7b 100644 --- a/backend/apps/agents/manager/streaming/handle_assistant_message.py +++ b/backend/apps/agents/manager/streaming/handle_assistant_message.py @@ -18,11 +18,13 @@ from backend.apps.agents.manager.streaming.upsert_message import upsert_message from backend.apps.agents.manager.streaming.PartialReply import PartialReply from backend.apps.agents.manager.streaming import thinking as thinking_mod -try: +# The block types drive isinstance DISPATCH, so they must be real at runtime; imported inside the handler because by stream time the SDK is already resident (the turn's presence check imported it), keeping the 350ms sdk+mcp chain off the boot graph. +from typing import TYPE_CHECKING + +if TYPE_CHECKING: from claude_agent_sdk import AssistantMessage - from claude_agent_sdk.types import ThinkingBlock, TextBlock, ToolUseBlock -except ImportError: # the SDK is optional at runtime (mock mode); keep this module importable - AssistantMessage = ThinkingBlock = TextBlock = ToolUseBlock = object # type: ignore +else: + AssistantMessage = object @typechecked @@ -35,6 +37,8 @@ async def handle_assistant_message( live_partial: Dict[str, PartialReply], sessions: Dict[str, AgentSession], ) -> None: + from claude_agent_sdk.types import ThinkingBlock, TextBlock, ToolUseBlock + content_parts = [] new_thinking_parts = [] tool_uses = [] diff --git a/backend/apps/agents/manager/streaming/handle_result_message.py b/backend/apps/agents/manager/streaming/handle_result_message.py index fae05a87..7fc3a4cb 100644 --- a/backend/apps/agents/manager/streaming/handle_result_message.py +++ b/backend/apps/agents/manager/streaming/handle_result_message.py @@ -15,10 +15,13 @@ from backend.apps.agents.core.ws_manager import ws_manager from backend.apps.agents.manager.streaming.state import ThinkingState, TurnState from backend.apps.agents.manager.streaming import thinking as thinking_mod -try: +# Annotation-only here (no isinstance dispatch), so the runtime symbol can stay `object` and the SDK chain stays off the boot import graph. +from typing import TYPE_CHECKING + +if TYPE_CHECKING: from claude_agent_sdk import ResultMessage -except ImportError: # the SDK is optional at runtime (mock mode); keep this module importable - ResultMessage = object # type: ignore +else: + ResultMessage = object logger = logging.getLogger(__name__) diff --git a/backend/apps/agents/manager/streaming/handle_stream_event.py b/backend/apps/agents/manager/streaming/handle_stream_event.py index 17d5a626..33d91157 100644 --- a/backend/apps/agents/manager/streaming/handle_stream_event.py +++ b/backend/apps/agents/manager/streaming/handle_stream_event.py @@ -15,10 +15,14 @@ from backend.apps.agents.core.ws_manager import ws_manager from backend.apps.agents.manager.streaming.state import ThinkingState, TurnState from backend.apps.agents.manager.streaming.PartialReply import PartialReply -try: +# Runtime annotation stays `object` (the old ImportError fallback already admitted that); the real +# type lives behind TYPE_CHECKING so importing this module stops paying the 350ms claude_agent_sdk+mcp chain at boot. +from typing import TYPE_CHECKING + +if TYPE_CHECKING: from claude_agent_sdk.types import StreamEvent -except ImportError: # the SDK is optional at runtime (mock mode); keep this module importable - StreamEvent = object # type: ignore +else: + StreamEvent = object @typechecked