[eric] boot: anthropic and the sdk+mcp chain leave the import graph, api-ready median 2531 to 1843ms, hooks re-import at call time

This commit is contained in:
ciregenz
2026-08-06 16:33:22 -07:00
parent 1a04797c09
commit 1b2203fa75
5 changed files with 44 additions and 16 deletions
+14 -5
View File
@@ -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
@@ -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(
@@ -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 = []
@@ -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__)
@@ -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