"""Assorted AIAgent runtime helpers, moved out of run_agent.py. Each function takes the parent ``AIAgent`` as ``agent`` except the stateless helpers (``sanitize_tool_call_arguments``, ``drop_thinking_only_and_merge_users``). """ from __future__ import annotations import copy import json import logging import re import threading import time from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from hermes_cli.timeouts import get_provider_request_timeout from agent.message_sanitization import ( _FULL_ARGS_LOG_BOUND, coalesce_tool_call_id, tool_call_id_variants, tool_result_id_variants, ) from agent.prompt_builder import format_steer_marker from agent.tool_dispatch_helpers import _trajectory_normalize_msg, make_tool_result_message from agent.trajectory import convert_scratchpad_to_think from agent.credential_pool import ( STATUS_EXHAUSTED, credential_pool_matches_provider, resolve_runtime_pool_key, ) from agent.error_classifier import FailoverReason from agent.turn_context import drop_stale_api_content from utils import base_url_host_matches, base_url_hostname, env_var_enabled, atomic_json_write logger = logging.getLogger(__name__) # Cap consecutive same-entry OAuth token refreshes on a persistent auth failure; # without it a single-entry pool re-mints forever and never reaches fallback (#26080). _MAX_AUTH_REFRESH_ATTEMPTS = 2 _REASONING_TAG_NAMES = ("think", "thinking", "reasoning", "REASONING_SCRATCHPAD", "thought") _TOOL_CALL_TAG_NAMES = ("tool_call", "tool_calls", "tool_result", "function_call", "function_calls") _REASONING_BLOCK_PATTERNS = tuple( re.compile(rf"<{name}>.*?", re.DOTALL | re.IGNORECASE) for name in _REASONING_TAG_NAMES ) _TOOL_CALL_BLOCK_PATTERNS = tuple( re.compile(rf"<{name}\b[^>]*>.*?", re.DOTALL | re.IGNORECASE) for name in _TOOL_CALL_TAG_NAMES ) # Named blocks; see strip_think_blocks step 1c for the # boundary/tempered-dot rationale. _NAMED_FUNCTION_BLOCK_PATTERN = re.compile( r'(?:(?<=^)|(?<=[\n\r.!?:]))[ \t]*' r']*\bname\s*=[^>]*>' r'(?:(?:(?!).)*)', re.DOTALL | re.IGNORECASE, ) _UNTERMINATED_REASONING_BLOCK_PATTERN = re.compile( rf'(?:^|\n)[ \t]*<(?:{"|".join(_REASONING_TAG_NAMES)})\b[^>]*>.*$', re.DOTALL | re.IGNORECASE, ) _ORPHAN_REASONING_TAG_PATTERN = re.compile( rf'\s*', re.IGNORECASE, ) _STRAY_TOOL_CALL_CLOSER_PATTERN = re.compile( rf'\s*', re.IGNORECASE, ) def _ra(): """Lazy ``run_agent`` reference for test-patch routing.""" import run_agent return run_agent AGENT_RUNTIME_POST_HOOK_TOOL_NAMES = frozenset( {"todo_list", "session_search", "memory", "clarify", "read_terminal", "desktop_preview", "drive_preview", "annotate_preview", "read_window_below", "setup_mcp", "gui_tour", "delegate_task"} ) def convert_to_trajectory_format(agent, messages: List[Dict[str, Any]], user_query: str, completed: bool) -> List[Dict[str, Any]]: """Convert internal message history to trajectory format for saving.""" # Trajectories are text-only: swap image-bearing tool messages for their # text_summary so ~1MB base64 blobs are not embedded. messages = [_trajectory_normalize_msg(m) for m in messages] trajectory = [] system_msg = ( "You are a function calling AI model. You are provided with function signatures within XML tags. " "You may call one or more functions to assist with the user query. If available tools are not relevant in assisting " "with user query, just respond in natural conversational language. Don't make assumptions about what values to plug " "into functions. After calling & executing the functions, you will be provided with function results within " " XML tags. Here are the available tools:\n" f"\n{agent._format_tools_for_system_message()}\n\n" "For each function call return a JSON object, with the following pydantic model json schema for each:\n" "{'title': 'FunctionCall', 'type': 'object', 'properties': {'name': {'title': 'Name', 'type': 'string'}, " "'arguments': {'title': 'Arguments', 'type': 'object'}}, 'required': ['name', 'arguments']}\n" "Each function call should be enclosed within XML tags.\n" "Example:\n\n{'name': ,'arguments': }\n" ) trajectory.append({ "from": "system", "value": system_msg }) trajectory.append({ "from": "human", "value": user_query }) # Skip messages[0] (already added). Prefill is injected at API-call time # only, so no offset adjustment is needed. i = 1 while i < len(messages): msg = messages[i] if msg["role"] == "assistant": if "tool_calls" in msg and msg["tool_calls"]: content = "" # Prepend reasoning in tags if available (native thinking tokens) if msg.get("reasoning") and msg["reasoning"].strip(): content = f"\n{msg['reasoning']}\n\n" if msg.get("content") and msg["content"].strip(): # -> (model reasons via XML when native thinking is off) content += convert_scratchpad_to_think(msg["content"]) + "\n" for tool_call in msg["tool_calls"]: if not tool_call or not isinstance(tool_call, dict): continue # Arguments were validated during conversation; try/except is a safety net try: arguments = json.loads(tool_call["function"]["arguments"]) if isinstance(tool_call["function"]["arguments"], str) else tool_call["function"]["arguments"] except json.JSONDecodeError: # Should not happen (validated during the conversation); degrade to {} rather than abort. logger.warning("Unexpected invalid JSON in trajectory conversion: %s", tool_call['function']['arguments'][:100]) arguments = {} tool_call_json = { "name": tool_call["function"]["name"], "arguments": arguments } content += f"\n{json.dumps(tool_call_json, ensure_ascii=False)}\n\n" # Every gpt turn gets a block (empty if none) for a consistent training format if "" not in content: content = "\n\n" + content trajectory.append({ "from": "gpt", "value": content.rstrip() }) tool_responses = [] j = i + 1 while j < len(messages) and messages[j]["role"] == "tool": tool_msg = messages[j] tool_response = "\n" # Pretty-print tool content if it looks like JSON tool_content = tool_msg["content"] try: if tool_content.strip().startswith(("{", "[")): tool_content = json.loads(tool_content) except (json.JSONDecodeError, AttributeError): pass # Keep as string if not valid JSON tool_index = len(tool_responses) tool_name = ( msg["tool_calls"][tool_index]["function"]["name"] if tool_index < len(msg["tool_calls"]) else "unknown" ) tool_response += json.dumps({ "tool_call_id": tool_msg.get("tool_call_id", ""), "name": tool_name, "content": tool_content }, ensure_ascii=False) tool_response += "\n" tool_responses.append(tool_response) j += 1 if tool_responses: trajectory.append({ "from": "tool", "value": "\n".join(tool_responses) }) i = j - 1 # Skip the tool messages we just processed else: content = "" # Prepend reasoning in tags if available (native thinking tokens) if msg.get("reasoning") and msg["reasoning"].strip(): content = f"\n{msg['reasoning']}\n\n" # -> (model reasons via XML when native thinking is off) raw_content = msg["content"] or "" content += convert_scratchpad_to_think(raw_content) # Every gpt turn gets a block (empty if none) for a consistent training format if "" not in content: content = "\n\n" + content trajectory.append({ "from": "gpt", "value": content.strip() }) elif msg["role"] == "user": trajectory.append({ "from": "human", "value": msg["content"] }) i += 1 return trajectory def sanitize_tool_call_arguments( messages: list, *, logger=None, session_id: str = None, cursor: Optional[dict] = None, ) -> int: """Repair corrupted assistant tool-call argument JSON in-place. ``cursor`` (optional caller-owned dict) stores under ``"prefix"`` strong references to the message objects validated last call; the longest ``is``-identical prefix is skipped on the next call. Skipping is safe because only the surrogate/non-ASCII sanitizers mutate arguments on live dicts (inside JSON string values), and every other path replaces or reorders dicts, breaking identity. Strong refs (not ``id()``) rule out address-reuse aliasing (#50372). """ log = logger or logging.getLogger(__name__) if not isinstance(messages, list): return 0 start_index = 0 if cursor is not None: prev_prefix = cursor.get("prefix") if isinstance(prev_prefix, list): limit = min(len(prev_prefix), len(messages)) while start_index < limit and messages[start_index] is prev_prefix[start_index]: start_index += 1 repaired = 0 marker = _ra().AIAgent._TOOL_CALL_ARGUMENTS_CORRUPTION_MARKER def _prepend_marker(tool_msg: dict) -> None: existing = tool_msg.get("content") if isinstance(existing, str): if not existing: tool_msg["content"] = marker elif not existing.startswith(marker): tool_msg["content"] = f"{marker}\n{existing}" return if existing is None: tool_msg["content"] = marker return try: existing_text = json.dumps(existing) except TypeError: existing_text = str(existing) tool_msg["content"] = f"{marker}\n{existing_text}" message_index = start_index while message_index < len(messages): msg = messages[message_index] if not isinstance(msg, dict) or msg.get("role") != "assistant": message_index += 1 continue tool_calls = msg.get("tool_calls") if not isinstance(tool_calls, list) or not tool_calls: message_index += 1 continue insert_at = message_index + 1 for tool_call in tool_calls: if not isinstance(tool_call, dict): continue function = tool_call.get("function") if not isinstance(function, dict): continue arguments = function.get("arguments") if arguments is None or arguments == "": function["arguments"] = "{}" continue if isinstance(arguments, str) and not arguments.strip(): function["arguments"] = "{}" continue if not isinstance(arguments, str): continue try: json.loads(arguments) except json.JSONDecodeError: # Use canonical ``call_id || id`` precedence so scan and stub share the id # the pipeline uses; bare ``id`` misses Codex call_id results and orphans a stub (#58168). tool_call_id = _ra().AIAgent._get_tool_call_id_static(tool_call) or None function_name = function.get("name", "?") # Log the FULL (bounded) argument string: we are about to overwrite the only # copy, which may hold real user content from a truncated write_file/patch (#80498). preview = arguments[:_FULL_ARGS_LOG_BOUND] log.warning( "Corrupted tool_call arguments repaired before request " "(session=%s, message_index=%s, tool_call_id=%s, function=%s, " "original_arguments=%r)", session_id or "-", message_index, tool_call_id or "-", function_name, preview, ) function["arguments"] = "{}" existing_tool_msg = None scan_index = message_index + 1 while scan_index < len(messages): candidate = messages[scan_index] if not isinstance(candidate, dict) or candidate.get("role") != "tool": break if ( tool_result_id_variants(candidate.get("tool_call_id")) & tool_call_id_variants(tool_call) ): existing_tool_msg = candidate break scan_index += 1 if existing_tool_msg is None: messages.insert( insert_at, make_tool_result_message( function_name if function_name != "?" else "", marker, tool_call_id, ), ) insert_at += 1 else: _prepend_marker(existing_tool_msg) repaired += 1 message_index += 1 if cursor is not None: # Strong refs to the objects validated this call; any divergence # (compression, undo, repair, steer) forces a re-scan from that index. cursor["prefix"] = messages[:] return repaired # Session-scoped in-flight registry for note_turn_start. The gateway caches agents # per routing key while the transcript is keyed by session_id (many-to-one), so two # agent objects can run concurrent turns on one session unseen by per-agent state (#64934). _INFLIGHT_TURNS_BY_SESSION: Dict[str, Tuple[str, float]] = {} _INFLIGHT_TURNS_LOCK = threading.Lock() def note_turn_start(agent, turn_id: str): """Tripwire: warn when a turn starts while a previous turn of the same agent or the same session (on another agent object) has not finished its persist. Does not prevent the overlap; it names it with both turn ids so the dispatch route that bypassed the busy guard can be found in logs. Returns the previous in-flight turn_id on overlap, else None; takes ownership of the slot either way. """ prev = getattr(agent, "_inflight_turn_id", None) prev_started = getattr(agent, "_inflight_turn_started", 0.0) agent._inflight_turn_id = turn_id agent._inflight_turn_started = time.time() overlap = None if prev and prev != turn_id: logger.warning( "turn %s starting while turn %s (started %.0fs ago) has not " "completed its turn-end persist (session=%s) — concurrent turns " "on one session; transcript writes may interleave", turn_id, prev, time.time() - prev_started if prev_started else -1.0, getattr(agent, "session_id", None) or "-", ) overlap = prev # Cross-agent leg: same session_id in flight under another agent object # (busy guard is keyed by routing key and cannot see it). Persist-disabled # forks share the parent's session_id but never write, so they must not # register or pop here (note_turn_persisted skips them symmetrically). session_id = getattr(agent, "session_id", None) if session_id and not getattr(agent, "_persist_disabled", False): now = time.time() with _INFLIGHT_TURNS_LOCK: entry = _INFLIGHT_TURNS_BY_SESSION.get(session_id) _INFLIGHT_TURNS_BY_SESSION[session_id] = (turn_id, now) # Record the session id registered under: compression can rotate # agent.session_id mid-turn and persist must pop the slot actually held. agent._inflight_turn_session_id = session_id if entry and entry[0] not in (turn_id, prev): logger.warning( "turn %s starting while turn %s (started %.0fs ago) is still " "in flight on session %s under a different agent object — " "two routing keys are mapped to one session_id; concurrent " "turns on one session; transcript writes may interleave", turn_id, entry[0], now - entry[1] if entry[1] else -1.0, session_id, ) overlap = overlap or entry[0] return overlap def note_turn_persisted(agent): """Clear the in-flight marker at turn-end persist (see note_turn_start). Unconditional by design: on a real overlap the first persist clears the second slot and the tripwire under-reports rather than double-reports. """ agent._inflight_turn_id = None # Persist-disabled forks never registered a slot; popping here would # steal the live parent turn's slot (symmetric with note_turn_start). if not getattr(agent, "_persist_disabled", False): session_id = getattr(agent, "_inflight_turn_session_id", None) or getattr( agent, "session_id", None ) if session_id: with _INFLIGHT_TURNS_LOCK: _INFLIGHT_TURNS_BY_SESSION.pop(session_id, None) agent._inflight_turn_session_id = None def repair_message_sequence(agent, messages: List[Dict]) -> int: """Collapse malformed role-alternation left in the live history. Providers require strict alternation after the system message; violations cause silent empty responses or HTTP 400s. Runs right before the API call as a defensive belt for host-fed, resumed, or replayed histories. Passes: 0. merge consecutive assistant turns (union tool_calls, concat content; codex interim turns exempt; #29148, #49147); 1. drop stray ``tool`` results with no preceding matching tool_call; 2. prune tool_calls not answered in the immediately following tool run, dropping the turn if left empty (codex interims exempt); 3. merge consecutive user messages. A user turn directly after an assistant turn is valid and left alone. Returns the number of repairs made. """ if not messages: return 0 repairs = 0 # Pass 0: merge consecutive assistant messages, BEFORE Pass 1 so the merged # tool_call-id union is known. Codex Responses interim turns are exempt: # each carries its own continuation state that must be replayed verbatim. def _is_codex_interim(m: Dict) -> bool: return bool( m.get("codex_reasoning_items") or m.get("codex_message_items") or m.get("finish_reason") == "incomplete" ) def _is_verification_candidate(m: Dict) -> bool: return m.get("finish_reason") in { "verification_required", "verify_hook_continue", } collapsed: List[Dict] = [] for msg in messages: if ( collapsed and isinstance(msg, dict) and msg.get("role") == "assistant" and isinstance(collapsed[-1], dict) and collapsed[-1].get("role") == "assistant" and not _is_codex_interim(msg) and not _is_codex_interim(collapsed[-1]) ): prev = collapsed[-1] # A provisional verification candidate (finish_reason = # verification_required / verify_hook_continue) is superseded, not unioned (#65919 §7). if _is_verification_candidate(prev): collapsed[-1] = msg repairs += 1 continue # Union tool_calls (preserve order, both may carry them). prev_calls = list(prev.get("tool_calls") or []) new_calls = list(msg.get("tool_calls") or []) if new_calls: prev["tool_calls"] = prev_calls + new_calls elif prev_calls: prev["tool_calls"] = prev_calls else: # Drop a stale ``tool_calls: []`` at the source: strict providers # (DeepSeek v4, Kimi) 400 on it and it persists into replayed history (#58755, #77921). prev.pop("tool_calls", None) # Concatenate plain-text content only; leave multimodal (list) content alone. prev_content = prev.get("content") new_content = msg.get("content") content_rewritten = False if isinstance(prev_content, str) and isinstance(new_content, str): joined = "\n".join( p for p in (prev_content.strip(), new_content.strip()) if p ) prev["content"] = joined # A falsy new_content leaves ``joined`` == prev_content; that is not a rewrite (#78063). content_rewritten = joined != prev_content elif not prev_content and new_content is not None: prev["content"] = new_content content_rewritten = new_content != prev_content # Carry reasoning_content from the later turn only if the earlier lacks it # (strict thinking providers need one on the merged tool-call turn). if not prev.get("reasoning_content") and msg.get("reasoning_content"): prev["reasoning_content"] = msg["reasoning_content"] # A stale ``api_content`` sidecar overrides ``content`` at API-build time and # would replay pre-merge bytes; drop it only when content actually changed (#78063). if content_rewritten: drop_stale_api_content(prev) repairs += 1 continue collapsed.append(msg) # Pass 1: drop stray tool messages not following a known assistant tool call. # Consume the whole alias group (call_id/id/response_item_id/composite) so a # duplicate keyed on a sibling alias is not replayed to strict providers (#66974, #91768). known_tool_ids: Dict[str, int] = {} matched_tool_groups: set = set() next_tool_group = 0 filtered: List[Dict] = [] for msg in collapsed: if not isinstance(msg, dict): filtered.append(msg) continue role = msg.get("role") if role == "assistant": known_tool_ids = {} matched_tool_groups = set() for tc in (msg.get("tool_calls") or []): variants = tool_call_id_variants(tc) if not variants: continue group_id = next_tool_group next_tool_group += 1 for tc_id in variants: known_tool_ids.setdefault(tc_id, group_id) filtered.append(msg) elif role == "tool": result_variants = tool_result_id_variants(msg.get("tool_call_id")) candidate_groups = { known_tool_ids[tc_id] for tc_id in result_variants if tc_id in known_tool_ids and known_tool_ids[tc_id] not in matched_tool_groups } if not result_variants: filtered.append(msg) elif candidate_groups: # Consume the whole alias group so a second result on any sibling # spelling is dropped; strict providers 400 on duplicates (#58327, #66974, #55436). group_id = min(candidate_groups) filtered.append(msg) matched_tool_groups.add(group_id) else: repairs += 1 else: if role == "user": # A user turn closes the tool-result run; later tool messages are orphans. known_tool_ids = {} matched_tool_groups = set() filtered.append(msg) # Pass 2: prune tool_calls never answered in the IMMEDIATELY following tool run # (any id variant, same alias policy as Pass 1); a displaced result masks the per-call # stub pass and strict providers (DeepSeek v4) 400. Drop payload-empty turns; codex interims exempt. pruned: List[Dict] = [] i = 0 n = len(filtered) while i < n: msg = filtered[i] if not ( isinstance(msg, dict) and msg.get("role") == "assistant" and msg.get("tool_calls") and not _is_codex_interim(msg) ): pruned.append(msg) i += 1 continue answered: set = set() j = i + 1 while ( j < n and isinstance(filtered[j], dict) and filtered[j].get("role") == "tool" ): tid = (filtered[j].get("tool_call_id") or "").strip() if tid: answered.update(tool_result_id_variants(tid)) j += 1 kept_calls: List[Dict] = [] dropped_calls = 0 for tc in msg.get("tool_calls") or []: variants = tool_call_id_variants(tc) if variants and (variants & answered): kept_calls.append(tc) else: dropped_calls += 1 if dropped_calls: repairs += 1 if not kept_calls and not _msg_has_payload( {k: v for k, v in msg.items() if k != "tool_calls"} ): # Pruned calls were the only payload; drop the turn (empty assistant messages 400). i += 1 continue if kept_calls: msg["tool_calls"] = kept_calls else: msg.pop("tool_calls", None) pruned.append(msg) i += 1 # Pass 3: merge consecutive user messages (no user input lost). merged: List[Dict] = [] for msg in pruned: if ( merged and isinstance(msg, dict) and msg.get("role") == "user" and isinstance(merged[-1], dict) and merged[-1].get("role") == "user" ): prev = merged[-1] # A summary carrier followed by a new user row is a deliberate durable shape # after retry/rewind; never mutate the persisted carrier (sanitizers merge copies later). from agent.context_compressor import split_user_originated_turn handoff, _ = split_user_originated_turn(prev) if handoff is not None: merged.append(msg) continue prev_content = prev.get("content", "") new_content = msg.get("content", "") # Only merge plain-text content; leave multimodal (list) content alone. if isinstance(prev_content, str) and isinstance(new_content, str): prev["content"] = ( (prev_content + "\n\n" + new_content) if prev_content and new_content else (prev_content or new_content) ) # Merged content invalidates the api_content sidecar; drop it so replay cannot use stale bytes. drop_stale_api_content(prev) repairs += 1 continue merged.append(msg) if repairs > 0: # Rewrite in place so persistence/return value/DB flush see the repaired sequence. messages[:] = merged return repairs def repair_message_sequence_with_cursor(agent, messages: List[Dict]) -> int: """Run :func:`repair_message_sequence` and keep ``_last_flushed_db_idx`` consistent (#44837). Repair shrinks the list in place; counting survivors from the flushed prefix (identity-preserved) gives the exact new cursor, whereas a ``min()`` clamp would skip unflushed rows. Falls back to the clamp without a snapshot. """ pre_repair_flushed_ids = None flush_cursor = getattr(agent, "_last_flushed_db_idx", None) if isinstance(flush_cursor, int) and flush_cursor > 0: pre_repair_flushed_ids = {id(m) for m in messages[:flush_cursor]} repairs = repair_message_sequence(agent, messages) if repairs > 0 and hasattr(agent, "_last_flushed_db_idx"): if pre_repair_flushed_ids is not None: agent._last_flushed_db_idx = sum( 1 for m in messages if id(m) in pre_repair_flushed_ids ) else: agent._last_flushed_db_idx = min( agent._last_flushed_db_idx, len(messages) ) return repairs def strip_think_blocks(agent, content: str) -> str: """Remove reasoning/thinking blocks from content, returning only visible text. Strips closed tag pairs, unterminated open tags at a block boundary (mirrors ``gateway/stream_consumer.py``), stray orphan tags, and all case-insensitive variants (think/thinking/reasoning/REASONING_SCRATCHPAD/thought). Also strips standalone tool-call XML blocks some open models emit in content (ported from openclaw/openclaw#67318); the ```` variant is boundary- and ``name=``-gated so prose mentions survive. """ if not content: return "" # Flatten list/dict content (e.g. Anthropic-via-OpenRouter block lists from # stored history) before regex: a raw list hits re.sub, raises TypeError, # and the conversation loop retries forever. if not isinstance(content, str): if isinstance(content, list): _parts: list[str] = [] for _part in content: if isinstance(_part, str): _parts.append(_part) elif isinstance(_part, dict): _ptype = str(_part.get("type") or "").strip().lower() # Drop thinking/reasoning blocks outright; their text key varies per provider. if _ptype in {"thinking", "reasoning", "redacted_thinking"}: continue _text = _part.get("text") if isinstance(_text, str) and _text: _parts.append(_text) content = "".join(_parts) elif isinstance(content, dict): content = str(content.get("text") or content.get("content") or "") else: content = str(content) if not content: return "" # 1. Closed tag pairs, case-insensitive so mixed-case tags do not fall # through to the unterminated pass and eat trailing content. for _pattern in _REASONING_BLOCK_PATTERNS: content = _pattern.sub('', content) # 1b. Tool-call XML blocks (openclaw/openclaw#67318); generic tags need no attribute gating. for _pattern in _TOOL_CALL_BLOCK_PATTERNS: content = _pattern.sub('', content) # 1c. Gemma-style block: strip only at a block boundary # AND with a name attribute so prose mentions of survive. content = _NAMED_FUNCTION_BLOCK_PATTERN.sub('', content) # 2. Unterminated reasoning block at a block boundary: strip to end of # string (#8878, #9568: MiniMax M2.7 leaking raw reasoning). content = _UNTERMINATED_REASONING_BLOCK_PATTERN.sub('', content) # 3. Stray orphan open/close tags that slipped through. content = _ORPHAN_REASONING_TAG_PATTERN.sub('', content) # 3b. Stray tool-call closers only; bare/unterminated is kept since a # truncated streaming tail may still be valuable (matches OpenClaw asymmetry). content = _STRAY_TOOL_CALL_CLOSER_PATTERN.sub('', content) return content def sync_credential_pool_entry_id(agent) -> None: """Rebind ``agent._credential_pool_entry_id`` from the current pool + key. OAuth refreshes can replace the token before recovery runs, so the key alone cannot attribute a failure; the stable entry ID can. Cleared when no pool is bound. """ pool = getattr(agent, "_credential_pool", None) try: agent._credential_pool_entry_id = ( pool.entry_id_for_api_key(getattr(agent, "api_key", None)) if pool is not None else None ) except Exception: agent._credential_pool_entry_id = None def recover_with_credential_pool( agent, *, status_code: Optional[int], has_retried_429: bool, classified_reason: Optional[FailoverReason] = None, error_context: Optional[Dict[str, Any]] = None, billing_unverified: bool = False, ) -> tuple[bool, bool]: """Attempt credential recovery via pool rotation. Returns (recovered, has_retried_429). Rate limits: retry once, then rotate. Billing exhaustion: rotate immediately. Auth failures: refresh before rotating. ``classified_reason`` honors the structured classifier over raw HTTP codes (e.g. Anthropic 400 for "out of extra usage"). ``billing_unverified`` (#82154) persists an ambiguous billing verdict so the entry gets a short cooldown, not the one-hour bench. """ pool = agent._credential_pool if pool is None: return False, has_retried_429 # The pool belongs to the PRIMARY provider: acting on fallback errors would # corrupt its state (#33088) and reset base_url to the primary endpoint (#33163). current_provider = (getattr(agent, "provider", "") or "").strip().lower() pool_provider = (getattr(pool, "provider", "") or "").strip().lower() # Skip recovery when the pool is scoped to another provider. Empty pool provider # means unscoped; empty agent provider is a mismatch (swap would leave provider="" model=""). if pool_provider: # Same fail-closed boundary predicate as runtime binding (named-custom # aliases, endpoint validation, fallback isolation). if not credential_pool_matches_provider( pool, current_provider, base_url=getattr(agent, "base_url", None), ): _ra().logger.warning( "Credential pool provider mismatch: pool=%s, agent=%s — " "skipping pool mutation to avoid cross-provider contamination", pool_provider, current_provider, ) return False, has_retried_429 # Attribute the failure to the key actually dispatched, not pool.current(): # the shared pointer often points at a different healthy entry, and marking # it exhausted can take the whole pool offline from one 429 (#43747). _api_key_hint = getattr(agent, "api_key", None) or None _raw_credential_id = getattr(agent, "_credential_pool_entry_id", None) _credential_id = ( _raw_credential_id if isinstance(_raw_credential_id, str) and _raw_credential_id else None ) if not _api_key_hint: _cur = pool.current() if _cur: _api_key_hint = getattr(_cur, "runtime_api_key", None) if not _credential_id: _current_id = getattr(_cur, "id", None) if isinstance(_current_id, str) and _current_id: _credential_id = _current_id def _rotate_failed_credential(rotate_status: int): kwargs = { "status_code": rotate_status, "error_context": error_context, "api_key_hint": _api_key_hint, } if _credential_id: kwargs["credential_id"] = _credential_id # Pass classified semantics, not just the status: a billing 403 and an # edge-throttle 403 need opposite cooldowns. ``effective_reason`` is resolved below. if effective_reason is not None: _failure_reason = effective_reason.value if effective_reason == FailoverReason.billing and billing_unverified: # Ambiguous billing body (#82154): size the cooldown as transient, not a 1-hour bench. from agent.credential_pool import FAILURE_REASON_BILLING_UNVERIFIED _failure_reason = FAILURE_REASON_BILLING_UNVERIFIED kwargs["failure_reason"] = _failure_reason return pool.mark_exhausted_and_rotate(**kwargs) def _rotate_and_swap(default_status: int, label: str) -> bool: """Rotate away from the failed credential; True when a new entry was swapped in.""" rotate_status = status_code if status_code is not None else default_status next_entry = _rotate_failed_credential(rotate_status) if next_entry is None: return False _ra().logger.info( "Credential %s (%s) — rotated to pool entry %s", rotate_status, label, getattr(next_entry, "id", "?"), ) agent._swap_credential(next_entry) return True effective_reason = classified_reason if effective_reason is None: if status_code == 402: effective_reason = FailoverReason.billing elif status_code == 429: effective_reason = FailoverReason.rate_limit elif status_code in {401, 403}: effective_reason = FailoverReason.auth if effective_reason == FailoverReason.upstream_rate_limit: # Upstream (e.g. DeepSeek behind OpenRouter) is throttling the aggregator; the # credential is healthy. Do not rotate/exhaust; let fallback switch models. upstream = (error_context or {}).get("upstream_provider") if error_context else None if upstream: _ra().logger.info( "Upstream provider %s rate-limited via aggregator — skipping " "credential rotation, deferring to fallback chain", upstream, ) else: _ra().logger.info( "Upstream aggregator 429 (provider unknown) — skipping " "credential rotation, deferring to fallback chain" ) return False, has_retried_429 if effective_reason == FailoverReason.billing: # A separate pool instance may have resolved runtime credentials, leaving # no ``current_id``; match the key that failed, not a different account. if _rotate_and_swap(402, "billing"): return True, False return False, has_retried_429 if effective_reason == FailoverReason.rate_limit: # Already-exhausted credential: rotate immediately. Avoids the "cancel-between-429s" # trap where the local has_retried_429 resets per prompt and retries forever. current_entry = None if _credential_id: current_entry = next( (e for e in pool.entries() if e.id == _credential_id), None, ) if _api_key_hint: current_entry = current_entry or next( (e for e in pool.entries() if e.runtime_api_key == _api_key_hint), None, ) if current_entry is None: current_entry = pool.current() current_last_status = getattr(current_entry, "last_status", None) if current_entry else None if current_last_status == STATUS_EXHAUSTED: _ra().logger.info( "Credential already exhausted (last_status=%s) — rotating immediately instead of retrying", current_last_status, ) if _rotate_and_swap(429, "rate limit, pre-exhausted"): return True, False return False, True usage_limit_reached = False if error_context: context_reason = str(error_context.get("reason") or "").lower() context_message = str(error_context.get("message") or "").lower() usage_limit_reached = ( "usage_limit_reached" in context_reason or "gousagelimit" in context_reason or "usage limit reached" in context_message or "usage limit has been reached" in context_message ) if not has_retried_429 and not usage_limit_reached: return False, True if _rotate_and_swap(429, "rate limit"): return True, False return False, True if effective_reason == FailoverReason.auth: # Entitlement 403s look like auth failures but refresh cannot fix them; any # xai-oauth 403 is treated as entitlement (#26847) EXCEPT xAI's stale-token # signals (``[WKE=unauthenticated:...]``, "could not be validated"), which must # stay refreshable (#29344). is_entitlement = agent._is_entitlement_failure(error_context, status_code) _auth_haystack = " ".join( str(error_context.get(k) or "").lower() for k in ("message", "reason", "code", "error") if isinstance(error_context, dict) ) if ( not is_entitlement and status_code == 403 and "oauth authentication is currently not allowed for this organization" in _auth_haystack ): is_entitlement = True if ( not is_entitlement and status_code == 403 and (agent.provider or "") == "anthropic" and getattr(agent, "api_mode", "") == "anthropic_messages" ): is_entitlement = True if not is_entitlement and status_code == 403 and (agent.provider or "") == "xai-oauth": _is_xai_auth_failure = ( "[wke=unauthenticated:" in _auth_haystack or "oauth2 access token could not be validated" in _auth_haystack ) if not _is_xai_auth_failure: is_entitlement = True if is_entitlement: _ra().logger.info( "Credential %s — entitlement-shaped 403 from %s; " "skipping pool refresh (account lacks subscription, " "not a transient auth failure).", status_code if status_code is not None else "auth", agent.provider or "provider", ) return False, has_retried_429 # Refresh the entry that supplied the failing key, not current(): refreshing a # healthy entry burns its single-use refresh token for a failure it never had. refresh_kwargs = {"api_key_hint": _api_key_hint} if _credential_id: refresh_kwargs["credential_id"] = _credential_id refreshed = pool.try_refresh_matching(**refresh_kwargs) if refreshed is not None: # try_refresh_matching() reports success even when upstream keeps rejecting; # cap same-entry refreshes so a single-entry pool falls through to fallback (#26080). refreshed_id = getattr(refreshed, "id", None) if refreshed_id is not None: refresh_counts = getattr(agent, "_auth_pool_refresh_counts", None) if refresh_counts is None: refresh_counts = {} agent._auth_pool_refresh_counts = refresh_counts refresh_key = (agent.provider, refreshed_id) refresh_counts[refresh_key] = refresh_counts.get(refresh_key, 0) + 1 if refresh_counts[refresh_key] > _MAX_AUTH_REFRESH_ATTEMPTS: _ra().logger.warning( "Credential auth failure persists after %s refreshes for " "pool entry %s — treating as unrecoverable and allowing " "fallback to activate.", refresh_counts[refresh_key] - 1, refreshed_id, ) return False, has_retried_429 _ra().logger.info("Credential auth failure — refreshed pool entry %s", getattr(refreshed, 'id', '?')) agent._swap_credential(refreshed) return True, has_retried_429 # Refresh failed; rotate (the failed entry is already marked exhausted). if _rotate_and_swap(401, "auth refresh failed"): return True, False return False, has_retried_429 def _apply_primary_runtime_fields(agent, rt: Dict[str, Any]) -> None: """Copy the identity/transport fields of a ``_primary_runtime`` snapshot onto ``agent``. Shared by transport recovery and turn-start restore; the caller rebuilds the client. """ agent.model = rt["model"] agent.provider = rt["provider"] agent.requested_provider = rt.get("requested_provider", agent.provider) agent.base_url = rt["base_url"] # setter updates _base_url_lower agent.api_mode = rt["api_mode"] if hasattr(agent, "_transport_cache"): agent._transport_cache.clear() agent.api_key = rt["api_key"] agent._reasoning_echo_flag = rt.get("reasoning_echo_flag", False) agent.request_overrides = dict(rt.get("request_overrides") or {}) agent._client_kwargs = dict(rt["client_kwargs"]) def _build_anthropic_client_from_runtime(agent, rt: Dict[str, Any]) -> None: """Rebuild the native Anthropic client from a ``_primary_runtime`` snapshot.""" from agent.anthropic_adapter import build_anthropic_client agent._anthropic_api_key = rt["anthropic_api_key"] agent._anthropic_base_url = rt["anthropic_base_url"] agent._anthropic_client = build_anthropic_client( rt["anthropic_api_key"], rt["anthropic_base_url"], timeout=get_provider_request_timeout(agent.provider, agent.model), ) agent._is_anthropic_oauth = rt["is_anthropic_oauth"] agent.client = None def try_recover_primary_transport( agent, api_error: Exception, *, retry_count: int, max_retries: int, ) -> bool: """Rebuild the primary client once and retry after ``max_retries`` exhaust on a transient transport error. Skipped for aggregator providers (OpenRouter, Nous) that already manage pools/retries server-side. """ if agent._fallback_activated: return False error_type = type(api_error).__name__ if error_type not in _TRANSIENT_TRANSPORT_ERRORS: return False # Skip for aggregator providers — they manage their own retry infra if agent._is_openrouter_url(): return False provider_lower = (agent.provider or "").strip().lower() # Portal OpenAI-wire traffic rides aggregator retry infra (skip), but Portal # Claude on native Messages holds a local Anthropic client that needs the rebuild. if ( provider_lower in {"nous", "nous-portal", "nousresearch"} and getattr(agent, "api_mode", None) != "anthropic_messages" ): return False try: # Never hard-close the shared client here (#70773): stale streaming workers may # still be unwinding on the old pool; _retire_shared_openai_client defers FD release to GC. if getattr(agent, "client", None) is not None: try: agent._retire_shared_openai_client( agent.client, reason="primary_recovery", ) except Exception: pass rt = agent._primary_runtime _apply_primary_runtime_fields(agent, rt) if agent.api_mode == "anthropic_messages": _build_anthropic_client_from_runtime(agent, rt) elif (agent.provider or "").strip().lower() == "moa": # MoA has empty client_kwargs; rebuild via the shared facade factory so the # reference_callback relay survives recovery (#53802). from agent.moa_loop import build_moa_facade agent.client = build_moa_facade(agent, agent.model) else: agent.client = agent._create_openai_client( dict(rt["client_kwargs"]), reason="primary_recovery", shared=True, ) wait_time = min(3 + retry_count, 8) agent._vprint( f"{agent.log_prefix}🔁 Transient {error_type} on {agent.provider} — " f"rebuilt client, waiting {wait_time}s before one last primary attempt.", force=True, ) time.sleep(wait_time) return True except Exception as e: logger.warning("Primary transport recovery failed: %s", e) return False # ── End provider fallback ────────────────────────────────────────────── def drop_thinking_only_and_merge_users( messages: List[Dict[str, Any]], *, drop_codex_reasoning_items: bool = True, ) -> List[Dict[str, Any]]: """Drop thinking-only assistant turns and merge adjacent user messages left behind. Operates on the per-call ``api_messages`` copy only; ``agent.messages`` is never mutated. Drop-and-merge (not stub text) keeps history honest and preserves role alternation (mirrors Claude Code's ``normalizeMessagesForAPI``). """ if not messages: return messages # Pass 1: drop thinking-only assistant turns. kept = [ m for m in messages if not _ra().AIAgent._is_thinking_only_assistant( m, drop_codex_reasoning_items=drop_codex_reasoning_items, ) ] dropped = len(messages) - len(kept) # Pass 2: merge any newly-adjacent user messages. merged: List[Dict[str, Any]] = [] merges = 0 for m in kept: prev = merged[-1] if merged else None if ( prev is not None and prev.get("role") == "user" and m.get("role") == "user" ): prev_content = prev.get("content", "") cur_content = m.get("content", "") # Copy ``prev`` so caller dicts are never mutated (safe from tests/other loops). prev_copy = dict(prev) # Only string+string content merges; list (multimodal) sides append as separate blocks. if isinstance(prev_content, str) and isinstance(cur_content, str): sep = "\n\n" if prev_content and cur_content else "" prev_copy["content"] = prev_content + sep + cur_content elif isinstance(prev_content, list) and isinstance(cur_content, list): prev_copy["content"] = list(prev_content) + list(cur_content) elif isinstance(prev_content, list) and isinstance(cur_content, str): if cur_content: prev_copy["content"] = list(prev_content) + [ {"type": "text", "text": cur_content} ] else: prev_copy["content"] = list(prev_content) elif isinstance(prev_content, str) and isinstance(cur_content, list): new_blocks: List[Dict[str, Any]] = [] if prev_content: new_blocks.append({"type": "text", "text": prev_content}) new_blocks.extend(cur_content) prev_copy["content"] = new_blocks else: # Unknown content shape — fall back to appending separately # (violates alternation, but safer than raising in a hot path). merged.append(m) continue merged[-1] = prev_copy merges += 1 else: merged.append(m) if dropped == 0 and merges == 0: return messages _ra().logger.debug( "Pre-call sanitizer: dropped %d thinking-only assistant turn(s), " "merged %d adjacent user message(s)", dropped, merges, ) return merged def restore_primary_runtime(agent) -> bool: """Restore the primary runtime at the start of a new turn so fallback stays turn-scoped. Needed for long-lived CLI agents and the gateway's cached agents (``_agent_cache``). """ if not agent._fallback_activated: # Reset the index even without activation: a failed _try_activate_fallback() can strand # _fallback_index past the chain end and silently block future fallbacks (#20465). agent._fallback_index = 0 return False if getattr(agent, "_rate_limited_until", 0) > time.monotonic(): return False # primary still in rate-limit cooldown, stay on fallback # Reset-aware gate: when the credential pool reports a reset time still in the future # (subscription windows), skip the guaranteed-to-fail restore (saves two cache invalidations # per turn). Fails open on any error/None. The loaded primary pool is handed to the # rebind block below via ``prefetched_primary_pool`` so it loads at most once. rt = agent._primary_runtime primary_provider = str((rt or {}).get("provider") or "").strip().lower() primary_runtime_base_url = str((rt or {}).get("base_url") or "") def _matches_primary(candidate) -> bool: return credential_pool_matches_provider( candidate, primary_provider, base_url=primary_runtime_base_url ) def _load_primary_pool(): """Load the primary provider's pool; None when absent or provider-mismatched.""" from agent.credential_pool import load_pool key = resolve_runtime_pool_key(primary_provider, primary_runtime_base_url) loaded = load_pool(key) if key else None return loaded if loaded is not None and _matches_primary(loaded) else None prefetched_primary_pool = None primary_pool_prefetched = False try: pool = getattr(agent, "_credential_pool", None) if not _matches_primary(pool): prefetched_primary_pool = pool = _load_primary_pool() primary_pool_prefetched = True next_at = getattr(pool, "next_available_at", lambda: None)() if next_at is not None and next_at > time.time(): if not getattr(agent, "_restore_wait_logged", False): agent._restore_wait_logged = True logger.info( "Primary %s rate-limited until %s; staying on fallback " "%s/%s until the reset elapses", primary_provider or "?", datetime.fromtimestamp(next_at).isoformat(timespec="seconds"), agent.provider, agent.model, ) return False except Exception: logger.debug( "Reset-aware restore gate failed; falling back to per-turn retry", exc_info=True, ) agent._restore_wait_logged = False fallback_route = getattr(agent, "_provider_fallback_route", None) if ( isinstance(fallback_route, (list, tuple)) and len(fallback_route) == 2 ): previous_model = str(fallback_route[0] or "unknown") previous_provider = str(fallback_route[1] or "unknown") else: previous_model = str(getattr(agent, "model", "") or "unknown") previous_provider = str(getattr(agent, "provider", "") or "unknown") provider_fallback_active = bool( getattr(agent, "_provider_fallback_active", False) ) try: # ── Core runtime state ── _apply_primary_runtime_fields(agent, rt) if "runtime_capabilities" in rt: raw_capabilities = rt["runtime_capabilities"] if not isinstance(raw_capabilities, dict): logger.warning("Ignoring malformed runtime capabilities snapshot") else: agent.runtime_capabilities = dict(raw_capabilities) elif "capabilities" in rt: # Read snapshots written by the initial capability propagation patch. raw_capabilities = rt["capabilities"] if isinstance(raw_capabilities, dict): agent.runtime_capabilities = dict(raw_capabilities) agent._use_prompt_caching = rt["use_prompt_caching"] # Default to native layout for snapshots predating the native-vs-proxy split. agent._use_native_cache_layout = rt.get( "use_native_cache_layout", agent.api_mode == "anthropic_messages" and agent.provider == "anthropic", ) # An operator cache disable (_cache_disabled) must survive snapshot restoration (#33555). if getattr(agent, "_cache_disabled", False): agent._use_prompt_caching = False agent._use_native_cache_layout = False # ── Rebuild client for the primary provider ── if agent.provider == "moa": # MoA has no real OpenAI client kwargs; rebuild via the shared facade factory so the # reference_callback relay stays wired (#53802). from agent.moa_loop import build_moa_facade agent.client = build_moa_facade(agent, agent.model) agent._anthropic_client = None elif agent.api_mode == "anthropic_messages": _build_anthropic_client_from_runtime(agent, rt) else: agent.client = agent._create_openai_client( dict(rt["client_kwargs"]), reason="restore_primary", shared=True, ) # ── Restore context engine state ── cc = agent.context_compressor cc.update_model( model=rt["compressor_model"], context_length=rt["compressor_context_length"], base_url=rt["compressor_base_url"], api_key=rt["compressor_api_key"], provider=rt["compressor_provider"], api_mode=rt.get("compressor_api_mode", ""), ) # ── Rebind and re-select the primary credential pool ── # A cross-provider fallback attaches the fallback's pool; leaving it would trip the # provider-mismatch guard on the next 401/429. Reload the primary pool, else clear it. pool = getattr(agent, "_credential_pool", None) pool_provider = str(getattr(pool, "provider", "") or "").strip().lower() if pool is not None and pool_provider and not _matches_primary(pool): agent._credential_pool = None agent._credential_pool_entry_id = None try: # Reuse the pool the reset-aware gate already loaded (avoids a second auth.json read). agent._credential_pool = ( prefetched_primary_pool if primary_pool_prefetched else _load_primary_pool() ) except Exception as exc: logger.warning( "Restore could not reload primary credential pool for %s: %s", primary_provider, exc, ) # The snapshot api_key may be stale after pool rotation; re-select the pool's current # best entry, keeping the snapshot key when no usable entry exists (#25205). agent._credential_pool_entry_id = None pool = getattr(agent, "_credential_pool", None) if pool is not None and pool.has_available(): entry = pool.select() if entry is not None: entry_provider = str(getattr(entry, "provider", "") or "").strip().lower() entry_key = ( getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "") ) if entry_key and _matches_primary(entry): # _swap_credential rebuilds the client and reapplies base-url-scoped headers (#33163). agent._swap_credential(entry) logger.info( "Restore re-selected pool entry %s (%s)", getattr(entry, "id", "?"), getattr(entry, "label", "?"), ) elif entry_key: logger.info( "Restore skipped pool entry %s (%s): provider %s does not match primary provider %s", getattr(entry, "id", "?"), getattr(entry, "label", "?"), entry_provider or "?", primary_provider or "?", ) # ── Restore reasoning_config if saved (older snapshots keep the current value) ── saved_reasoning = rt.get("reasoning_config") if saved_reasoning is not None: agent.reasoning_config = dict(saved_reasoning) # ── Reset fallback chain for the new turn ── agent._fallback_activated = False agent._fallback_index = 0 agent._rate_limit_backoff_count = 0 # reset exponential backoff counter # Reset the stale-call circuit breaker (#58962): its streak measured the fallback provider. from agent.chat_completion_helpers import _reset_stale_streak _reset_stale_streak(agent) # Undo the fallback's identity rewrite so the prompt is # byte-identical to the stored copy again (prefix cache match). from agent.chat_completion_helpers import rewrite_prompt_model_identity rewrite_prompt_model_identity(agent, rt["model"], rt["provider"]) logger.info( "Primary runtime restored for new turn: %s (%s)", agent.model, agent.provider, ) agent._provider_fallback_active = False agent._provider_fallback_route = None if provider_fallback_active: try: agent._emit_status( f"✅ Primary model restored: {agent.model} via {agent.provider}; " f"fallback {previous_model} via {previous_provider} is no longer active." ) except Exception: # Notification surfaces are best-effort and must never undo a # successful runtime restoration. pass return True except Exception as e: logger.warning("Failed to restore primary runtime: %s", e) return False # Which error types indicate a transient transport failure worth # one more attempt with a rebuilt client / connection pool. _TRANSIENT_TRANSPORT_ERRORS = frozenset({ "ReadTimeout", "ConnectTimeout", "PoolTimeout", "ConnectError", "RemoteProtocolError", "APIConnectionError", "APITimeoutError", }) def extract_reasoning(agent, assistant_message) -> Optional[str]: """Extract reasoning text from an assistant message, or None. Checks ``reasoning``, ``reasoning_content``, ``reasoning_details`` (OpenRouter unified), then inline thinking blocks in list content. """ reasoning_parts = [] if hasattr(assistant_message, 'reasoning') and assistant_message.reasoning: reasoning_parts.append(assistant_message.reasoning) if hasattr(assistant_message, 'reasoning_content') and assistant_message.reasoning_content: if assistant_message.reasoning_content not in reasoning_parts: reasoning_parts.append(assistant_message.reasoning_content) # reasoning_details: [{"type": "reasoning.summary", "summary": "...", ...}, ...] if hasattr(assistant_message, 'reasoning_details') and assistant_message.reasoning_details: for detail in assistant_message.reasoning_details: if isinstance(detail, dict): summary = ( detail.get('summary') or detail.get('thinking') or detail.get('content') or detail.get('text') ) if summary and summary not in reasoning_parts: reasoning_parts.append(summary) # Fall back to reasoning embedded in content only when no structured field was found. content = getattr(assistant_message, "content", None) if not reasoning_parts and isinstance(content, list): # DeepSeek V4 Pro returns typed content blocks ({"type": "thinking", ...}); dropping # them makes the next turn fail with HTTP 400 "thinking must be passed back" (#21944). for block in content: if isinstance(block, dict) and block.get("type") == "thinking": thinking_text = block.get("thinking") or block.get("text") or "" thinking_text = thinking_text.strip() if thinking_text and thinking_text not in reasoning_parts: reasoning_parts.append(thinking_text) if not reasoning_parts and isinstance(content, str) and content: inline_patterns = ( r"(.*?)", r"(.*?)", r"(.*?)", r"(.*?)", r"(.*?)", ) for pattern in inline_patterns: flags = re.DOTALL | re.IGNORECASE for block in re.findall(pattern, content, flags=flags): cleaned = block.strip() if cleaned and cleaned not in reasoning_parts: reasoning_parts.append(cleaned) if reasoning_parts: return "\n\n".join(reasoning_parts) return None def dump_api_request_debug( agent, api_kwargs: Dict[str, Any], *, reason: str, error: Optional[Exception] = None, ) -> Optional[Path]: """Dump the request body from api_kwargs (minus transport keys) for debugging provider 4xx failures.""" try: body = copy.deepcopy(api_kwargs) body.pop("timeout", None) body = {k: v for k, v in body.items() if v is not None} api_key = None try: api_key = getattr(agent.client, "api_key", None) except Exception as e: _ra().logger.debug("Could not extract API key for debug dump: %s", e) dump_payload: Dict[str, Any] = { "timestamp": datetime.now().isoformat(), "session_id": agent.session_id, "reason": reason, "request": { "method": "POST", "url": f"{agent.base_url.rstrip('/')}{'/responses' if agent.api_mode == 'codex_responses' else '/chat/completions'}", "headers": { "Authorization": f"Bearer {agent._mask_api_key_for_logs(api_key)}", "Content-Type": "application/json", }, "body": body, }, } if error is not None: error_info: Dict[str, Any] = { "type": type(error).__name__, "message": str(error), } for attr_name in ("status_code", "request_id", "code", "param", "type"): attr_value = getattr(error, attr_name, None) if attr_value is not None: error_info[attr_name] = attr_value body_attr = getattr(error, "body", None) if body_attr is not None: error_info["body"] = body_attr response_obj = getattr(error, "response", None) if response_obj is not None: try: error_info["response_status"] = getattr(response_obj, "status_code", None) error_info["response_text"] = response_obj.text except Exception as e: _ra().logger.debug("Could not extract error response details: %s", e) dump_payload["error"] = error_info timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") # Sanitize the session ID (may come from an untrusted X-Hermes-Session-Id header) # so a "../"-shaped ID cannot write outside logs_dir. safe_sid = _ra()._safe_session_filename_component(agent.session_id) dump_file = agent.logs_dir / f"request_dump_{safe_sid}_{timestamp}.json" # Redact secrets first: this fires unconditionally on API errors and captures the full # request body, so context-embedded secrets would otherwise land in cleartext on disk. from agent.redact import redact_sensitive_text _serialized = json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str) _redacted_payload = json.loads(redact_sensitive_text(_serialized, force=True)) atomic_json_write(dump_file, _redacted_payload, default=str) agent._vprint(f"{agent.log_prefix}🧾 Request debug dump written to: {dump_file}") if env_var_enabled("HERMES_DUMP_REQUEST_STDOUT"): print(json.dumps(_redacted_payload, ensure_ascii=False, indent=2, default=str)) return dump_file except Exception as dump_error: if agent.verbose_logging: logger.warning("Failed to dump API request debug payload: %s", dump_error) return None def _direct_native_anthropic_tool_cache_capability( agent, *, provider: Optional[str] = None, base_url: Optional[str] = None, api_mode: Optional[str] = None, model: Optional[str] = None, ) -> bool: """Return whether this resolved destination accepts native tool markers.""" eff_base_url = base_url if base_url is not None else (agent.base_url or "") eff_api_mode = api_mode if api_mode is not None else (agent.api_mode or "") return ( eff_api_mode == "anthropic_messages" and base_url_hostname(eff_base_url) == "api.anthropic.com" ) def cache_ttl_means_disabled(ttl: Any) -> bool: """Return True when a ``prompt_caching.cache_ttl`` value means caching off. Single predicate shared by ``agent_init`` and the stub policy paths (#76085). Unknown values (``"2h"``, integers) are NOT a disable. """ if ttl in ("5m", "1h"): return False if ttl is False or ttl is None: return True return str(ttl).lower() in ("off", "false", "disabled", "no", "none") # The cache_ttl tiers accepted by config; mirrored by agent_init's live-agent snapshot. VALID_CACHE_TTLS = ("5m", "1h") def _raw_cache_ttl_from_config() -> Any: """Read the raw ``prompt_caching.cache_ttl`` config value (may raise).""" from hermes_cli.config import load_config_readonly pc_cfg = load_config_readonly().get("prompt_caching", {}) or {} return pc_cfg.get("cache_ttl", "5m") def prompt_caching_disabled_from_config() -> bool: """Return True when ``prompt_caching.cache_ttl`` is configured as off (same detection as ``agent_init``; #76085 / #33555).""" try: ttl = _raw_cache_ttl_from_config() except Exception: return False return cache_ttl_means_disabled(ttl) def configured_cache_ttl() -> Optional[str]: """Return the configured ``prompt_caching.cache_ttl`` tier (``5m``/``1h``), else None. Mirrors ``agent_init`` so stub paths don't regress a configured ``1h`` to 5m (#84733). """ try: ttl = _raw_cache_ttl_from_config() except Exception: return None return ttl if ttl in VALID_CACHE_TTLS else None def blank_cache_policy_stub(cache_disabled: Optional[bool] = None): """Build the destination-identity-blank stub for ``anthropic_prompt_cache_policy``. Sole sanctioned constructor so ``_cache_disabled`` is never omitted (#76085); when ``cache_disabled`` is None the global config is consulted. """ from types import SimpleNamespace if cache_disabled is None: cache_disabled = prompt_caching_disabled_from_config() return SimpleNamespace( provider="", base_url="", api_mode="", model="", _cache_disabled=bool(cache_disabled), ) def plan_cache_sections_for_destination( messages: list, tools: Optional[list], *, provider: str, base_url: str, api_mode: str, model: str, cache_disabled: Optional[bool] = None, cache_ttl: Optional[str] = None, static_system_prefix: Optional[str] = None, ) -> Tuple[list, list]: """Plan request-local cache sections for one resolved destination (MoA / auxiliary senders). Returns stripped copies (non-caching route) or a ``build_prompt_cache_plan`` layout; never mutates ``messages``/``tools``. ``cache_disabled`` and ``cache_ttl`` default to live config so these paths honor the operator's disable (#76085) and tier (#84733); ``static_system_prefix`` gives the system prompt the same early breakpoint as the main loop. """ from agent.prompt_caching import ( build_prompt_cache_plan, effective_cache_ttl, envelope_tool_part_cache_markers_supported, strip_anthropic_cache_control, strip_anthropic_tool_cache_control, ) stub = blank_cache_policy_stub(cache_disabled) should_cache, native_layout = anthropic_prompt_cache_policy( stub, provider=provider, base_url=base_url, api_mode=api_mode, model=model, ) if not should_cache: canonical_messages = copy.deepcopy(messages or []) strip_anthropic_cache_control(canonical_messages) return canonical_messages, strip_anthropic_tool_cache_control(tools) plan = build_prompt_cache_plan( messages, tools, cache_ttl=effective_cache_ttl( # effective_cache_ttl resolves None → "5m"; cache-disabled agents never reach here. cache_ttl, provider=provider, model=model, ), native_anthropic=native_layout, static_system_prefix=( static_system_prefix if isinstance(static_system_prefix, str) else None ), direct_native_tool_cache=_direct_native_anthropic_tool_cache_capability( stub, provider=provider, base_url=base_url, api_mode=api_mode, model=model, ), # LiteLLM-style envelope routes forward part-level markers into # tool_result.content[] → non-retryable 400 (#89886). tool_part_markers=envelope_tool_part_cache_markers_supported( provider, base_url ), ) return plan.messages, plan.tools def _is_litellm_route(provider_lower: str, base_url: str) -> bool: """True when a route is a LiteLLM proxy, by provider id or host token. ``litellm`` must match as a whole delimited token (not substring) in provider or host; a path segment never qualifies. """ if _has_litellm_token(provider_lower, ":-_/"): return True return _has_litellm_token(base_url_hostname(base_url), ".-") def _has_litellm_token(value: str, delimiters: str) -> bool: """True when ``value`` contains ``litellm`` as a whole delimited token.""" if not value: return False for delimiter in delimiters: value = value.replace(delimiter, " ") return "litellm" in value.split() def anthropic_prompt_cache_policy( agent, *, provider: Optional[str] = None, base_url: Optional[str] = None, api_mode: Optional[str] = None, model: Optional[str] = None, ) -> tuple[bool, bool]: """Decide whether to apply Anthropic prompt caching; returns ``(should_cache, use_native_layout)``. ``use_native_layout`` puts markers on inner content blocks (native Anthropic wire); otherwise on the message envelope (OpenRouter / OpenAI-wire proxies). Qwen/Alibaba routes also honour envelope markers (pi-mono #3392). An operator disable is read from ``_cache_disabled`` (not ``_cache_ttl``, unset during init) so it survives switches and restores (#33555). """ if getattr(agent, "_cache_disabled", False): return (False, False) eff_provider = (provider if provider is not None else agent.provider) or "" eff_base_url = base_url if base_url is not None else (agent.base_url or "") eff_api_mode = api_mode if api_mode is not None else (agent.api_mode or "") eff_model = (model if model is not None else agent.model) or "" # MoA virtual provider matches no caching branch, silently losing caching for the acting # aggregator; resolve the policy from the preset's real aggregator slot instead. if eff_provider.strip().lower() == "moa": try: from hermes_cli.config import load_config as _load_moa_cfg from hermes_cli.moa_config import resolve_moa_preset from hermes_cli.runtime_provider import resolve_runtime_provider _preset = resolve_moa_preset( _load_moa_cfg().get("moa") or {}, eff_model or None ) _agg = _preset.get("aggregator") or {} _agg_provider = str(_agg.get("provider") or "").strip() _agg_model = str(_agg.get("model") or "").strip() if _agg_provider and _agg_model: _agg_base_url = "" _agg_api_mode = "" try: _rt = resolve_runtime_provider( requested=_agg_provider, target_model=_agg_model ) _agg_base_url = _rt.get("base_url") or "" _agg_api_mode = _rt.get("api_mode") or "" except Exception: pass return anthropic_prompt_cache_policy( agent, provider=_agg_provider, base_url=_agg_base_url, api_mode=_agg_api_mode, model=_agg_model, ) except Exception as _moa_exc: # pragma: no cover - defensive logger.debug("MoA aggregator cache-policy resolution failed: %s", _moa_exc) return False, False if isinstance(eff_model, dict): eff_model = eff_model.get('model') or eff_model.get('default') or '' eff_model = eff_model if isinstance(eff_model, str) else str(eff_model or '') model_lower = eff_model.lower() provider_lower = eff_provider.lower() is_claude = "claude" in model_lower # Kimi/Moonshot via OpenRouter uses the same envelope cache_control as Claude; without # this branch it serves ~1% cache hits (#25970). Family matcher covers bare k1./k2. slugs. from agent.anthropic_adapter import _model_name_is_kimi_family is_kimi = ( _model_name_is_kimi_family(eff_model) or "moonshot" in model_lower ) is_openrouter = base_url_host_matches(eff_base_url, "openrouter.ai") # Nous Portal proxies to OpenRouter; treat as OpenRouter-equivalent for cache layout. is_nous_portal = base_url_host_matches(eff_base_url, "nousresearch.com") is_anthropic_wire = eff_api_mode == "anthropic_messages" is_native_anthropic = ( is_anthropic_wire and (eff_provider == "anthropic" or base_url_hostname(eff_base_url) == "api.anthropic.com") ) # Honor a configured route's per-model ``prompt_caching`` capability (explicit false too); # only for the two transports this planner handles, not Responses/Bedrock. custom_prompt_caching = None _supports_anthropic_cache_markers = eff_api_mode in { "anthropic_messages", "chat_completions", } _litellm_openai_wire = ( eff_api_mode == "chat_completions" and is_claude and _is_litellm_route(provider_lower, eff_base_url) ) _custom_providers = getattr(agent, "_custom_providers", None) _route_may_be_custom = False if not _supports_anthropic_cache_markers: # Responses/Bedrock never consume the declaration — skip the # identity probe entirely for those transports. pass elif _custom_providers: # Cheap identity gate before the capability helper, matching its semantics # (normalize_route_base_url + custom_provider_aliases) so spelling differences don't drop declarations. from hermes_cli.providers import custom_provider_aliases from hermes_cli.route_identity import normalize_route_base_url _provider_ids = {provider_lower} if provider_lower.startswith("custom:"): _provider_ids.add(provider_lower.removeprefix("custom:")) _eff_url_normalized = normalize_route_base_url(eff_base_url) for _entry in _custom_providers: if not isinstance(_entry, dict): continue _entry_ids = custom_provider_aliases( str(_entry.get("name") or ""), str(_entry.get("provider_key") or ""), ) if _provider_ids & _entry_ids or ( _eff_url_normalized and normalize_route_base_url(_entry.get("base_url")) == _eff_url_normalized ): _route_may_be_custom = True break elif _custom_providers is None: # None = list not attached yet (early init or blank stub); an attached empty list never # matches. Avoid rebuilding the list for ordinary built-in routes. try: from hermes_cli.providers import get_provider # allow_network=False: never trigger a registry fetch from the send path; # a catalog miss degrades to the conservative capability lookup. _provider_def = get_provider(eff_provider, allow_network=False) _route_may_be_custom = _provider_def is None or ( bool(_provider_def.base_url) and base_url_hostname(_provider_def.base_url) != base_url_hostname(eff_base_url) ) except Exception as _pd_exc: logger.debug( "provider lookup failed during cache-policy pre-gate: %s", _pd_exc, ) _route_may_be_custom = provider_lower.startswith("custom:") if _supports_anthropic_cache_markers and ( is_anthropic_wire or _litellm_openai_wire or _route_may_be_custom ): try: from hermes_cli.config import get_custom_provider_model_capability custom_prompt_caching = get_custom_provider_model_capability( model=eff_model, base_url=eff_base_url, capability="prompt_caching", custom_providers=_custom_providers, ) except Exception as _cap_exc: logger.debug( "custom-provider prompt_caching capability lookup failed: %s", _cap_exc, ) if custom_prompt_caching is not None: # Layout follows the transport: native Messages → inner blocks; OpenAI wire → envelope. return custom_prompt_caching, custom_prompt_caching and is_anthropic_wire # MiniMax-M3 uses server-side automatic prefix caching; explicit markers are dead weight. # Checked BEFORE the native-Anthropic return since provider="anthropic" may point at a MiniMax proxy. is_minimax_provider = provider_lower in {"minimax", "minimax-cn"} is_minimax_host = ( base_url_host_matches(eff_base_url, "api.minimax.io") or base_url_host_matches(eff_base_url, "api.minimaxi.com") ) is_minimax_route = is_minimax_provider or is_minimax_host if is_anthropic_wire and is_minimax_route: from agent.model_metadata import _model_name_suggests_minimax_m3 if _model_name_suggests_minimax_m3(eff_model): return False, False if is_native_anthropic: return True, True # Envelope layout is OpenAI-wire only; Portal Claude on native Messages must fall through # to the anthropic_messages branch (inner-block markers) or it serves 0% cache hits. if ( (is_openrouter or is_nous_portal) and (is_claude or is_kimi) and not is_anthropic_wire ): return True, False # Nous Portal Qwen takes the envelope path too; the alibaba-family check below only matches # provider=opencode/alibaba and would leave Portal traffic uncached. if is_nous_portal and "qwen" in model_lower: return True, False if is_anthropic_wire and is_claude: # Third-party Anthropic-compatible gateway. return True, True # LiteLLM fronting Claude on the OpenAI-compatible wire supports cache_control but matched # no grant branch above (#84506). Claude-only: strict relays reject the block format for # other models (#77217). Envelope layout: the native layout's top-level markers are only # relocated by the anthropic_messages adapter and cause HTTP 400 via LiteLLM (#69512). # Gated on chat_completions explicitly; codex_responses/bedrock_converse have their own handling. if _litellm_openai_wire: return True, False # MiniMax's own models (M2.x) on its Anthropic-compatible endpoint support cache_control; # opt them in past the is_claude gate. M3 is excluded above. if is_anthropic_wire and is_minimax_route: return True, True # Qwen/Alibaba on OpenCode and DashScope accept envelope cache_control on the OpenAI wire. # DeepSeek on OpenCode is excluded: its relay 400s on block-array content (#77217). # Family set/predicate shared with the effective_cache_ttl clamp (#84733). from agent.prompt_caching import ALIBABA_FAMILY_PROVIDERS, is_qwen_model model_is_qwen = is_qwen_model(model_lower) provider_is_alibaba_family = provider_lower in ALIBABA_FAMILY_PROVIDERS if provider_is_alibaba_family and model_is_qwen: # Envelope layout (native_anthropic=False), matching pi-mono's "alibaba" cacheControlFormat. return True, False return False, False def _provider_supplied_client(agent, client_kwargs: dict) -> Any | None: """Ask the registered ProviderProfile for a custom client, if any. Resolves by provider name first, then by the ``base_url`` scheme prefix so a runtime configured only by URL (``acp://…``) still reaches its profile. A profile that raises is logged and skipped: a third-party plugin must not be able to take the turn down, it can only fail to provide a client. """ try: from providers import get_provider_profile except Exception: return None profile = None provider_name = (getattr(agent, "provider", "") or "").strip() if provider_name: try: profile = get_provider_profile(provider_name) except Exception: profile = None if profile is None: base_url = str(client_kwargs.get("base_url", "") or "").strip() if base_url: profile = _profile_for_base_url(base_url) if profile is None: return None try: return profile.create_client(**client_kwargs) except Exception: _ra().logger.warning( "Provider profile %r failed to create a client; falling back to the " "standard client path", getattr(profile, "name", provider_name) or "?", exc_info=True, ) return None def _profile_for_base_url(base_url: str) -> Any | None: """Find a registered profile whose own base_url matches ``base_url``. Only used when the provider name did not resolve. Matches on exact base_url so a non-HTTP scheme (``acp://copilot``) routes to its profile even when the caller passed no provider name. """ try: from providers import list_providers except Exception: return None target = base_url.rstrip("/").lower() try: candidates = list_providers() except Exception: return None for candidate in candidates or []: own = str(getattr(candidate, "base_url", "") or "").rstrip("/").lower() # Prefix match, not equality: the replaced copilot-acp branch keyed on # ``startswith("acp://copilot")``, so a base_url carrying a path or a # user override under the same root must still resolve. if own and (target == own or target.startswith(own + "/")): return candidate return None def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: bool) -> Any: from agent.auxiliary_client import _validate_base_url, _validate_proxy_env_urls from agent.ssl_verify import resolve_httpx_verify # Treat client_kwargs as read-only: callers pass agent._client_kwargs, and in-place # mutation leaks into later requests (#10933: a torn-down httpx transport got reused). client_kwargs = dict(client_kwargs) # The MoA virtual provider has no OpenAI wire endpoint; the facade *is* the client. # Rebuild the facade, never a native client (#78382 TypeError, #53802 relay re-wire). if (getattr(agent, "provider", "") or "").strip().lower() == "moa": from agent.moa_loop import build_moa_facade return build_moa_facade(agent, getattr(agent, "model", None) or "default") ssl_ca_cert = client_kwargs.pop("ssl_ca_cert", None) ssl_verify_cfg = client_kwargs.pop("ssl_verify", None) httpx_verify = resolve_httpx_verify(ca_bundle=ssl_ca_cert, ssl_verify=ssl_verify_cfg) _validate_proxy_env_urls() _validate_base_url(client_kwargs.get("base_url")) # ── Provider-supplied client (registration seam) ────────────────────── # A provider whose wire protocol is not OpenAI-over-HTTP supplies its own # client from its ProviderProfile.create_client(). Consulted before the # built-in ladder so a profile registered from ~/.hermes/plugins/ or a pip # entry point can ship a transport without editing this function — that is # what makes an out-of-tree ACP provider possible at all. Returning None # (the default) falls through to the paths below, so every existing # provider is unaffected. provider_client = _provider_supplied_client(agent, client_kwargs) if provider_client is not None: _ra().logger.info( "%s client created from provider profile (%s, shared=%s) %s", agent.provider, reason, shared, agent._client_log_context(), ) return provider_client if agent.provider == "gemini": from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url base_url = str(client_kwargs.get("base_url", "") or "") if is_native_gemini_base_url(base_url): safe_kwargs = { k: v for k, v in client_kwargs.items() if k in {"api_key", "base_url", "default_headers", "timeout", "http_client"} } if "http_client" not in safe_kwargs: keepalive_http = agent._build_keepalive_http_client( base_url, verify=httpx_verify, ) if keepalive_http is not None: safe_kwargs["http_client"] = keepalive_http client = GeminiNativeClient(**safe_kwargs) _ra().logger.info( "Gemini native client created (%s, shared=%s) %s", reason, shared, agent._client_log_context(), ) return client # TCP keepalives so dead provider connections are detected (~60s) instead of hanging in # CLOSE-WAIT (#10324). Injected into the local copy only (#10933), so each client gets its # own httpx.Client; pinned by tests/run_agent/test_create_openai_client_reuse.py and # tests/run_agent/test_sequential_chats_live.py. if "http_client" not in client_kwargs: keepalive_http = agent._build_keepalive_http_client( client_kwargs.get("base_url", ""), verify=httpx_verify, ) if keepalive_http is not None: client_kwargs["http_client"] = keepalive_http # Retries belong to the outer conversation loop (honors Retry-After); SDK retries would # double-retry inside it (#26293). auxiliary_client keeps SDK retries as it isn't wrapped. client_kwargs.setdefault("max_retries", 0) # Defense-in-depth: primary_recovery/restore_primary rebuild from a _primary_runtime # snapshot without re-running header wiring; missing Copilot-Integration-Id causes # model_not_available_for_integrator 400s. Only ADD missing keys, never override. try: if base_url_host_matches(str(client_kwargs.get("base_url", "")), "githubcopilot.com"): from hermes_cli.models import copilot_default_headers existing = dict(client_kwargs.get("default_headers") or {}) existing_lower = {k.lower() for k in existing} for hk, hv in copilot_default_headers().items(): if hk.lower() not in existing_lower: existing[hk] = hv client_kwargs["default_headers"] = existing except Exception: _ra().logger.debug("Copilot default-header guard skipped", exc_info=True) # OpenCode Free is served anonymously: any unrecognized bearer is a 401, so an empty # Authorization default_header overrides the SDK's "Bearer ". if agent.provider == "opencode-free": from hermes_cli.models import opencode_zen_free_headers _existing = dict(client_kwargs.get("default_headers") or {}) _existing.update(opencode_zen_free_headers()) client_kwargs["default_headers"] = _existing # All primary construction and recovery paths must identify Hermes to the # official Codex endpoint, including snapshots with custom header overrides. from agent.codex_headers import apply_required_codex_headers apply_required_codex_headers( client_kwargs, access_token=client_kwargs.get("api_key", ""), base_url=str(client_kwargs.get("base_url", "")), ) # Module-level `OpenAI` is resolved lazily via __getattr__; tests patch `run_agent.OpenAI`. client = _ra().OpenAI(**client_kwargs) _ra().logger.info( "OpenAI client created (%s, shared=%s) %s", reason, shared, agent._client_log_context(), ) return client def _apply_switched_provider_request_overrides(agent, new_provider): """Re-derive the switched-to provider's ``request_overrides`` (custom_providers ``extra_body``) onto a live agent. Matches by provider key, base_url AND model (same rule as ``agent_init._merge_custom_provider_extra_body``) so a different model at the same endpoint never inherits another model's ``extra_body``. Stale ``extra_body`` is cleared; ``service_tier`` / ``speed`` overrides are preserved. """ from agent.agent_init import _custom_provider_extra_body_for_agent # Prefer the init-time cache (agent._custom_providers); reload only if absent. custom_providers = getattr(agent, "_custom_providers", None) if custom_providers is None: try: from hermes_cli.config import load_config, get_compatible_custom_providers custom_providers = get_compatible_custom_providers(load_config()) except Exception: custom_providers = [] new_extra_body = _custom_provider_extra_body_for_agent( provider=new_provider, model=getattr(agent, "model", "") or "", base_url=getattr(agent, "base_url", "") or "", custom_providers=custom_providers or [], ) overrides = dict(getattr(agent, "request_overrides", {}) or {}) overrides.pop("extra_body", None) # always drop the previous provider's extra_body if new_extra_body: overrides["extra_body"] = dict(new_extra_body) agent.request_overrides = overrides def switch_model( agent, new_model, new_provider, api_key='', base_url='', api_mode='', capabilities=None, ): """Switch the model/provider in-place for a live agent (rebuild clients, caching flags, compressor). Mirrors ``_try_activate_fallback()`` but also updates ``_primary_runtime`` so the change persists across turns. """ from hermes_cli.providers import determine_api_mode from agent.native_compaction import resolve_native_compaction_capabilities old_model = agent.model old_provider = agent.provider old_norm = (old_provider or "").strip().lower() new_norm = (new_provider or "").strip().lower() # Pass model so dual-wire providers (Nous Portal anthropic/* -> Messages) resolve correctly. if not api_mode: api_mode = determine_api_mode(new_provider, base_url, model=new_model) if not base_url and new_norm == "openai": # An omitted URL means the provider's canonical direct endpoint. base_url = "https://api.openai.com/v1" # Same-provider switches may omit base_url (e.g. credential refresh); resolve # capabilities from the endpoint the normalization below retains. effective_base_url = base_url if not effective_base_url and old_norm == new_norm: effective_base_url = getattr(agent, "base_url", "") destination_capabilities = ( dict(capabilities) if isinstance(capabilities, dict) else resolve_native_compaction_capabilities( model=new_model, base_url=effective_base_url, provider=new_provider, is_codex_backend=new_norm == 'openai-codex', ) ) # Guard against a trailing /v1 on OpenCode base_url reaching the anthropic_messages # client (double-/v1 404); model_switch already strips it, direct callers may not. from hermes_cli.models import opencode_provider_family if ( api_mode == "anthropic_messages" and opencode_provider_family(new_provider) is not None and isinstance(base_url, str) and base_url ): base_url = re.sub(r"/v1/?$", "", base_url) # Snapshot every field the swap+rebuild mutates so a failed rebuild rolls back atomically # (else new model name + OLD client -> 400s next turn). Sentinel distinguishes unset from # None: tests build bare agents via __new__ without all fields. _MISSING = object() _snapshot = { name: getattr(agent, name, _MISSING) for name in ( "model", "provider", "requested_provider", "base_url", "api_mode", "api_key", "client", "_anthropic_client", "_anthropic_api_key", "_anthropic_base_url", "_is_anthropic_oauth", "_config_context_length", "_reasoning_echo_flag", "runtime_capabilities", ) } # Shallow-copy the dict so mutating the live one doesn't poison the rollback target. _snapshot["_client_kwargs"] = dict(getattr(agent, "_client_kwargs", {}) or {}) # Pool reload is part of this switch and must be reversible on rollback (#52727). _snapshot["_credential_pool"] = getattr(agent, "_credential_pool", _MISSING) _snapshot["_credential_pool_entry_id"] = getattr( agent, "_credential_pool_entry_id", _MISSING ) def _restore_snapshot() -> None: for _name, _value in _snapshot.items(): if _value is _MISSING: # Attribute did not exist before the swap; don't fabricate it. continue try: setattr(agent, _name, _value) except Exception: # noqa: BLE001 pass try: # Clear the per-config override so the new model's context window is re-resolved. agent._config_context_length = None # ── Swap core runtime fields ── agent.model = new_model agent.provider = new_provider agent.requested_provider = new_provider # Re-read reasoning_echo so the flag reflects the new primary model (see _reasoning_echo_opt_in). agent._reasoning_echo_flag = agent._read_reasoning_echo_from_config() # Empty base_url while the provider changes means upstream resolution failed; falling # back to the old provider's URL pairs the wrong host and persists via _primary_runtime # (#47828). Fail loud. Same-provider re-select (credential refresh) may keep the URL. if base_url: agent.base_url = base_url elif old_norm != new_norm: raise ValueError( f"switch_model: no base_url resolved for provider " f"'{new_provider}' (switching from '{old_provider}'); " "refusing to keep the previous provider's endpoint" ) agent.api_mode = api_mode # New api_mode may need a different transport. if hasattr(agent, "_transport_cache"): agent._transport_cache.clear() if api_key: agent.api_key = api_key # Reload the credential pool on provider change (#52727): a pool with a mismatched # provider makes recover_with_credential_pool short-circuit. Reload failure is non-fatal. if old_norm != new_norm or getattr(agent, "_credential_pool", None) is None: # A pool bound to the old provider is worse than none: the recovery guard rejects it. agent._credential_pool = None agent._credential_pool_entry_id = None try: from agent.credential_pool import load_pool agent._credential_pool = load_pool(new_provider) except Exception as _pool_exc: # noqa: BLE001 logger.warning( "switch_model: credential pool reload failed for %s (%s); " "continuing without pool rotation this turn", new_provider, _pool_exc, ) # ── Build new client ── if new_norm == "moa": from agent.moa_loop import build_moa_facade # MoA speaks only chat.completions via the MoAClient facade; the aggregator's real # transport is applied inside the fan-out. Pin api_mode so the loop never dispatches # client.responses.create against the facade (matches agent_init.py). agent.api_mode = "chat_completions" agent.api_key = api_key or "moa-virtual-provider" agent.base_url = "moa://local" agent._client_kwargs = {} agent.client = build_moa_facade(agent, agent.model) elif api_mode == "anthropic_messages": from agent.anthropic_adapter import ( build_anthropic_client, resolve_anthropic_token, _is_oauth_token, ) # Only fall back to ANTHROPIC_TOKEN for native Anthropic; other anthropic_messages # providers must never receive Anthropic credentials. _is_native_anthropic = new_provider == "anthropic" effective_key = (api_key or agent.api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or agent.api_key or "") # MiniMax OAuth: per-request callable token provider survives 15-min expiry # (rationale in agent_init.py). if new_provider == "minimax-oauth" and isinstance(effective_key, str) and effective_key: try: from hermes_cli.auth import build_minimax_oauth_token_provider effective_key = build_minimax_oauth_token_provider() except Exception as _mm_exc: # noqa: BLE001 logger.warning( "MiniMax OAuth: failed to install per-request token provider " "on switch (%s); using static bearer.", _mm_exc, ) agent.api_key = effective_key agent._anthropic_api_key = effective_key agent._anthropic_base_url = base_url or getattr(agent, "_anthropic_base_url", None) agent._anthropic_client = build_anthropic_client( effective_key, agent._anthropic_base_url, timeout=get_provider_request_timeout(agent.provider, agent.model), ) agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False agent.client = None agent._client_kwargs = {} else: effective_key = api_key or agent.api_key effective_base = base_url or agent.base_url agent._client_kwargs = { "api_key": effective_key, "base_url": effective_base, } try: from hermes_cli.config import ( apply_custom_provider_tls_to_client_kwargs, get_compatible_custom_providers, load_config_readonly, ) # Read live config, not agent._custom_providers, so mid-session ssl_ca_cert / # ssl_verify edits are honored (#15779). apply_custom_provider_tls_to_client_kwargs( agent._client_kwargs, str(effective_base or ""), get_compatible_custom_providers(load_config_readonly()), ) except Exception: logger.debug("custom-provider TLS resolution skipped on switch_model", exc_info=True) _sm_timeout = get_provider_request_timeout(agent.provider, agent.model) if _sm_timeout is not None: agent._client_kwargs["timeout"] = _sm_timeout # Reapply provider headers (OpenRouter HTTP-Referer/X-Title) lost when # _client_kwargs was rebuilt; otherwise attribution shows "Unknown". agent._apply_client_headers_for_base_url(effective_base) agent.client = agent._create_openai_client( dict(agent._client_kwargs), reason="switch_model", shared=True, ) sync_credential_pool_entry_id(agent) except Exception: # Roll back to the pre-swap snapshot so the agent stays consistent; callers # (cli.py / gateway/run.py / tui_gateway) catch the re-raised exception. _restore_snapshot() raise # LM Studio: preload before probing context length. _sm_custom_providers = None try: from hermes_cli.config import ( get_compatible_custom_providers, get_custom_provider_context_length, load_config, ) _sm_cfg = load_config() _sm_custom_providers = get_compatible_custom_providers(_sm_cfg) _destination_context_intent = get_custom_provider_context_length( model=agent.model, base_url=agent.base_url, custom_providers=_sm_custom_providers, ) except Exception: _destination_context_intent = None agent._config_context_length = _destination_context_intent if hasattr(agent, "_ensure_lmstudio_runtime_loaded"): try: _runtime_context_length = agent._ensure_lmstudio_runtime_loaded( _destination_context_intent ) except Exception: _restore_snapshot() raise else: _runtime_context_length = None if ( hasattr(agent, "_lmstudio_load_was_unverified") and agent._lmstudio_load_was_unverified(_runtime_context_length) ): logger.warning( "LM Studio model activation was rejected or completed without a " "verifiable active context length during model switch; continuing " "with configured context" ) if hasattr(agent, "_effective_lmstudio_context_length"): _effective_context_length = agent._effective_lmstudio_context_length( _destination_context_intent, _runtime_context_length, ) else: _effective_context_length = _destination_context_intent # Refresh the custom-provider snapshot from the config just loaded so the prompt_caching # lookup sees flags added to config.yaml after session start. if _sm_custom_providers is not None: agent._custom_providers = _sm_custom_providers agent._use_prompt_caching, agent._use_native_cache_layout = ( agent._anthropic_prompt_cache_policy( provider=new_provider, base_url=agent.base_url, api_mode=api_mode, model=new_model, ) ) # ── Update context compressor ── if hasattr(agent, "context_compressor") and agent.context_compressor: from agent.model_metadata import get_model_context_length if _sm_custom_providers is None: try: from hermes_cli.config import get_compatible_custom_providers, load_config _sm_custom_providers = get_compatible_custom_providers(load_config()) except Exception: _sm_custom_providers = None # agent.api_key may be a callable (Azure Foundry Entra ID); get_model_context_length # expects a string for live probes, so coerce defensively. _ctx_api_key = agent.api_key if isinstance(agent.api_key, str) else "" try: new_context_length = get_model_context_length( agent.model, base_url=agent.base_url, api_key=_ctx_api_key, provider=agent.provider, config_context_length=_effective_context_length, custom_providers=_sm_custom_providers, ) agent.context_compressor.update_model( model=agent.model, context_length=new_context_length, base_url=agent.base_url, api_key=agent.api_key, # context_compressor forwards to call_llm; callable preserved provider=agent.provider, api_mode=agent.api_mode, ) except Exception: _restore_snapshot() raise # Re-read the per-model reasoning_effort override so it applies immediately # (per-model > global; YAML False = disabled). try: from hermes_constants import resolve_reasoning_config from hermes_cli.config import load_config as _sm_load_config _reasoning_cfg = _sm_load_config() or {} agent.reasoning_config = resolve_reasoning_config(_reasoning_cfg, agent.model) logger.info( "switch_model: reasoning_config resolved for %s: %s", agent.model, agent.reasoning_config, ) except Exception as _reasoning_err: logger.debug("switch_model: could not re-resolve reasoning_config: %s", _reasoning_err) # Invalidate the cached system prompt so it rebuilds next turn. agent._cached_system_prompt = None # Publish the destination capability map only after every runtime setup # above has succeeded. Failed switches must leave the old map intact. agent.runtime_capabilities = destination_capabilities # Reset the cross-turn stale-call circuit breaker (#58962); otherwise the latched # streak keeps short-circuiting the freshly selected healthy provider. from agent.chat_completion_helpers import _reset_stale_streak _reset_stale_streak(agent) # Update _primary_runtime so the change persists across turns. _cc = agent.context_compressor if hasattr(agent, "context_compressor") and agent.context_compressor else None agent._primary_runtime = { "model": agent.model, "provider": agent.provider, "requested_provider": agent.requested_provider, "base_url": agent.base_url, "api_mode": agent.api_mode, "api_key": getattr(agent, "api_key", ""), "client_kwargs": dict(agent._client_kwargs), "use_prompt_caching": agent._use_prompt_caching, "use_native_cache_layout": agent._use_native_cache_layout, "reasoning_config": dict(agent.reasoning_config) if getattr(agent, "reasoning_config", None) else None, "reasoning_echo_flag": getattr(agent, "_reasoning_echo_flag", False), # Overrides must travel with the switched-to identity or a later recovery/restore # resurrects PRE-switch overrides from the stale init snapshot (#75091). "request_overrides": dict(getattr(agent, "request_overrides", {}) or {}), "runtime_capabilities": dict(getattr(agent, "runtime_capabilities", {}) or {}), "compressor_model": getattr(_cc, "model", agent.model) if _cc else agent.model, "compressor_base_url": getattr(_cc, "base_url", agent.base_url) if _cc else agent.base_url, "compressor_api_key": getattr(_cc, "api_key", "") if _cc else "", "compressor_provider": getattr(_cc, "provider", agent.provider) if _cc else agent.provider, "compressor_context_length": _cc.context_length if _cc else 0, "compressor_api_mode": getattr(_cc, "api_mode", agent.api_mode) if _cc else agent.api_mode, "compressor_threshold_tokens": _cc.threshold_tokens if _cc else 0, } if api_mode == "anthropic_messages": agent._primary_runtime.update({ "anthropic_api_key": agent._anthropic_api_key, "anthropic_base_url": agent._anthropic_base_url, "is_anthropic_oauth": agent._is_anthropic_oauth, }) # ── Reset fallback state ── agent._fallback_activated = False agent._provider_fallback_active = False agent._provider_fallback_route = None agent._fallback_index = 0 # On a deliberate provider swap, prune fallback entries targeting the OLD or NEW primary; # otherwise a failed turn silently re-activates the provider the user just rejected. fallback_chain = list(getattr(agent, "_fallback_chain", []) or []) if old_norm and new_norm and old_norm != new_norm: fallback_chain = [ entry for entry in fallback_chain if (entry.get("provider") or "").strip().lower() not in {old_norm, new_norm} ] agent._fallback_chain = fallback_chain agent._fallback_model = fallback_chain[0] if fallback_chain else None # Apply the switched-to provider's request_overrides (custom_providers extra_body). try: _apply_switched_provider_request_overrides(agent, new_provider) except Exception: logger.debug("switch_model: request_overrides re-derivation failed", exc_info=True) logger.info( "Model switched in-place: %s (%s) -> %s (%s)", old_model, old_provider, new_model, new_provider, ) # Persist billing route so dashboard Model cards show the post-switch provider (#48248). # _session_db / session_id may be unset (tests, bare agents). _session_db = getattr(agent, "_session_db", None) _session_id = getattr(agent, "session_id", None) if _session_db is not None and _session_id: try: _session_db.update_session_billing_route( _session_id, provider=agent.provider, base_url=agent.base_url, billing_mode=getattr(agent, "api_mode", None), ) except Exception: logger.warning( "Failed to persist billing route after model switch", exc_info=True, ) def invoke_tool(agent, function_name: str, function_args: dict, effective_task_id: str, tool_call_id: Optional[str] = None, messages: list = None, pre_tool_block_checked: bool = False, skip_tool_request_middleware: bool = False, tool_request_middleware_trace: Optional[List[Dict[str, Any]]] = None, skip_tool_execution_middleware: bool = False) -> str: """Invoke a single tool and return the result string; no display logic. Handles agent-level and registry-dispatched tools. Used by the concurrent path; the sequential path keeps its own inline invocation for display. """ from agent.inline_tool_executors import ( InlineToolContext, emit_terminal_post_tool_call, resolve_invoke_tool_executor, tool_hook_ids, ) if not isinstance(function_args, dict): function_args = {} _tool_middleware_trace = list(tool_request_middleware_trace or []) try: from hermes_cli.middleware import apply_tool_request_middleware if not skip_tool_request_middleware: _tool_request_mw = apply_tool_request_middleware( function_name, function_args, **tool_hook_ids(agent, effective_task_id, tool_call_id), ) function_args = _tool_request_mw.payload _tool_middleware_trace = _tool_request_mw.trace except Exception as _mw_err: logger.debug("tool_request middleware error: %s", _mw_err) # Check plugin hooks for a block or approval directive before executing. block_message: Optional[str] = None if not pre_tool_block_checked: try: from hermes_cli.plugins import _dispatch_pre_tool_call_hooks block_message, modified_args = _dispatch_pre_tool_call_hooks( function_name, function_args, task_id=effective_task_id or "", session_id=getattr(agent, "session_id", "") or "", tool_call_id=tool_call_id or "", turn_id=getattr(agent, "_current_turn_id", "") or "", api_request_id=getattr(agent, "_current_api_request_id", "") or "", middleware_trace=list(_tool_middleware_trace), ) if modified_args is not None: function_args = modified_args except Exception: block_message = None if block_message is not None: result = json.dumps({"error": block_message}, ensure_ascii=False) emit_terminal_post_tool_call( agent, function_name=function_name, function_args=function_args, result=result, effective_task_id=effective_task_id, tool_call_id=tool_call_id, status="blocked", error_type="plugin_block", error_message=block_message, middleware_trace=_tool_middleware_trace, ) return result tool_start_time = time.monotonic() def _finish_agent_tool(result: Any, observed_args: Optional[dict] = None) -> Any: emit_terminal_post_tool_call( agent, function_name=function_name, function_args=observed_args if isinstance(observed_args, dict) else function_args, result=result, effective_task_id=effective_task_id, tool_call_id=tool_call_id, duration_ms=int((time.monotonic() - tool_start_time) * 1000), middleware_trace=_tool_middleware_trace, ) return result inline_executor = resolve_invoke_tool_executor(agent, function_name) if inline_executor is not None: inline_ctx = InlineToolContext( effective_task_id=effective_task_id, tool_call_id=tool_call_id, messages=messages, ) def _execute(next_args: dict) -> Any: return _finish_agent_tool(inline_executor(agent, next_args, inline_ctx), next_args) else: def _execute(next_args: dict) -> Any: dispatch_kwargs = dict( tool_call_id=tool_call_id, session_id=agent.session_id or "", turn_id=getattr(agent, "_current_turn_id", "") or "", api_request_id=getattr(agent, "_current_api_request_id", "") or "", enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None, skip_pre_tool_call_hook=True, skip_tool_request_middleware=True, enabled_toolsets=getattr(agent, "enabled_toolsets", None), disabled_toolsets=getattr(agent, "disabled_toolsets", None), tool_request_middleware_trace=list(_tool_middleware_trace), ) if skip_tool_execution_middleware: dispatch_kwargs["skip_tool_execution_middleware"] = True return _ra().handle_function_call( function_name, next_args, effective_task_id, **dispatch_kwargs, ) if skip_tool_execution_middleware: return _execute(function_args) from hermes_cli.middleware import run_tool_execution_middleware return run_tool_execution_middleware( function_name, function_args, lambda next_args: _execute(next_args if isinstance(next_args, dict) else function_args), original_args=function_args, **tool_hook_ids(agent, effective_task_id, tool_call_id), ) def repair_tool_call(agent, tool_name: str) -> str | None: """Repair a mismatched tool name (case, separators, CamelCase, ``_tool`` suffixes, then fuzzy match) before aborting. Suffix stripping is applied twice so ``TodoTool_tool`` reduces fully (#14784). Returns the repaired name if in valid_tool_names, else None. """ import re from difflib import get_close_matches if not tool_name: return None # VolcEngine api/plan (#33007) leaks XML attribute fragments into tool_use.name # (`terminal" parameter="command" ...`); trim at the first quote/angle bracket. # Do NOT split on whitespace: "write file" must reach ``_norm`` -> ``write_file`` # (test_space_to_underscore in tests/run_agent/test_repair_tool_call_name.py). for _xml_sep in ('"', "'", "<", ">"): _idx = tool_name.find(_xml_sep) if _idx > 0: tool_name = tool_name[:_idx] if not tool_name: return None def _norm(s: str) -> str: return s.lower().replace("-", "_").replace(" ", "_") def _camel_snake(s: str) -> str: return re.sub(r"(? str | None: lc = s.lower() for suffix in ("_tool", "-tool", "tool"): if lc.endswith(suffix): return s[: -len(suffix)].rstrip("_-") return None # Cheap fast-paths first. lowered = tool_name.lower() if lowered in agent.valid_tool_names: return lowered normalized = _norm(tool_name) if normalized in agent.valid_tool_names: return normalized cands: set[str] = {tool_name, lowered, normalized, _camel_snake(tool_name)} # Strip trailing tool-suffix up to twice (TodoTool_tool needs it). for _ in range(2): extra: set[str] = set() for c in cands: stripped = _strip_tool_suffix(c) if stripped: extra.add(stripped) extra.add(_norm(stripped)) extra.add(_camel_snake(stripped)) cands |= extra for c in cands: if c and c in agent.valid_tool_names: return c matches = get_close_matches(lowered, agent.valid_tool_names, n=1, cutoff=0.7) if matches: return matches[0] return None def _tool_call_id_variants(tc: Any) -> set: """Return every id a tool result might match this tool_call on. Thin backward-compatible forwarder; policy owner is ``agent.message_sanitization.tool_call_id_variants``. """ return set(tool_call_id_variants(tc)) # Placeholder for an empty non-final message the provider would reject. Kept identical to # the stub placeholder in chat_completion_helpers so healed transcripts read consistently. _INTERRUPTED_PLACEHOLDER = "[response interrupted]" # Escalate repeated heals once per session window, then stay quiet (#96870). Default # threshold; tunable via ``agent.sanitizer_heal_escalation_threshold`` (<= 0 disables). _EMPTY_HEAL_ESCALATE_AFTER = 3 _EMPTY_HEAL_WINDOW_S = 600.0 _empty_heal_log_state: Dict[str, Dict[str, Any]] = {} _empty_heal_log_lock = threading.Lock() # Sessions already given the one-time user notice; separate from the windowed log state # so the user is told ONCE per session (#96870, out-of-band, never in conversation context). _empty_heal_user_notified: set = set() # One-shot pending notices keyed by session, drained by the conversation loop via # ``consume_pending_sanitizer_heal_notice`` and delivered via the status/warning callback. _empty_heal_pending_notice: Dict[str, str] = {} def _msg_has_payload(msg: Dict[str, Any]) -> bool: """True if ``msg`` carries anything the API treats as non-empty content (text, multimodal blocks, tool_calls, tool_call_id, reasoning). Role-agnostic counterpart of ``AIAgent._is_thinking_only_assistant``. """ content = msg.get("content") if isinstance(content, str): if content.strip(): return True elif isinstance(content, list): for block in content: if isinstance(block, dict): # any typed block counts, as long as a text block is not itself blank if block.get("type") == "text": if isinstance(block.get("text"), str) and block["text"].strip(): return True continue return True elif block: return True elif content not in (None, ""): return True # Structural payloads that make an "empty-content" message still valid. if msg.get("tool_calls"): return True if isinstance(msg.get("reasoning_content"), str) and msg["reasoning_content"].strip(): return True if msg.get("reasoning") or msg.get("reasoning_details"): return True # Codex Responses item carriers persist with content:"" by design (text lives in # codex_message_items / codex_reasoning_items and is replayed); treat as payload so # the repair never rewrites a designed-empty codex turn. return bool(msg.get("codex_message_items") or msg.get("codex_reasoning_items")) def fill_empty_non_final_wire_payload( msg: Dict[str, Any], *, is_final: bool ) -> bool: """Write the interrupted placeholder onto an empty non-final wire copy; returns True when filled. Pass the per-call copy only; durable history must not be mutated (#88955, #96870). """ if is_final or not isinstance(msg, dict): return False if msg.get("role") not in ("user", "assistant"): return False if _msg_has_payload(msg): return False msg["content"] = _INTERRUPTED_PLACEHOLDER return True def _session_id_for_heal_log() -> str: try: from hermes_logging import _session_context return str(getattr(_session_context, "session_id", None) or "") except Exception: return "" def _heal_escalation_threshold() -> int: """Escalation threshold from ``agent.sanitizer_heal_escalation_threshold``, else the module default (fail-safe on any read error).""" try: from hermes_cli.config import load_config_readonly raw = (load_config_readonly().get("agent", {}) or {}).get( "sanitizer_heal_escalation_threshold" ) if raw is not None: return int(raw) except Exception: pass return _EMPTY_HEAL_ESCALATE_AFTER def consume_pending_sanitizer_heal_notice() -> Optional[str]: """Drain the one-time user notice for the current session, if any (at most one per session lifetime). Delivered through the status/warning callback, NEVER appended to the conversation context. """ key = _session_id_for_heal_log() or "-" with _empty_heal_log_lock: return _empty_heal_pending_notice.pop(key, None) def get_sanitizer_heal_stats() -> Dict[str, Dict[str, Any]]: """Read-only snapshot of per-session sanitiser heal counters for diagnostics. Keyed by session id; values carry ``heal_events``, ``messages_healed`` and ``escalated``. """ with _empty_heal_log_lock: return { k: { "heal_events": v.get("total_events", v.get("count", 0)), "messages_healed": v.get("total_healed", 0), "escalated": k in _empty_heal_user_notified, } for k, v in _empty_heal_log_state.items() } def _log_empty_non_final_heal(healed: int) -> None: """WARNING on the first heals in a window, one ERROR at the threshold, then silent (#96870). The threshold also queues a ONE-TIME out-of-band user notice (drained by ``consume_pending_sanitizer_heal_notice``); never re-armed by a new window. """ key = _session_id_for_heal_log() or "-" threshold = _heal_escalation_threshold() now = time.monotonic() with _empty_heal_log_lock: state = _empty_heal_log_state.get(key) if state is None or (now - state["window_start"]) > _EMPTY_HEAL_WINDOW_S: prior_events = state.get("total_events", 0) if state else 0 prior_healed = state.get("total_healed", 0) if state else 0 state = { "count": 0, "window_start": now, "escalated": False, "total_events": prior_events, "total_healed": prior_healed, } _empty_heal_log_state[key] = state state["count"] += 1 state["total_events"] = state.get("total_events", 0) + 1 state["total_healed"] = state.get("total_healed", 0) + healed count = state["count"] total_events = state["total_events"] total_healed = state["total_healed"] if threshold > 0 and count >= threshold and not state["escalated"]: state["escalated"] = True level = "error" if key not in _empty_heal_user_notified: _empty_heal_user_notified.add(key) _empty_heal_pending_notice[key] = ( "⚠️ Your session transcript required repeated repair " f"({total_events} heal passes so far). Replies keep " "working, but a corrupted turn is stuck in this " "session's history — run /debug share or `hermes " "doctor` to capture diagnostics, or /new to start a " "clean session." ) elif state["escalated"]: level = "silent" else: level = "warning" if level == "silent": return if level == "error": _ra().logger.error( "Pre-call sanitizer: repeated-heal escalation for session %s — " "healed %d empty non-final message(s) this send; heal pattern: " "%d heal events / %d messages healed this session " "(%d in the current session window, threshold %d). The transcript " "is being repaired on every send; /new drops the poisoned turns.", key, healed, total_events, total_healed, count, threshold, ) return _ra().logger.warning( "Pre-call sanitizer: healed %d empty non-final message(s) by " "substituting placeholder content — an empty-content turn was in " "the transcript and would 400 the request ('messages must have " "non-empty content' / INVALID_REQUEST_BODY). Self-recovering the " "poisoned transcript in memory; no restart needed.", healed, ) def repair_empty_non_final_messages( messages: List[Dict[str, Any]], ) -> List[Dict[str, Any]]: """Substitute a placeholder for empty-content non-final messages on the per-call copy. Anthropic/litellm/Bedrock 400 on any empty non-final message, and a persisted stub poisons every later turn; repairing the wire copy heals the session in memory. Substitution (not deletion) keeps role alternation and tool-call pairing intact. The final message is left untouched. """ if not messages or len(messages) < 2: return messages repaired: List[Dict[str, Any]] = [] healed = 0 last_idx = len(messages) - 1 for idx, msg in enumerate(messages): if ( idx != last_idx and isinstance(msg, dict) # Tool results are checked by their own pairing pass; empty ones are a separate concern. and msg.get("role") in ("assistant", "user") and not _msg_has_payload(msg) ): # Shallow-copy so stored history / prompt caching stays byte-stable. fixed = dict(msg) fixed["content"] = _INTERRUPTED_PLACEHOLDER repaired.append(fixed) healed += 1 else: repaired.append(msg) if healed: _log_empty_non_final_heal(healed) return repaired return messages def _classify_tool_call_orphans(messages: List[Dict[str, Any]]): """Classify orphaned tool-call / tool-result pairs; single source of truth for GLOBAL orphan detection. Returns ``(surviving_call_ids, result_call_ids, orphaned_results, missing_tool_calls)``. Every id variant of a tool_call (``id``, ``call_id``, ``response_item_id``, composite bridge) is registered so a result matching any alias survives (#55626, #63000, #58357). ``orphaned_results`` are the actual dicts (filter by ``id(msg)``). ``sanitize_api_messages`` pairs positionally instead (#94704) but shares the ``tool_call_id_variants`` / ``tool_result_id_variants`` alias policy. """ assistant_call_variants: List[tuple[Any, frozenset[str]]] = [] surviving_call_ids: set[str] = set() for msg in messages: if msg.get("role") != "assistant": continue for tc in msg.get("tool_calls") or []: variants = tool_call_id_variants(tc) if variants: assistant_call_variants.append((tc, variants)) surviving_call_ids.update(variants) result_entries = [ (msg, tool_result_id_variants(msg.get("tool_call_id"))) for msg in messages if msg.get("role") == "tool" ] result_call_ids: set[str] = set() for _, variants in result_entries: result_call_ids.update(variants) orphaned_results = [ msg for msg, variants in result_entries if variants and not (variants & surviving_call_ids) ] orphaned_ids = {id(msg) for msg in orphaned_results} surviving_result_variants = [ variants for msg, variants in result_entries if variants and id(msg) not in orphaned_ids ] missing_tool_calls = [ tc for tc, variants in assistant_call_variants if not any(variants & rv for rv in surviving_result_variants) ] return surviving_call_ids, result_call_ids, orphaned_results, missing_tool_calls def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Fix orphaned tool_call / tool_result pairs before every LLM call; runs unconditionally (not gated on the compressor).""" # --- Role allowlist: drop messages with roles the API won't accept --- filtered = [] for msg in messages: role = msg.get("role") if role not in _ra().AIAgent._VALID_API_ROLES: _ra().logger.debug( "Pre-call sanitizer: dropping message with invalid role %r", role, ) continue filtered.append(msg) messages = filtered # --- Heal empty-content non-final messages (self-recovery) --- # A dead stream can leave an empty stub mid-transcript that 400s every later request; # repair the per-call copy so the session heals in memory. Done first so the substituted # turn participates in the tool-pair and dedup passes below. messages = repair_empty_non_final_messages(messages) # --- Drop empty / malformed tool_calls arrays on assistant messages --- # Strict providers 400 on ``tool_calls: []`` (#58755, #56980). Normalize on the # per-call copy (shallow-copy) so persisted history stays byte-stable. normalized: List[Dict[str, Any]] = [] dropped_empty_tool_calls = 0 for msg in messages: if ( isinstance(msg, dict) and msg.get("role") == "assistant" and "tool_calls" in msg and not (isinstance(msg["tool_calls"], list) and msg["tool_calls"]) ): msg = {k: v for k, v in msg.items() if k != "tool_calls"} dropped_empty_tool_calls += 1 normalized.append(msg) if dropped_empty_tool_calls: messages = normalized _ra().logger.debug( "Pre-call sanitizer: dropped empty/invalid tool_calls on %d " "assistant message(s)", dropped_empty_tool_calls, ) # --- Repair tool_calls whose function.name is empty/missing --- # Rename to a sentinel instead of dropping: the dispatch loop keeps empty-name calls # paired with an anti-priming result (#47967), and Responses adapters drop nameless calls (400). _EMPTY_NAME_SENTINEL = "invalid_tool_call" for msg in messages: if msg.get("role") != "assistant": continue tcs = msg.get("tool_calls") or [] if not tcs: continue for tc in tcs: if isinstance(tc, dict): fn = tc.get("function") name = fn.get("name") if isinstance(fn, dict) else getattr(fn, "name", None) else: fn = getattr(tc, "function", None) name = getattr(fn, "name", None) if fn else None if isinstance(name, str) and name.strip(): continue _ra().logger.warning( "Pre-call sanitizer: repairing tool_call with empty " "function.name -> %r (id=%s)", _EMPTY_NAME_SENTINEL, _ra().AIAgent._get_tool_call_id_static(tc), ) if isinstance(fn, dict): fn["name"] = _EMPTY_NAME_SENTINEL elif fn is not None and hasattr(fn, "name"): try: fn.name = _EMPTY_NAME_SENTINEL except Exception: pass elif isinstance(tc, dict): tc["function"] = {"name": _EMPTY_NAME_SENTINEL, "arguments": "{}"} # --- Drop tool results with a missing/empty tool_call_id --- # Kept explicit (not left to the positional walk) for its own log line and so the # final-chokepoint guarantee holds for callers skipping ``repair_message_sequence`` (#78071). _pre_id_filter_count = len(messages) messages = [ m for m in messages if not (m.get("role") == "tool" and not (m.get("tool_call_id") or "").strip()) ] if len(messages) != _pre_id_filter_count: _ra().logger.debug( "Pre-call sanitizer: dropped %d tool result(s) with missing/empty tool_call_id", _pre_id_filter_count - len(messages), ) # --- Positional tool_call <-> tool_result pairing (#94704) --- # Strict providers (DeepSeek v4, Kimi) require results IMMEDIATELY after their call: # drop positional orphans, stub unanswered declared ids; matching is alias-aware (#55626/#63000/#93251). paired: List[Dict[str, Any]] = [] declared_calls: Dict[str, tuple] = {} dropped_positional_orphans = 0 added_stubs = 0 def _flush_unanswered_stubs() -> None: nonlocal added_stubs for key in sorted(declared_calls): tc, _variants = declared_calls[key] cid = coalesce_tool_call_id(tc) or key paired.append({ "role": "tool", "name": _ra().AIAgent._get_tool_call_name_static(tc), "content": "[Result unavailable — see context summary above]", "tool_call_id": cid, }) added_stubs += 1 declared_calls.clear() for msg in messages: role = msg.get("role") if role == "assistant": # A new assistant turn closes the previous tool-result run: # anything still pending was never answered positionally. _flush_unanswered_stubs() declared_calls = {} for tc in msg.get("tool_calls") or []: variants = tool_call_id_variants(tc) if variants: # Key on a stable representative of the alias group so # a result matching ANY spelling can consume the call. declared_calls[sorted(variants)[0]] = (tc, variants) paired.append(msg) elif role == "tool": result_variants = tool_result_id_variants(msg.get("tool_call_id")) matched = next( ( key for key, (_tc, variants) in declared_calls.items() if variants & result_variants ), None, ) if matched is not None: paired.append(msg) # Consume so a duplicate result reusing the id is dropped (strict providers reject duplicates). declared_calls.pop(matched, None) else: dropped_positional_orphans += 1 else: if role == "user": # A user turn closes the tool-result run; later tool messages are orphans. _flush_unanswered_stubs() paired.append(msg) # The transcript may end right after an unanswered assistant turn. _flush_unanswered_stubs() if dropped_positional_orphans or added_stubs: messages = paired if dropped_positional_orphans: _ra().logger.debug( "Pre-call sanitizer: removed %d positionally orphaned tool result(s)", dropped_positional_orphans, ) if added_stubs: _ra().logger.debug( "Pre-call sanitizer: added %d stub tool result(s) for " "positionally unanswered tool call(s)", added_stubs, ) # 3. Deduplicate tool_call_ids (strict providers 400 on duplicates, #58327): collapse # duplicates within an assistant message; drop results answering no OUTSTANDING call. # Track outstanding calls (not ids ever seen) because llama.cpp reuses one constant id, # and track the whole variant group so alias-keyed results are not deleted (#93251). seen_assistant_call_ids: set = set() outstanding_call_ids: set = set() outstanding_groups: Dict[int, frozenset] = {} variant_to_group: Dict[str, int] = {} next_group_id = 0 deduped: List[Dict[str, Any]] = [] removed_dupes = 0 for msg in messages: role = msg.get("role") if role == "assistant" and msg.get("tool_calls"): kept_tcs = [] for tc in msg.get("tool_calls") or []: variants = tool_call_id_variants(tc) if variants and variants & seen_assistant_call_ids: removed_dupes += 1 continue if variants: group_id = next_group_id next_group_id += 1 outstanding_groups[group_id] = variants for variant in variants: seen_assistant_call_ids.add(variant) outstanding_call_ids.add(variant) variant_to_group.setdefault(variant, group_id) kept_tcs.append(tc) if kept_tcs: msg = {**msg, "tool_calls": kept_tcs} elif len(kept_tcs) != len(msg.get("tool_calls") or []): msg = {k: v for k, v in msg.items() if k != "tool_calls"} deduped.append(msg) elif role == "tool": result_variants = tool_result_id_variants(msg.get("tool_call_id")) candidate_groups = { variant_to_group[variant] for variant in result_variants if variant in variant_to_group and variant in outstanding_call_ids } if result_variants and not candidate_groups: removed_dupes += 1 continue if candidate_groups: # Consume EVERY variant of the matched call; ids are re-armed by the next call reusing them. group_id = min(candidate_groups) group_variants = outstanding_groups.pop(group_id, frozenset()) for variant in group_variants: outstanding_call_ids.discard(variant) seen_assistant_call_ids.discard(variant) if variant_to_group.get(variant) == group_id: variant_to_group.pop(variant, None) deduped.append(msg) else: deduped.append(msg) if removed_dupes: messages = deduped _ra().logger.debug( "Pre-call sanitizer: removed %d duplicate tool_call_id reference(s)", removed_dupes, ) # 4. Align each tool result's wire ``name`` with its call's function name: Google 400s # on a mismatch, which is routine when tool_search bridges via ``tool_call`` (#72089). # Done here, provider-agnostically, on the per-call copy only. call_names: Dict[str, str] = {} for msg in messages: if msg.get("role") == "assistant": for tc in msg.get("tool_calls") or []: # Strip on insert to match the lookup below so padded ids still pair. cid = (_ra().AIAgent._get_tool_call_id_static(tc) or "").strip() nm = _ra().AIAgent._get_tool_call_name_static(tc) if cid and nm: call_names[cid] = nm realigned: List[Tuple[str, str]] = [] aligned: List[Dict[str, Any]] = [] for msg in messages: if msg.get("role") == "tool": cid = (msg.get("tool_call_id") or "").strip() expected = call_names.get(cid) current = msg.get("name") # Only rewrite a present, disagreeing name; clean transcripts must stay byte-identical for prompt caching. if expected and current and current != expected: msg = {**msg, "name": expected} realigned.append((current, expected)) aligned.append(msg) if realigned: messages = aligned _ra().logger.debug( "Pre-call sanitizer: realigned %d tool result name(s) with their " "tool_call function name (%s)", len(realigned), ", ".join(f"{was} -> {now}" for was, now in realigned), ) return messages def looks_like_codex_intermediate_ack( agent, user_message: Any, assistant_content: str, messages: List[Dict[str, Any]], require_workspace: bool = True, ) -> bool: """Detect a planning/ack message that should continue instead of ending the turn. ``require_workspace=False`` (user opted into ``agent.intent_ack_continuation`` for all api_modes) drops the filesystem/repo reference requirement; the future-ack + short-content + no-prior-tools + action-verb checks always apply. """ if any(isinstance(msg, dict) and msg.get("role") == "tool" for msg in messages): return False assistant_text = agent._strip_think_blocks(assistant_content or "").strip().lower() if not assistant_text: return False if len(assistant_text) > 1200: return False has_future_ack = bool( re.search(r"\b(i['’]ll|i will|let me|i can do that|i can help with that)\b", assistant_text) ) if not has_future_ack: return False action_markers = ( "look into", "look at", "inspect", "scan", "check", "analyz", "review", "explore", "read", "open", "run", "test", "fix", "debug", "search", "find", "walkthrough", "report back", "summarize", ) workspace_markers = ( "directory", "current directory", "current dir", "cwd", "repo", "repository", "codebase", "project", "folder", "filesystem", "file tree", "files", "path", ) assistant_mentions_action = any(marker in assistant_text for marker in action_markers) if not assistant_mentions_action: return False # Opted-in (all-api_mode) path: future-ack + action verb + no prior tool call suffices. if not require_workspace: return True # ``user_message`` may be a multi-part content list (vision via the OpenAI-compat # server); a list survives ``or ""`` and ``.strip()`` raises, so flatten first. from agent.codex_responses_adapter import _summarize_user_message_for_log user_text = _summarize_user_message_for_log(user_message).strip().lower() user_targets_workspace = ( any(marker in user_text for marker in workspace_markers) or "~/" in user_text or "/" in user_text ) assistant_targets_workspace = any( marker in assistant_text for marker in workspace_markers ) return user_targets_workspace or assistant_targets_workspace # Narrow "trailing continue-intent" detector for the stall guard (agent.stall_guards): # only the message TAIL announcing a next action, so mid-sentence "I will" never trips it. _TRAILING_CONTINUE_INTENT_RE = re.compile( r"(?:\blet me now\b|\bi(?:['\u2019])?ll now\b|\bi will now\b" r"|\bnow i(?:['\u2019]ll| will)\b|\bnext[,:] i\b)" r"[^.!?\n]{0,100}[.:\u2026]?\s*$", re.IGNORECASE, ) # Content longer than this is a substantive reply, not a dangling ack. _TRAILING_CONTINUE_INTENT_MAX_CHARS = 400 def trailing_continue_intent(text: str) -> bool: """Whether ``text`` is a short reply ENDING on an announced next action (stall-guard re-prompt trigger).""" t = (text or "").strip() if not t or len(t) > _TRAILING_CONTINUE_INTENT_MAX_CHARS: return False return bool(_TRAILING_CONTINUE_INTENT_RE.search(t[-160:])) def intent_ack_continuation_mode(agent) -> str: """Resolve the intent-ack continuation mode: ``"off"``, ``"codex_only"`` (workspace acks on codex_responses), or ``"all"``. Mirrors ``agent.tool_use_enforcement``: ``"auto"`` -> codex_only; true-ish values -> all; false-ish -> off; ``list`` -> all when a substring matches the active model name, else off. """ mode = getattr(agent, "_intent_ack_continuation", "auto") if mode is True or (isinstance(mode, str) and mode.lower() in {"true", "always", "yes", "on"}): return "all" if mode is False or (isinstance(mode, str) and mode.lower() in {"false", "never", "no", "off"}): return "off" if isinstance(mode, list): model_lower = (agent.model or "").lower() return "all" if any(p.lower() in model_lower for p in mode if isinstance(p, str)) else "off" # "auto" or any unrecognised value — historical codex-only behavior. return "codex_only" if agent.api_mode == "codex_responses" else "off" def copy_reasoning_content_for_api(agent, source_msg: dict, api_msg: dict) -> None: """Forward reasoning fields onto an API replay message; policy lives in ``agent.message_sanitization.apply_reasoning_content_policy``.""" from agent.message_sanitization import apply_reasoning_content_policy apply_reasoning_content_policy( source_msg, api_msg, agent._needs_thinking_reasoning_pad() ) def reapply_reasoning_echo_for_provider(agent, api_messages: list) -> int: """Re-pad or strip assistant turns' reasoning_content for the CURRENT provider after a fallback switch. ``api_messages`` is shaped for the primary provider; require-side providers (DeepSeek/Kimi/MiMo) 400 without the pad, strict ones (Mistral, Cerebras, Groq, ...) 400/422 with it (#45655). Idempotent. Returns the number of assistant turns changed. """ from agent.message_sanitization import reapply_reasoning_echo return reapply_reasoning_echo( api_messages, agent._needs_thinking_reasoning_pad() ) def _iter_httpx_pool_objects(http_client: Any): """Yield httpcore pool objects reachable from an httpx client, including mounted transports. Keepalive (#10324) and proxy configs put live connections on ``client._mounts``; walking only ``_transport`` made ``force_close_tcp_sockets`` miss them (#72975). """ seen_pools: set[int] = set() def _emit(pool: Any): if pool is None: return marker = id(pool) if marker in seen_pools: return seen_pools.add(marker) yield pool def _pools_for_transport(transport: Any): if transport is None: return # Connections live under ``_pool``; a directly mounted HTTPProxy *is* a # ConnectionPool, so ``_connections`` may sit on the transport itself. pool = getattr(transport, "_pool", None) if pool is not None: yield from _emit(pool) return if getattr(transport, "_connections", None) is not None: yield from _emit(transport) try: yield from _pools_for_transport(getattr(http_client, "_transport", None)) mounts = getattr(http_client, "_mounts", None) or {} for _pattern, mounted in list(mounts.items()): yield from _pools_for_transport(mounted) except Exception: return def _connection_candidates(conn: Any): """Walk nested ``_connection`` wrappers (proxy tunnel → HTTP11/2).""" seen: set[int] = set() stack = [conn] while stack: candidate = stack.pop() if candidate is None: continue marker = id(candidate) if marker in seen: continue seen.add(marker) yield candidate inner = getattr(candidate, "_connection", None) if inner is not None and id(inner) not in seen: stack.append(inner) def _iter_pool_sockets(client: Any): """Yield raw sockets reachable from an OpenAI/httpx client pool. Traversal is defensive over private httpcore internals (``conn._connection``, proxy tunnel wrappers) that vary by release. Also walks mount transports and in-flight ``PoolRequest.connection`` objects, reachable when ``_connections`` is empty during checkout (#85252). """ try: http_client = getattr(client, "_client", None) if http_client is None: # Some SDK wrappers *are* the httpx client; fall through so mount-aware discovery runs. http_client = client pools = list(_iter_httpx_pool_objects(http_client)) except Exception: return if not pools: return seen: set[int] = set() for pool in pools: # ``is None``, not falsiness: an empty ``_connections`` must still let us walk in-flight ``_requests``. raw_conns = getattr(pool, "_connections", None) if raw_conns is None: raw_conns = getattr(pool, "_pool", None) connections = list(raw_conns or []) for pool_req in list(getattr(pool, "_requests", None) or []): conn = getattr(pool_req, "connection", None) if conn is not None: connections.append(conn) for conn in connections: for candidate in _connection_candidates(conn): stream = ( getattr(candidate, "_network_stream", None) or getattr(candidate, "_stream", None) ) if stream is None: continue sock = getattr(stream, "_sock", None) if sock is None: get_extra_info = getattr(stream, "get_extra_info", None) if callable(get_extra_info): try: sock = get_extra_info("socket") except Exception: sock = None if sock is None: wrapped = getattr(stream, "stream", None) if wrapped is not None: sock = getattr(wrapped, "_sock", None) if sock is None: # anyio-backed streams expose the raw socket through # SocketAttribute.raw_socket when available. wrapped = getattr(stream, "_stream", None) extra = getattr(wrapped, "extra", None) if callable(extra): try: from anyio.abc import SocketAttribute sock = extra(SocketAttribute.raw_socket) except Exception: sock = None if sock is None: continue marker = id(sock) if marker in seen: continue seen.add(marker) yield sock def cleanup_dead_connections(agent) -> bool: """Force-close and rebuild the primary client if its pool has dead sockets (CLOSE-WAIT, errors); returns True if cleaned.""" client = getattr(agent, "client", None) if client is None: return False try: dead_count = 0 for sock in _iter_pool_sockets(client): # Probe socket health with a non-blocking recv peek import socket as _socket try: sock.setblocking(False) data = sock.recv(1, _socket.MSG_PEEK | _socket.MSG_DONTWAIT) if data == b"": dead_count += 1 except BlockingIOError: pass # No data available — socket is healthy except OSError: dead_count += 1 finally: try: sock.setblocking(True) except OSError: pass if dead_count > 0: _ra().logger.warning( "Found %d dead connection(s) in client pool — rebuilding client", dead_count, ) agent._replace_primary_openai_client(reason="dead_connection_cleanup") return True except Exception as exc: _ra().logger.debug("Dead connection check error: %s", exc) return False def extract_api_error_context(error: Exception) -> Dict[str, Any]: """Extract structured rate-limit details from provider errors.""" context: Dict[str, Any] = {} body = getattr(error, "body", None) payload = None if isinstance(body, dict): payload = body.get("error") if isinstance(body.get("error"), dict) else body if isinstance(payload, dict): reason = payload.get("code") or payload.get("type") or payload.get("error") if isinstance(reason, str) and reason.strip(): context["reason"] = reason.strip() message = payload.get("message") or payload.get("error_description") if not message and isinstance(payload.get("error"), str): # xAI uses a top-level string ``error`` beside a structured # ``code`` (for example personal-team-blocked:spending-limit). message = payload.get("error") if isinstance(message, str) and message.strip(): context["message"] = message.strip() for key in ("resets_at", "reset_at"): value = payload.get(key) if value not in {None, ""}: context["reset_at"] = value break retry_after = payload.get("retry_after") if retry_after not in {None, ""} and "reset_at" not in context: try: context["reset_at"] = time.time() + float(retry_after) except (TypeError, ValueError): pass response = getattr(error, "response", None) headers = getattr(response, "headers", None) if headers: retry_after = headers.get("retry-after") or headers.get("Retry-After") if retry_after and "reset_at" not in context: try: context["reset_at"] = time.time() + float(retry_after) except (TypeError, ValueError): pass ratelimit_reset = headers.get("x-ratelimit-reset") if ratelimit_reset and "reset_at" not in context: context["reset_at"] = ratelimit_reset if "message" not in context: raw_message = str(error).strip() if raw_message: context["message"] = raw_message[:500] if "reset_at" not in context: message = context.get("message") or "" if isinstance(message, str): delay_match = re.search(r"quotaResetDelay[:\s\"]+(\d+(?:\.\d+)?)(ms|s)", message, re.IGNORECASE) if delay_match: value = float(delay_match.group(1)) seconds = value / 1000.0 if delay_match.group(2).lower() == "ms" else value context["reset_at"] = time.time() + seconds else: resets_in_match = re.search( r"resets?\s+in\s+" r"(?:(\d+(?:\.\d+)?)\s*(?:h|hr|hrs|hour|hours)\b\s*)?" r"(?:(\d+(?:\.\d+)?)\s*(?:m|min|mins|minute|minutes)\b\s*)?" r"(?:(\d+(?:\.\d+)?)\s*(?:s|sec|secs|second|seconds)\b)?", message, re.IGNORECASE, ) if resets_in_match and any(resets_in_match.groups()): hours = float(resets_in_match.group(1) or 0) minutes = float(resets_in_match.group(2) or 0) seconds = float(resets_in_match.group(3) or 0) context["reset_at"] = time.time() + (hours * 3600) + (minutes * 60) + seconds else: sec_match = re.search( r"retry\s+(?:after\s+)?(\d+(?:\.\d+)?)\s*(?:sec|secs|seconds|s\b)", message, re.IGNORECASE, ) if sec_match: context["reset_at"] = time.time() + float(sec_match.group(1)) return context def apply_pending_steer_to_tool_results(agent, messages: list, num_tool_msgs: int) -> None: """Append pending /steer text to the last ``role:"tool"`` message of this batch, marked as user-origin. Modifies existing content only, so role alternation is preserved. ``num_tool_msgs`` bounds the tail slice searched. """ if num_tool_msgs <= 0 or not messages: return steer_text = agent._drain_pending_steer() if not steer_text: return # Skip non-tool messages in the tail in case something else is appended at the boundary. target_idx = None for j in range(len(messages) - 1, max(len(messages) - num_tool_msgs - 1, -1), -1): msg = messages[j] if isinstance(msg, dict) and msg.get("role") == "tool": target_idx = j break if target_idx is None: # No tool result in this batch (e.g. all skipped by interrupt): put the steer # back so the caller's fallback delivers it as a next-turn user message. _lock = getattr(agent, "_pending_steer_lock", None) if _lock is not None: with _lock: if agent._pending_steer: agent._pending_steer = agent._pending_steer + "\n" + steer_text else: agent._pending_steer = steer_text else: existing = getattr(agent, "_pending_steer", None) agent._pending_steer = (existing + "\n" + steer_text) if existing else steer_text return marker = format_steer_marker(steer_text) existing_content = messages[target_idx].get("content", "") if not isinstance(existing_content, str): # Anthropic multimodal content blocks: preserve them and append a text block. try: blocks = list(existing_content) if existing_content else [] blocks.append({"type": "text", "text": marker.lstrip()}) messages[target_idx]["content"] = blocks except Exception: # Fall back to string replacement if content shape is unexpected. messages[target_idx]["content"] = f"{existing_content}{marker}" else: messages[target_idx]["content"] = existing_content + marker _ra().logger.info( "Delivered /steer to agent after tool batch (%d chars): %s", len(steer_text), steer_text[:120] + ("..." if len(steer_text) > 120 else ""), ) def force_close_tcp_sockets(client: Any) -> int: """Abort in-flight TCP I/O via ``shutdown(SHUT_RDWR)`` WITHOUT closing FDs. ``close()`` from a non-owner thread is unsafe: the SSL BIO caches the raw FD, the kernel recycles it, and a flushed TLS record lands in the wrong file (#29507 clobbered a SQLite header). ``shutdown()`` is FD-safe from any thread; the owning httpx thread releases the FD on unwind. Returns the number of sockets shut down (logged as ``tcp_force_closed=N`` for backwards-compatible parsing). """ import socket as _socket shutdown_count = 0 try: for sock in _iter_pool_sockets(client): try: # Clear a blocking timeout so a hung SSL_read notices the shutdown (#85252). # Still no close() — that is the #29507 race. settimeout = getattr(sock, "settimeout", None) if callable(settimeout): try: settimeout(0) except OSError: pass sock.shutdown(_socket.SHUT_RDWR) except OSError: # Already shut down / not connected / FD invalid — all benign. pass # IMPORTANT (#29507): do NOT call sock.close() here. See docstring. shutdown_count += 1 except Exception as exc: _ra().logger.debug("Force-close TCP sockets sweep error: %s", exc) return shutdown_count __all__ = [ "convert_to_trajectory_format", "sanitize_tool_call_arguments", "repair_message_sequence", "strip_think_blocks", "recover_with_credential_pool", "try_recover_primary_transport", "drop_thinking_only_and_merge_users", "restore_primary_runtime", "extract_reasoning", "dump_api_request_debug", "prompt_caching_disabled_from_config", "blank_cache_policy_stub", "plan_cache_sections_for_destination", "anthropic_prompt_cache_policy", "create_openai_client", "switch_model", "invoke_tool", "repair_tool_call", "sanitize_api_messages", "looks_like_codex_intermediate_ack", "copy_reasoning_content_for_api", "cleanup_dead_connections", "extract_api_error_context", "apply_pending_steer_to_tool_results", "_iter_pool_sockets", "force_close_tcp_sockets", ]