From 506c586c13c18485bb3c80c68b57af5f5e79bd41 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:18:55 -0700 Subject: [PATCH 01/20] refactor(agent/agent_runtime_helpers): split repair_message_sequence into 4 pass helpers + ordered pass table --- agent/agent_runtime_helpers.py | 250 ++++++++++++++++----------------- 1 file changed, 120 insertions(+), 130 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index db1b6b66ca..50c8b27b5d 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -454,43 +454,52 @@ def note_turn_persisted(agent): agent._inflight_turn_session_id = None -def repair_message_sequence(agent, messages: List[Dict]) -> int: - """Collapse malformed role-alternation left in the live history. +def _is_codex_interim(m: Dict) -> bool: + """Codex Responses interim turn: carries its own continuation state, replayed verbatim.""" + return bool( + m.get("codex_reasoning_items") + or m.get("codex_message_items") + or m.get("finish_reason") == "incomplete" + ) - 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. +def _merge_assistant_into(prev: Dict, msg: Dict) -> None: + """Fold a consecutive assistant ``msg`` into ``prev`` (union tool_calls, concat text).""" + 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. + 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. + 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. + if content_rewritten: + drop_stale_api_content(prev) - Returns the number of repairs made. - """ - if not messages: - return 0 +def _merge_consecutive_assistants(messages: List[Dict]) -> Tuple[List[Dict], int]: + """Pass 0: merge consecutive assistant turns (codex interims exempt).""" 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 ( @@ -503,61 +512,30 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int: 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): + # A provisional verification candidate is superseded, not unioned. + if prev.get("finish_reason") in {"verification_required", "verify_hook_continue"}: 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) + _merge_assistant_into(prev, msg) repairs += 1 continue collapsed.append(msg) + return collapsed, repairs - # 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). + +def _drop_stray_tool_results(messages: List[Dict]) -> Tuple[List[Dict], int]: + """Pass 1: drop tool results not following a known assistant tool call. + + Consumes 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. + """ + repairs = 0 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") + for msg in messages: + role = msg.get("role") if isinstance(msg, dict) else None if role == "assistant": known_tool_ids = {} matched_tool_groups = set() @@ -569,40 +547,39 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int: 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 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: + if result_variants and not candidate_groups: 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) + continue + if candidate_groups: + matched_tool_groups.add(min(candidate_groups)) + elif 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) + return filtered, repairs - # 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. + +def _prune_unanswered_tool_calls(messages: List[Dict]) -> Tuple[List[Dict], int]: + """Pass 2: prune tool_calls not answered in the IMMEDIATELY following tool run. + + A displaced result masks the per-call stub pass and strict providers 400. + Payload-empty turns are dropped; codex interims exempt. + """ + repairs = 0 pruned: List[Dict] = [] + n = len(messages) i = 0 - n = len(filtered) while i < n: - msg = filtered[i] + msg = messages[i] + i += 1 if not ( isinstance(msg, dict) and msg.get("role") == "assistant" @@ -610,45 +587,35 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int: 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() + j = i + while j < n and isinstance(messages[j], dict) and messages[j].get("role") == "tool": + tid = (messages[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: + kept_calls = [tc for tc in msg["tool_calls"] if tool_call_id_variants(tc) & answered] + if len(kept_calls) != len(msg["tool_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 + return pruned, repairs - # Pass 3: merge consecutive user messages (no user input lost). + +def _merge_consecutive_users(messages: List[Dict]) -> Tuple[List[Dict], int]: + """Pass 3: merge consecutive plain-text user messages (no user input lost).""" + repairs = 0 merged: List[Dict] = [] - for msg in pruned: + for msg in messages: if ( merged and isinstance(msg, dict) @@ -657,19 +624,15 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int: 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). + # 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): + if handoff is None and isinstance(prev_content, str) and isinstance(new_content, str): prev["content"] = ( (prev_content + "\n\n" + new_content) if prev_content and new_content @@ -680,11 +643,38 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int: repairs += 1 continue merged.append(msg) + return merged, repairs + +_SEQUENCE_REPAIR_PASSES = ( + _merge_consecutive_assistants, + _drop_stray_tool_results, + _prune_unanswered_tool_calls, + _merge_consecutive_users, +) + + +def repair_message_sequence(agent, messages: List[Dict]) -> int: + """Collapse malformed role-alternation left in the live history; returns repair count. + + 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, in order: merge + consecutive assistant turns (BEFORE orphan detection so the merged tool_call-id + union is known); drop stray tool results; prune tool_calls unanswered in the + immediately following tool run; merge consecutive user messages. A user turn + directly after an assistant turn is valid and left alone. + """ + if not messages: + return 0 + repairs = 0 + current = messages + for repair_pass in _SEQUENCE_REPAIR_PASSES: + current, made = repair_pass(current) + repairs += made if repairs > 0: # Rewrite in place so persistence/return value/DB flush see the repaired sequence. - messages[:] = merged - + messages[:] = current return repairs From af6f9eb3f457a4a6b7248b5ac977778b7524698d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:21:37 -0700 Subject: [PATCH 02/20] refactor(agent/agent_runtime_helpers): split sanitize_api_messages into 7 phase helpers --- agent/agent_runtime_helpers.py | 232 +++++++++++++++++---------------- 1 file changed, 120 insertions(+), 112 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 50c8b27b5d..39f3fa2e20 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -3111,32 +3111,22 @@ def _classify_tool_call_orphans(messages: List[Dict[str, Any]]): 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 --- +def _drop_invalid_roles(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Drop messages whose role 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, - ) + _ra().logger.debug("Pre-call sanitizer: dropping message with invalid role %r", role) continue filtered.append(msg) - messages = filtered + return 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. +def _drop_empty_tool_calls_arrays(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Strict providers 400 on ``tool_calls: []``; normalize on shallow copies so history stays byte-stable.""" normalized: List[Dict[str, Any]] = [] - dropped_empty_tool_calls = 0 + dropped = 0 for msg in messages: if ( isinstance(msg, dict) @@ -3145,27 +3135,27 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] 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 + dropped += 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, - ) + if not dropped: + return messages + _ra().logger.debug( + "Pre-call sanitizer: dropped empty/invalid tool_calls on %d assistant message(s)", dropped + ) + return normalized - # --- 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" + +def _repair_nameless_tool_calls(messages: List[Dict[str, Any]]) -> None: + """Rename empty/missing ``function.name`` to a sentinel (in place). + + Dropping would unpair the anti-priming result the dispatch loop keeps for + empty-name calls, and Responses adapters 400 on nameless calls. + """ + 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: + for tc in msg.get("tool_calls") or []: if isinstance(tc, dict): fn = tc.get("function") name = fn.get("name") if isinstance(fn, dict) else getattr(fn, "name", None) @@ -3175,87 +3165,89 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] 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, + "Pre-call sanitizer: repairing tool_call with empty function.name -> %r (id=%s)", + sentinel, _ra().AIAgent._get_tool_call_id_static(tc), ) if isinstance(fn, dict): - fn["name"] = _EMPTY_NAME_SENTINEL + fn["name"] = sentinel elif fn is not None and hasattr(fn, "name"): try: - fn.name = _EMPTY_NAME_SENTINEL + fn.name = sentinel except Exception: pass elif isinstance(tc, dict): - tc["function"] = {"name": _EMPTY_NAME_SENTINEL, "arguments": "{}"} + tc["function"] = {"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 = [ + +def _drop_results_without_ids(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """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``. + """ + kept = [ 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: + if len(kept) != len(messages): _ra().logger.debug( "Pre-call sanitizer: dropped %d tool result(s) with missing/empty tool_call_id", - _pre_id_filter_count - len(messages), + len(messages) - len(kept), ) + return kept - # --- 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). + +def _pair_tool_calls_positionally(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Positional tool_call <-> tool_result pairing. + + Strict providers (DeepSeek v4, Kimi) require results IMMEDIATELY after their call: + drop positional orphans, stub unanswered declared ids; matching is alias-aware. + """ paired: List[Dict[str, Any]] = [] declared_calls: Dict[str, tuple] = {} - dropped_positional_orphans = 0 - added_stubs = 0 + dropped = 0 + stubs = 0 def _flush_unanswered_stubs() -> None: - nonlocal added_stubs + nonlocal 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, + "tool_call_id": coalesce_tool_call_id(tc) or key, }) - added_stubs += 1 + 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. + # 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. + # 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 - ), + (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 + if matched is None: + dropped += 1 + continue + paired.append(msg) + # Consume so a duplicate result reusing the id is dropped (strict providers reject duplicates). + declared_calls.pop(matched, None) else: if role == "user": # A user turn closes the tool-result run; later tool messages are orphans. @@ -3263,31 +3255,34 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] 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: + if dropped: _ra().logger.debug( - "Pre-call sanitizer: removed %d positionally orphaned tool result(s)", - dropped_positional_orphans, + "Pre-call sanitizer: removed %d positionally orphaned tool result(s)", dropped ) - if added_stubs: + if stubs: _ra().logger.debug( "Pre-call sanitizer: added %d stub tool result(s) for " "positionally unanswered tool call(s)", - added_stubs, + stubs, ) + return paired if (dropped or stubs) else messages - # 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). + +def _dedupe_tool_call_ids(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Deduplicate tool_call_ids (strict providers 400 on duplicates). + + Collapses duplicates within an assistant message and drops results answering + no OUTSTANDING call. Tracks outstanding calls (not ids ever seen) because + llama.cpp reuses one constant id, and the whole variant group so + alias-keyed results are not deleted. + """ 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 + removed = 0 for msg in messages: role = msg.get("role") if role == "assistant" and msg.get("tool_calls"): @@ -3295,7 +3290,7 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] 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 + removed += 1 continue if variants: group_id = next_group_id @@ -3310,40 +3305,38 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] 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 variant in variant_to_group and variant in outstanding_call_ids } if result_variants and not candidate_groups: - removed_dupes += 1 + removed += 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: + for variant in outstanding_groups.pop(group_id, frozenset()): 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, - ) + deduped.append(msg) + if not removed: + return messages + _ra().logger.debug( + "Pre-call sanitizer: removed %d duplicate tool_call_id reference(s)", removed + ) + return deduped - # 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. + +def _realign_tool_result_names(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Align each tool result's wire ``name`` with its call's function name (per-call copy only). + + Google 400s on a mismatch, which is routine when tool_search bridges via ``tool_call``. + """ call_names: Dict[str, str] = {} for msg in messages: if msg.get("role") == "assistant": @@ -3357,23 +3350,38 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] 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) + expected = call_names.get((msg.get("tool_call_id") or "").strip()) 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 + if not realigned: + return messages + _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 aligned + + +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). + + Order matters: empty non-final messages are healed first so the substituted turn + participates in the pairing and dedup passes. + """ + messages = _drop_invalid_roles(messages) + messages = repair_empty_non_final_messages(messages) + messages = _drop_empty_tool_calls_arrays(messages) + _repair_nameless_tool_calls(messages) + messages = _drop_results_without_ids(messages) + messages = _pair_tool_calls_positionally(messages) + messages = _dedupe_tool_call_ids(messages) + return _realign_tool_result_names(messages) From b0ee6e6cf37e1faaf600c66255b9cef3109c5a64 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:26:03 -0700 Subject: [PATCH 03/20] refactor(agent/agent_runtime_helpers): split switch_model into 9 phase helpers + snapshot field table --- agent/agent_runtime_helpers.py | 619 ++++++++++++++++----------------- 1 file changed, 305 insertions(+), 314 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 39f3fa2e20..cf2362c09f 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -2189,42 +2189,67 @@ def _apply_switched_provider_request_overrides(agent, new_provider): 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). +_SWITCH_SNAPSHOT_FIELDS = ( + "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", + # Pool reload is part of the switch and must be reversible on rollback. + "_credential_pool", + "_credential_pool_entry_id", +) +_MISSING = object() - Mirrors ``_try_activate_fallback()`` but also updates ``_primary_runtime`` so - the change persists across turns. + +def _snapshot_switch_state(agent) -> Dict[str, Any]: + """Snapshot every field the swap+rebuild mutates so a failed rebuild rolls back atomically. + + Otherwise a new model name + OLD client 400s next turn. The sentinel distinguishes + unset from None: tests build bare agents via ``__new__`` without all fields. """ + snapshot = {name: getattr(agent, name, _MISSING) for name in _SWITCH_SNAPSHOT_FIELDS} + # Shallow-copy the dict so mutating the live one doesn't poison the rollback target. + snapshot["_client_kwargs"] = dict(getattr(agent, "_client_kwargs", {}) or {}) + return snapshot + + +def _restore_switch_snapshot(agent, snapshot: Dict[str, Any]) -> None: + for name, value in snapshot.items(): + if value is _MISSING: + continue # attribute did not exist before the swap; don't fabricate it + try: + setattr(agent, name, value) + except Exception: # noqa: BLE001 + pass + + +def _resolve_switch_destination(agent, new_model, new_provider, base_url, api_mode, capabilities, old_norm, new_norm): + """Resolve ``(api_mode, base_url, destination_capabilities)`` for the switch target.""" 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() + from hermes_cli.models import opencode_provider_family # 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) @@ -2232,14 +2257,11 @@ def switch_model( model=new_model, base_url=effective_base_url, provider=new_provider, - is_codex_backend=new_norm == 'openai-codex', + 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 @@ -2247,181 +2269,130 @@ def switch_model( and base_url ): base_url = re.sub(r"/v1/?$", "", base_url) + return api_mode, base_url, destination_capabilities - # 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 +def _build_switched_client(agent, new_provider, api_key, base_url, api_mode, new_norm) -> None: + """Build the client for the switched-to destination (MoA facade / native Anthropic / OpenAI wire).""" + 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) + return + if 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() if is_native_anthropic else "") 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: - 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 + 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( - "switch_model: credential pool reload failed for %s (%s); " - "continuing without pool rotation this turn", - new_provider, _pool_exc, + "MiniMax OAuth: failed to install per-request token provider " + "on switch (%s); using static bearer.", + _mm_exc, ) - # ── Build new client ── - if new_norm == "moa": - from agent.moa_loop import build_moa_facade + 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 = {} + return + effective_base = base_url or agent.base_url + agent._client_kwargs = {"api_key": api_key or agent.api_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, + ) - # 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) + # Read live config, not agent._custom_providers, so mid-session ssl_ca_cert / + # ssl_verify edits are honored. + apply_custom_provider_tls_to_client_kwargs( + agent._client_kwargs, + str(effective_base or ""), + get_compatible_custom_providers(load_config_readonly()), + ) 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 + logger.debug("custom-provider TLS resolution skipped on switch_model", exc_info=True) + timeout = get_provider_request_timeout(agent.provider, agent.model) + if timeout is not None: + agent._client_kwargs["timeout"] = 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) - # LM Studio: preload before probing context length. - _sm_custom_providers = None + +def _swap_switch_runtime(agent, new_model, new_provider, api_key, base_url, api_mode, old_provider, old_norm, new_norm) -> None: + """Swap identity/transport fields, reload the pool, rebuild the client (rolled back by the caller on error).""" + # Clear the per-config override so the new model's context window is re-resolved. + agent._config_context_length = None + 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. + # 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: 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_switched_client(agent, new_provider, api_key, base_url, api_mode, new_norm) + sync_credential_pool_entry_id(agent) + + +def _resolve_switch_context_length(agent, snapshot): + """Resolve the destination context length (LM Studio preload first); returns ``(custom_providers, effective_len)``.""" + custom_providers = None try: from hermes_cli.config import ( get_compatible_custom_providers, @@ -2429,119 +2400,70 @@ def switch_model( 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, + custom_providers = get_compatible_custom_providers(load_config()) + intent = get_custom_provider_context_length( + model=agent.model, base_url=agent.base_url, custom_providers=custom_providers ) except Exception: - _destination_context_intent = None - agent._config_context_length = _destination_context_intent + intent = None + agent._config_context_length = intent + runtime_len = None if hasattr(agent, "_ensure_lmstudio_runtime_loaded"): try: - _runtime_context_length = agent._ensure_lmstudio_runtime_loaded( - _destination_context_intent - ) + runtime_len = agent._ensure_lmstudio_runtime_loaded(intent) except Exception: - _restore_snapshot() + _restore_switch_snapshot(agent, snapshot) raise - else: - _runtime_context_length = None - if ( - hasattr(agent, "_lmstudio_load_was_unverified") - and agent._lmstudio_load_was_unverified(_runtime_context_length) - ): + if hasattr(agent, "_lmstudio_load_was_unverified") and agent._lmstudio_load_was_unverified(runtime_len): logger.warning( "LM Studio model activation was rejected or completed without a " "verifiable active context length during model switch; continuing " "with configured context" ) + effective = intent 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 + effective = agent._effective_lmstudio_context_length(intent, runtime_len) + return custom_providers, effective - # 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 "" +def _update_switch_compressor(agent, custom_providers, effective_context_length, snapshot) -> None: + """Point the context compressor at the new model (rolls back the switch on failure).""" + from agent.model_metadata import get_model_context_length + if custom_providers is None: 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, - ) + from hermes_cli.config import get_compatible_custom_providers, load_config + custom_providers = get_compatible_custom_providers(load_config()) except Exception: - _restore_snapshot() - raise - - # Re-read the per-model reasoning_effort override so it applies immediately - # (per-model > global; YAML False = disabled). + 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: - 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, + 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=custom_providers, ) - except Exception as _reasoning_err: - logger.debug("switch_model: could not re-resolve reasoning_config: %s", _reasoning_err) + 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_switch_snapshot(agent, snapshot) + raise - # 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 = { +def _build_primary_runtime_snapshot(agent, api_mode) -> Dict[str, Any]: + """The ``_primary_runtime`` record that persists a switch across turns.""" + cc = agent.context_compressor if hasattr(agent, "context_compressor") and agent.context_compressor else None + rt = { "model": agent.model, "provider": agent.provider, "requested_provider": agent.requested_provider, @@ -2554,30 +2476,32 @@ def switch_model( "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). + # resurrects PRE-switch overrides from the stale init snapshot. "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, + "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({ + rt.update({ "anthropic_api_key": agent._anthropic_api_key, "anthropic_base_url": agent._anthropic_base_url, "is_anthropic_oauth": agent._is_anthropic_oauth, }) + return rt - # ── Reset fallback state ── + +def _finish_switch(agent, new_provider, old_norm, new_norm) -> None: + """Post-switch bookkeeping: fallback reset/prune, request_overrides, billing route.""" 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 []) @@ -2588,35 +2512,102 @@ def switch_model( ] 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) + +def _persist_switch_billing_route(agent) -> None: + """Persist the billing route so dashboard Model cards show the post-switch provider.""" + # _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 None or not session_id: + return + 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 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. Any failure during the swap/rebuild rolls the agent + back to its pre-switch snapshot and re-raises (callers catch). + """ + old_model = agent.model + old_provider = agent.provider + old_norm = (old_provider or "").strip().lower() + new_norm = (new_provider or "").strip().lower() + api_mode, base_url, destination_capabilities = _resolve_switch_destination( + agent, new_model, new_provider, base_url, api_mode, capabilities, old_norm, new_norm + ) + snapshot = _snapshot_switch_state(agent) + try: + _swap_switch_runtime( + agent, new_model, new_provider, api_key, base_url, api_mode, old_provider, old_norm, new_norm + ) + except Exception: + _restore_switch_snapshot(agent, snapshot) + raise + + custom_providers, effective_context_length = _resolve_switch_context_length(agent, snapshot) + # 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 custom_providers is not None: + agent._custom_providers = 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 + ) + if hasattr(agent, "context_compressor") and agent.context_compressor: + _update_switch_compressor(agent, custom_providers, effective_context_length, snapshot) + + # 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 + + agent.reasoning_config = resolve_reasoning_config(_sm_load_config() or {}, 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; 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) + agent._primary_runtime = _build_primary_runtime_snapshot(agent, api_mode) + _finish_switch(agent, new_provider, old_norm, new_norm) 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, - ) + _persist_switch_billing_route(agent) def invoke_tool(agent, function_name: str, function_args: dict, effective_task_id: str, From 7c62fc26dba248872654c131fcfaa7a08f474c0b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:32:30 -0700 Subject: [PATCH 04/20] refactor(agent/agent_runtime_helpers): split recover_with_credential_pool by failover reason + status->reason table --- agent/agent_runtime_helpers.py | 371 ++++++++++++++++----------------- 1 file changed, 182 insertions(+), 189 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index cf2362c09f..d26f334d7c 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -781,6 +781,141 @@ def sync_credential_pool_entry_id(agent) -> None: agent._credential_pool_entry_id = None +_STATUS_TO_FAILOVER_REASON = { + 402: FailoverReason.billing, + 429: FailoverReason.rate_limit, + 401: FailoverReason.auth, + 403: FailoverReason.auth, +} + +_USAGE_LIMIT_REASON_TOKENS = ("usage_limit_reached", "gousagelimit") +_USAGE_LIMIT_MESSAGE_TOKENS = ("usage limit reached", "usage limit has been reached") + + +def _failed_credential_identity(agent, pool) -> Tuple[Optional[str], Optional[str]]: + """``(api_key_hint, credential_id)`` of 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. + """ + api_key_hint = getattr(agent, "api_key", None) or None + raw_id = getattr(agent, "_credential_pool_entry_id", None) + credential_id = raw_id if isinstance(raw_id, str) and raw_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 + return api_key_hint, credential_id + + +def _is_entitlement_403(agent, status_code, error_context) -> bool: + """Entitlement 403s look like auth failures but refresh cannot fix them. + + Any xai-oauth 403 is treated as entitlement EXCEPT xAI's stale-token signals + (``[WKE=unauthenticated:...]``, "could not be validated"), which must stay refreshable. + """ + if agent._is_entitlement_failure(error_context, status_code): + return True + if status_code != 403: + return False + haystack = " ".join( + str(error_context.get(k) or "").lower() + for k in ("message", "reason", "code", "error") + if isinstance(error_context, dict) + ) + if "oauth authentication is currently not allowed for this organization" in haystack: + return True + provider = agent.provider or "" + if provider == "anthropic" and getattr(agent, "api_mode", "") == "anthropic_messages": + return True + if provider == "xai-oauth": + return not ( + "[wke=unauthenticated:" in haystack + or "oauth2 access token could not be validated" in haystack + ) + return False + + +def _recover_auth_failure(agent, pool, *, status_code, has_retried_429, error_context, api_key_hint, credential_id, rotate_and_swap): + if _is_entitlement_403(agent, status_code, error_context): + _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. + 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 _recover_rate_limit(pool, *, has_retried_429, error_context, api_key_hint, credential_id, rotate_and_swap): + # 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, + ) + return (True, False) if rotate_and_swap(429, "rate limit, pre-exhausted") else (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 = any(t in context_reason for t in _USAGE_LIMIT_REASON_TOKENS) or any( + t in context_message for t in _USAGE_LIMIT_MESSAGE_TOKENS + ) + if not has_retried_429 and not usage_limit_reached: + return False, True + return (True, False) if rotate_and_swap(429, "rate limit") else (False, True) + + def recover_with_credential_pool( agent, *, @@ -795,97 +930,63 @@ def recover_with_credential_pool( 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. + (e.g. Anthropic 400 for "out of extra usage"). ``billing_unverified`` 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). + # The pool belongs to the PRIMARY provider: acting on fallback errors would corrupt + # its state and reset base_url to the primary endpoint. Empty pool provider means + # unscoped; empty agent provider is a mismatch (swap would leave provider="" model=""). 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 + if pool_provider and not credential_pool_matches_provider( + pool, current_provider, base_url=getattr(agent, "base_url", None) + ): + # Same fail-closed boundary predicate as runtime binding. + _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) + api_key_hint, credential_id = _failed_credential_identity(agent, pool) + effective_reason = classified_reason + if effective_reason is None: + effective_reason = _STATUS_TO_FAILOVER_REASON.get(status_code) 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) + 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. + if effective_reason is not None: + failure_reason = effective_reason.value + if effective_reason == FailoverReason.billing and billing_unverified: + # Ambiguous billing body: 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 + next_entry = pool.mark_exhausted_and_rotate(**kwargs) 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", "?"), + 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. @@ -902,129 +1003,21 @@ def recover_with_credential_pool( "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 - + return (True, False) if _rotate_and_swap(402, "billing") else (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) + return _recover_rate_limit( + pool, has_retried_429=has_retried_429, error_context=error_context, + api_key_hint=api_key_hint, credential_id=credential_id, rotate_and_swap=_rotate_and_swap, + ) + if effective_reason == FailoverReason.auth: + return _recover_auth_failure( + agent, pool, status_code=status_code, has_retried_429=has_retried_429, + error_context=error_context, api_key_hint=api_key_hint, credential_id=credential_id, + rotate_and_swap=_rotate_and_swap, ) - 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 From 666b525e4d4ec5fa656d2edfc2b4e4a8b43e3173 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:38:17 -0700 Subject: [PATCH 05/20] refactor(agent/agent_runtime_helpers): split restore_primary_runtime into gate/capabilities/pool-rebind helpers --- agent/agent_runtime_helpers.py | 311 +++++++++++++++------------------ 1 file changed, 143 insertions(+), 168 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index d26f334d7c..a3d31f8253 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1053,6 +1053,21 @@ def _build_anthropic_client_from_runtime(agent, rt: Dict[str, Any]) -> None: agent.client = None +def _rebuild_primary_client(agent, rt: Dict[str, Any], *, reason: str) -> None: + """Rebuild the primary client from a ``_primary_runtime`` snapshot (MoA facade / native Anthropic / OpenAI wire).""" + if (agent.provider or "").strip().lower() == "moa": + # MoA has empty client_kwargs; rebuild via the shared facade factory so the + # reference_callback relay survives recovery. + 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=reason, shared=True) + + def try_recover_primary_transport( agent, api_error: Exception, *, retry_count: int, max_retries: int, ) -> bool: @@ -1062,12 +1077,9 @@ def try_recover_primary_transport( """ 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() @@ -1078,36 +1090,26 @@ def try_recover_primary_transport( 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. + # Never hard-close the shared client here: 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", - ) + 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, + 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} — " @@ -1204,48 +1206,19 @@ def drop_thinking_only_and_merge_users( -def restore_primary_runtime(agent) -> bool: - """Restore the primary runtime at the start of a new turn so fallback stays turn-scoped. +def _primary_reset_gate_blocks(agent, rt, primary_provider, primary_runtime_base_url, matches_primary, load_primary_pool): + """Reset-aware gate: skip a guaranteed-to-fail restore while the primary pool reports a future reset. - Needed for long-lived CLI agents and the gateway's cached agents (``_agent_cache``). + Fails open on any error/None. Returns ``(blocked, prefetched_pool, prefetched)`` so the + rebind step can reuse the loaded pool (one auth.json read at most). """ - 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 + prefetched_pool = None + 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 + if not matches_primary(pool): + prefetched_pool = pool = load_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): @@ -1258,72 +1231,131 @@ def restore_primary_runtime(agent) -> bool: agent.provider, agent.model, ) - return False + return True, prefetched_pool, prefetched except Exception: - logger.debug( - "Reset-aware restore gate failed; falling back to per-turn retry", - exc_info=True, + logger.debug("Reset-aware restore gate failed; falling back to per-turn retry", exc_info=True) + return False, prefetched_pool, prefetched + + +def _restore_runtime_capabilities(agent, rt: Dict[str, Any]) -> None: + if "runtime_capabilities" in rt: + raw = rt["runtime_capabilities"] + if not isinstance(raw, dict): + logger.warning("Ignoring malformed runtime capabilities snapshot") + else: + agent.runtime_capabilities = dict(raw) + elif "capabilities" in rt: + # Snapshots written by the initial capability propagation patch. + raw = rt["capabilities"] + if isinstance(raw, dict): + agent.runtime_capabilities = dict(raw) + + +def _rebind_primary_credential_pool(agent, primary_provider, matches_primary, load_primary_pool, prefetched_pool, prefetched) -> None: + """Rebind and re-select the primary credential pool after a fallback turn. + + 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. + 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. + """ + 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_pool if prefetched else load_primary_pool() + except Exception as exc: + logger.warning( + "Restore could not reload primary credential pool for %s: %s", primary_provider, exc + ) + agent._credential_pool_entry_id = None + pool = getattr(agent, "_credential_pool", None) + if pool is None or not pool.has_available(): + return + entry = pool.select() + if entry is None: + return + entry_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "") + if not entry_key: + return + if matches_primary(entry): + # _swap_credential rebuilds the client and reapplies base-url-scoped headers. + agent._swap_credential(entry) + logger.info( + "Restore re-selected pool entry %s (%s)", + getattr(entry, "id", "?"), getattr(entry, "label", "?"), ) + else: + logger.info( + "Restore skipped pool entry %s (%s): provider %s does not match primary provider %s", + getattr(entry, "id", "?"), + getattr(entry, "label", "?"), + str(getattr(entry, "provider", "") or "").strip().lower() or "?", + primary_provider or "?", + ) + + +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. + 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 + + 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 + + blocked, prefetched_pool, prefetched = _primary_reset_gate_blocks( + agent, rt, primary_provider, primary_runtime_base_url, _matches_primary, _load_primary_pool + ) + if blocked: + return False agent._restore_wait_logged = False fallback_route = getattr(agent, "_provider_fallback_route", None) - if ( - isinstance(fallback_route, (list, tuple)) - and len(fallback_route) == 2 - ): + 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) - ) + 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) + _restore_runtime_capabilities(agent, rt) 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). + # An operator cache disable (_cache_disabled) must survive snapshot restoration. 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( + _rebuild_primary_client(agent, rt, reason="restore_primary") + agent.context_compressor.update_model( model=rt["compressor_model"], context_length=rt["compressor_context_length"], base_url=rt["compressor_base_url"], @@ -1331,79 +1363,23 @@ def restore_primary_runtime(agent) -> bool: 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) ── + _rebind_primary_credential_pool( + agent, primary_provider, _matches_primary, _load_primary_pool, prefetched_pool, prefetched + ) + # Older snapshots have no reasoning_config; 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 + agent._rate_limit_backoff_count = 0 + # Reset the stale-call circuit breaker: its streak measured the fallback provider. + from agent.chat_completion_helpers import _reset_stale_streak, rewrite_prompt_model_identity _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 + # Undo the fallback's identity rewrite so the prompt is byte-identical to the + # stored copy again (prefix cache match). rewrite_prompt_model_identity(agent, rt["model"], rt["provider"]) - - logger.info( - "Primary runtime restored for new turn: %s (%s)", - agent.model, agent.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: @@ -1413,8 +1389,7 @@ def restore_primary_runtime(agent) -> bool: 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. + # Notification surfaces are best-effort and must never undo a successful restore. pass return True except Exception as e: From 6bcad19d1d4df1922c2478dbb437005a2d7521e3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:43:18 -0700 Subject: [PATCH 06/20] refactor(agent/agent_runtime_helpers): split anthropic_prompt_cache_policy (MoA + custom-route gate helpers) --- agent/agent_runtime_helpers.py | 258 ++++++++++++++------------------- 1 file changed, 112 insertions(+), 146 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index a3d31f8253..eb04989d34 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1721,6 +1721,81 @@ def _has_litellm_token(value: str, delimiters: str) -> bool: return "litellm" in value.split() +def _moa_aggregator_cache_policy(agent, eff_model: str) -> tuple[bool, bool]: + """MoA virtual provider: resolve the policy from the preset's real aggregator slot. + + The virtual provider matches no caching branch and would silently lose caching + for the acting aggregator. + """ + 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 + + +def _route_may_be_custom(agent, eff_provider: str, provider_lower: str, eff_base_url: str) -> bool: + """Cheap identity gate deciding whether a custom-provider capability lookup is worth running.""" + custom_providers = getattr(agent, "_custom_providers", None) + if custom_providers: + # Same semantics as the capability helper (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 + ): + return True + return False + if custom_providers is not None: + return False # attached empty list never matches + # None = list not attached yet (early init or blank stub). 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) + return 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) + return provider_lower.startswith("custom:") + + def anthropic_prompt_cache_policy( agent, *, @@ -1733,9 +1808,9 @@ def anthropic_prompt_cache_policy( ``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). + also honour envelope markers. An operator disable is read from ``_cache_disabled`` + (not ``_cache_ttl``, unset during init) so it survives switches and restores. + Branch ORDER is load-bearing (see inline notes). """ if getattr(agent, "_cache_disabled", False): return (False, False) @@ -1744,42 +1819,8 @@ def anthropic_prompt_cache_policy( 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 + return _moa_aggregator_cache_policy(agent, eff_model) if isinstance(eff_model, dict): eff_model = eff_model.get('model') or eff_model.get('default') or '' @@ -1788,85 +1829,27 @@ def anthropic_prompt_cache_policy( 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. + # this it serves ~1% cache hits. 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_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") + 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) + supports_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 + if supports_cache_markers and ( + is_anthropic_wire + or litellm_openai_wire + or _route_may_be_custom(agent, eff_provider, provider_lower, eff_base_url) ): try: from hermes_cli.config import get_custom_provider_model_capability @@ -1875,73 +1858,56 @@ def anthropic_prompt_cache_policy( model=eff_model, base_url=eff_base_url, capability="prompt_caching", - custom_providers=_custom_providers, + custom_providers=getattr(agent, "_custom_providers", None), ) + 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 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 + logger.debug("custom-provider prompt_caching capability lookup failed: %s", _cap_exc) # 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") + is_minimax_route = ( + provider_lower in {"minimax", "minimax-cn"} + or 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 - ): + 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. + # 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, True # third-party Anthropic-compatible gateway + # LiteLLM fronting Claude on the OpenAI wire supports cache_control but matched no grant + # above. Claude-only: strict relays reject the block format for other models. Envelope + # layout: native top-level markers are only relocated by the anthropic_messages adapter + # and 400 via LiteLLM. Gated on chat_completions; 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). + # Qwen/Alibaba on OpenCode and DashScope accept envelope cache_control on the OpenAI wire + # (pi-mono's "alibaba" cacheControlFormat). DeepSeek on OpenCode is excluded: its relay + # 400s on block-array content. Family set/predicate shared with the effective_cache_ttl clamp. 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. + if provider_lower in ALIBABA_FAMILY_PROVIDERS and is_qwen_model(model_lower): return True, False - return False, False From 92c5101c78be0ddd0af90ce9e37d60841df39e59 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:51:04 -0700 Subject: [PATCH 07/20] refactor(agent/agent_runtime_helpers): trajectory/strip_think/extract_reasoning helpers + pattern tables; drop dead _tool_call_id_variants --- agent/agent_runtime_helpers.py | 395 ++++++++++++++------------------- 1 file changed, 162 insertions(+), 233 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index eb04989d34..d227503ea9 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -92,145 +92,106 @@ AGENT_RUNTIME_POST_HOOK_TOOL_NAMES = frozenset( ) +_TRAJECTORY_SYSTEM_PROMPT = ( + "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" + "\n{tools}\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" +) + + +def _trajectory_gpt_prefix(msg: Dict[str, Any]) -> str: + """Leading ```` block from native reasoning tokens, if any.""" + if msg.get("reasoning") and msg["reasoning"].strip(): + return f"\n{msg['reasoning']}\n\n" + return "" + + +def _with_think_block(content: str) -> str: + """Every gpt turn gets a block (empty if none) for a consistent training format.""" + return content if "" in content else "\n\n" + content + + +def _trajectory_tool_call_turn(msg: Dict[str, Any]) -> str: + content = _trajectory_gpt_prefix(msg) + 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 + raw_args = tool_call["function"]["arguments"] + # Arguments were validated during conversation; degrade to {} rather than abort. + try: + arguments = json.loads(raw_args) if isinstance(raw_args, str) else raw_args + except json.JSONDecodeError: + logger.warning("Unexpected invalid JSON in trajectory conversion: %s", raw_args[: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" + return _with_think_block(content).rstrip() + + +def _trajectory_tool_responses(msg: Dict[str, Any], messages: List[Dict[str, Any]], start: int) -> Tuple[List[str], int]: + """Collect the ```` blocks for the tool run starting at ``start``; returns ``(blocks, next_index)``.""" + tool_responses = [] + j = start + while j < len(messages) and messages[j]["role"] == "tool": + tool_msg = messages[j] + tool_content = tool_msg["content"] + try: # pretty-print tool content if it looks like JSON + if tool_content.strip().startswith(("{", "[")): + tool_content = json.loads(tool_content) + except (json.JSONDecodeError, AttributeError): + pass + tool_index = len(tool_responses) + tool_name = ( + msg["tool_calls"][tool_index]["function"]["name"] + if tool_index < len(msg["tool_calls"]) + else "unknown" + ) + payload = json.dumps( + {"tool_call_id": tool_msg.get("tool_call_id", ""), "name": tool_name, "content": tool_content}, + ensure_ascii=False, + ) + tool_responses.append(f"\n{payload}\n") + j += 1 + return tool_responses, j + + 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. + trajectory = [ + {"from": "system", "value": _TRAJECTORY_SYSTEM_PROMPT.format(tools=agent._format_tools_for_system_message())}, + {"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 msg.get("tool_calls"): + trajectory.append({"from": "gpt", "value": _trajectory_tool_call_turn(msg)}) + tool_responses, j = _trajectory_tool_responses(msg, messages, i + 1) if tool_responses: - trajectory.append({ - "from": "tool", - "value": "\n".join(tool_responses) - }) - i = j - 1 # Skip the tool messages we just processed - + trajectory.append({"from": "tool", "value": "\n".join(tool_responses)}) + i = j - 1 # skip the tool messages 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() - }) - + content = _trajectory_gpt_prefix(msg) + convert_scratchpad_to_think(msg["content"] or "") + trajectory.append({"from": "gpt", "value": _with_think_block(content).strip()}) elif msg["role"] == "user": - trajectory.append({ - "from": "human", - "value": msg["content"] - }) - + trajectory.append({"from": "human", "value": msg["content"]}) i += 1 - return trajectory @@ -706,60 +667,62 @@ def repair_message_sequence_with_cursor(agent, messages: List[Dict]) -> int: +def _flatten_content_text(content: Any) -> str: + """Flatten list/dict content (e.g. Anthropic-via-OpenRouter block lists) to text. + + A raw list hitting ``re.sub`` raises TypeError and the conversation loop retries forever. + Thinking/reasoning blocks are dropped outright; their text key varies per provider. + """ + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for part in content: + if isinstance(part, str): + parts.append(part) + elif isinstance(part, dict): + if str(part.get("type") or "").strip().lower() in {"thinking", "reasoning", "redacted_thinking"}: + continue + text = part.get("text") + if isinstance(text, str) and text: + parts.append(text) + return "".join(parts) + if isinstance(content, dict): + return str(content.get("text") or content.get("content") or "") + return str(content) + + +# Order matters: closed pairs first (case-insensitive so mixed-case tags don't fall through +# to the unterminated pass and eat trailing content), then tool-call XML blocks, the +# boundary+name-gated block, the unterminated reasoning block, stray orphan +# reasoning tags, and finally stray tool-call CLOSERS only (bare/unterminated +# is kept: a truncated streaming tail may still be valuable, matching OpenClaw's asymmetry). +_THINK_STRIP_PATTERNS = ( + *_REASONING_BLOCK_PATTERNS, + *_TOOL_CALL_BLOCK_PATTERNS, + _NAMED_FUNCTION_BLOCK_PATTERN, + _UNTERMINATED_REASONING_BLOCK_PATTERN, + _ORPHAN_REASONING_TAG_PATTERN, + _STRAY_TOOL_CALL_CLOSER_PATTERN, +) + + 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 + ``gateway/stream_consumer.py``), stray orphan tags, all case-insensitive variants + (think/thinking/reasoning/REASONING_SCRATCHPAD/thought), and standalone tool-call XML + blocks some open models emit; 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) + content = _flatten_content_text(content) + if not content: + return "" + for pattern in _THINK_STRIP_PATTERNS: + content = pattern.sub('', content) return content @@ -1406,64 +1369,43 @@ _TRANSIENT_TRANSPORT_ERRORS = frozenset({ +_INLINE_REASONING_PATTERNS = tuple( + re.compile(rf"<{tag}>(.*?)", re.DOTALL | re.IGNORECASE) + for tag in ("think", "thinking", "thought", "reasoning", "REASONING_SCRATCHPAD") +) + + 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) + parts: List[str] = [] + def _add(text) -> None: + if text and text not in parts: + parts.append(text) + + _add(getattr(assistant_message, "reasoning", None)) + _add(getattr(assistant_message, "reasoning_content", None)) + # reasoning_details: [{"type": "reasoning.summary", "summary": "...", ...}, ...] + for detail in getattr(assistant_message, "reasoning_details", None) or []: + if isinstance(detail, dict): + _add(detail.get('summary') or detail.get('thinking') or detail.get('content') or detail.get('text')) # 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): + if not 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). + # them makes the next turn fail with HTTP 400 "thinking must be passed back". 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 + _add((block.get("thinking") or block.get("text") or "").strip()) + if not parts and isinstance(content, str) and content: + for pattern in _INLINE_REASONING_PATTERNS: + for block in pattern.findall(content): + _add(block.strip()) + return "\n\n".join(parts) if parts else None @@ -1707,18 +1649,14 @@ def _is_litellm_route(provider_lower: str, base_url: str) -> bool: ``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), ".-") + return _has_litellm_token(provider_lower, ":-_/") or _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() + return "litellm" in value.translate(str.maketrans(delimiters, " " * len(delimiters))).split() def _moa_aggregator_cache_policy(agent, eff_model: str) -> tuple[bool, bool]: @@ -2743,15 +2681,6 @@ def repair_tool_call(agent, tool_name: str) -> str | None: 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]" From 512ea3b0c60c719fb8dc1d6100d110ea7f498e6b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:58:17 -0700 Subject: [PATCH 08/20] refactor(agent/agent_runtime_helpers): helper extraction for arg sanitizer, user-merge, heal log, socket walk, error-context regexes --- agent/agent_runtime_helpers.py | 485 ++++++++++++++------------------- 1 file changed, 211 insertions(+), 274 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index d227503ea9..e8cdc09f00 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -196,6 +196,46 @@ def convert_to_trajectory_format(agent, messages: List[Dict[str, Any]], user_que +def _prepend_corruption_marker(tool_msg: dict, marker: str) -> 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}" + + +def _find_tool_result(messages: list, start: int, tool_call: dict) -> Optional[dict]: + """The tool result answering ``tool_call`` in the run starting at ``start``, if any.""" + for candidate in messages[start:]: + if not isinstance(candidate, dict) or candidate.get("role") != "tool": + return None + if tool_result_id_variants(candidate.get("tool_call_id")) & tool_call_id_variants(tool_call): + return candidate + return None + + +def _cursor_skip_prefix(messages: list, cursor: Optional[dict]) -> int: + """Length of the ``is``-identical prefix already validated on the previous call.""" + start = 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 < limit and messages[start] is prev_prefix[start]: + start += 1 + return start + + def sanitize_tool_call_arguments( messages: list, *, @@ -205,134 +245,73 @@ def sanitize_tool_call_arguments( ) -> 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). + ``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. """ 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 + message_index = _cursor_skip_prefix(messages, cursor) 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") + tool_calls = msg.get("tool_calls") if isinstance(msg, dict) and msg.get("role") == "assistant" else None 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") + function = tool_call.get("function") if isinstance(tool_call, dict) else None 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(): + if arguments is None or (isinstance(arguments, str) and not arguments.strip()): function["arguments"] = "{}" continue if not isinstance(arguments, str): continue - try: json.loads(arguments) + continue 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, + pass + # 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. + 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. + 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, + arguments[:_FULL_ARGS_LOG_BOUND], + ) + function["arguments"] = "{}" + existing_tool_msg = _find_tool_result(messages, message_index + 1, tool_call) + if existing_tool_msg is None: + messages.insert( + insert_at, + make_tool_result_message(function_name if function_name != "?" else "", marker, tool_call_id), ) - 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 - + insert_at += 1 + else: + _prepend_corruption_marker(existing_tool_msg, marker) + 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. + # 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 @@ -367,19 +346,18 @@ def note_turn_start(agent, turn_id: str): 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). + # 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. + # 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( @@ -1089,6 +1067,25 @@ def try_recover_primary_transport( +def _merge_user_content(prev_content: Any, cur_content: Any) -> Any: + """Merged content for two adjacent user messages; ``_UNMERGEABLE`` for unknown shapes. + + String+string joins with a blank line; list sides append as separate blocks. + """ + if isinstance(prev_content, str) and isinstance(cur_content, str): + return prev_content + ("\n\n" if prev_content and cur_content else "") + cur_content + if isinstance(prev_content, list) and isinstance(cur_content, list): + return list(prev_content) + list(cur_content) + if isinstance(prev_content, list) and isinstance(cur_content, str): + return list(prev_content) + ([{"type": "text", "text": cur_content}] if cur_content else []) + if isinstance(prev_content, str) and isinstance(cur_content, list): + return ([{"type": "text", "text": prev_content}] if prev_content else []) + list(cur_content) + return _UNMERGEABLE + + +_UNMERGEABLE = object() + + def drop_thinking_only_and_merge_users( messages: List[Dict[str, Any]], *, @@ -1102,63 +1099,27 @@ def drop_thinking_only_and_merge_users( """ 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, - ) + 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) + if prev is not None and prev.get("role") == "user" and m.get("role") == "user": + content = _merge_user_content(prev.get("content", ""), m.get("content", "")) + if content is not _UNMERGEABLE: + # Copy ``prev`` so caller dicts are never mutated. + merged[-1] = {**prev, "content": content} + merges += 1 continue - merged[-1] = prev_copy - merges += 1 - else: - merged.append(m) - + # Unknown content shape: append separately (violates alternation, but safer + # than raising in a hot path). + 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)", @@ -2712,26 +2673,25 @@ def _msg_has_payload(msg: Dict[str, Any]) -> bool: 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 + if block.get("type") != "text": + return True + if isinstance(block.get("text"), str) and block["text"].strip(): + 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")) + # Structural payloads that make an "empty-content" message still valid. Codex Responses + # item carriers persist with content:"" by design (text lives in codex_*_items and is + # replayed); treating them as payload keeps the repair from rewriting a designed-empty turn. + return bool( + msg.get("tool_calls") + or (isinstance(msg.get("reasoning_content"), str) and msg["reasoning_content"].strip()) + or msg.get("reasoning") + or msg.get("reasoning_details") + or msg.get("codex_message_items") + or msg.get("codex_reasoning_items") + ) def fill_empty_non_final_wire_payload( @@ -2805,7 +2765,7 @@ def get_sanitizer_heal_stats() -> Dict[str, Dict[str, Any]]: 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). + """WARNING on the first heals in a window, one ERROR at the threshold, then silent. 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. @@ -2816,14 +2776,12 @@ def _log_empty_non_final_heal(healed: int) -> None: 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, + "total_events": state.get("total_events", 0) if state else 0, + "total_healed": state.get("total_healed", 0) if state else 0, } _empty_heal_log_state[key] = state state["count"] += 1 @@ -2832,9 +2790,11 @@ def _log_empty_non_final_heal(healed: int) -> None: count = state["count"] total_events = state["total_events"] total_healed = state["total_healed"] - if threshold > 0 and count >= threshold and not state["escalated"]: + if state["escalated"]: + return + escalate = threshold > 0 and count >= threshold + if escalate: state["escalated"] = True - level = "error" if key not in _empty_heal_user_notified: _empty_heal_user_notified.add(key) _empty_heal_pending_notice[key] = ( @@ -2845,14 +2805,7 @@ def _log_empty_non_final_heal(healed: int) -> None: "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": + if escalate: _ra().logger.error( "Pre-call sanitizer: repeated-heal escalation for session %s — " "healed %d empty non-final message(s) this send; heal pattern: " @@ -3449,26 +3402,48 @@ def _connection_candidates(conn: Any): stack.append(inner) +def _socket_from_stream(stream: Any): + """Raw socket behind an httpcore network stream (several backends), or None.""" + 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. + extra = getattr(getattr(stream, "_stream", None), "extra", None) + if callable(extra): + try: + from anyio.abc import SocketAttribute + sock = extra(SocketAttribute.raw_socket) + except Exception: + sock = None + return sock + + 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). + in-flight ``PoolRequest.connection`` objects, reachable when ``_connections`` + is empty during checkout. """ try: + # Some SDK wrappers *are* the httpx client; fall through so mount-aware discovery runs. 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``. @@ -3482,41 +3457,11 @@ def _iter_pool_sockets(client: Any): 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: + stream = getattr(candidate, "_network_stream", None) or getattr(candidate, "_stream", None) + sock = _socket_from_stream(stream) if stream is not None else None + if sock is None or id(sock) in seen: 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) + seen.add(id(sock)) yield sock @@ -3557,10 +3502,42 @@ def cleanup_dead_connections(agent) -> bool: +_QUOTA_RESET_DELAY_RE = re.compile(r"quotaResetDelay[:\s\"]+(\d+(?:\.\d+)?)(ms|s)", re.IGNORECASE) +_RESETS_IN_RE = re.compile( + 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)?", + re.IGNORECASE, +) +_RETRY_AFTER_SECONDS_RE = re.compile(r"retry\s+(?:after\s+)?(\d+(?:\.\d+)?)\s*(?:sec|secs|seconds|s\b)", re.IGNORECASE) + + +def _reset_delay_from_message(message: str) -> Optional[float]: + """Seconds-until-reset parsed from free-text provider messages, or None.""" + m = _QUOTA_RESET_DELAY_RE.search(message) + if m: + value = float(m.group(1)) + return value / 1000.0 if m.group(2).lower() == "ms" else value + m = _RESETS_IN_RE.search(message) + if m and any(m.groups()): + return float(m.group(1) or 0) * 3600 + float(m.group(2) or 0) * 60 + float(m.group(3) or 0) + m = _RETRY_AFTER_SECONDS_RE.search(message) + return float(m.group(1)) if m else None + + +def _set_reset_from_retry_after(context: Dict[str, Any], retry_after: Any) -> None: + if retry_after in {None, ""} or "reset_at" in context: + return + try: + context["reset_at"] = time.time() + float(retry_after) + except (TypeError, ValueError): + pass + + 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): @@ -3571,8 +3548,7 @@ def extract_api_error_context(error: Exception) -> Dict[str, Any]: 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). + # xAI uses a top-level string ``error`` beside a structured ``code``. message = payload.get("error") if isinstance(message, str) and message.strip(): context["message"] = message.strip() @@ -3581,62 +3557,23 @@ def extract_api_error_context(error: Exception) -> Dict[str, Any]: 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) + _set_reset_from_retry_after(context, payload.get("retry_after")) + headers = getattr(getattr(error, "response", None), "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 + _set_reset_from_retry_after(context, headers.get("retry-after") or headers.get("Retry-After") or None) 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)) - + delay = _reset_delay_from_message(message) + if delay is not None: + context["reset_at"] = time.time() + delay return context From 25207b1d8b64cfe430f941fc8482f26066646194 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:11:28 -0700 Subject: [PATCH 09/20] refactor(agent/agent_runtime_helpers): client/invoke_tool/debug-dump/ack/steer helpers; compact async_utils + aux_accounting --- agent/agent_runtime_helpers.py | 552 ++++++++++++++------------------- agent/async_utils.py | 57 +--- agent/aux_accounting.py | 64 +--- 3 files changed, 255 insertions(+), 418 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index e8cdc09f00..bfbdbc0136 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1370,6 +1370,22 @@ def extract_reasoning(agent, assistant_message) -> Optional[str]: +def _api_error_debug_info(error: Exception) -> Dict[str, Any]: + info: Dict[str, Any] = {"type": type(error).__name__, "message": str(error)} + for attr_name in ("status_code", "request_id", "code", "param", "type", "body"): + attr_value = getattr(error, attr_name, None) + if attr_value is not None: + info[attr_name] = attr_value + response_obj = getattr(error, "response", None) + if response_obj is not None: + try: + info["response_status"] = getattr(response_obj, "status_code", None) + info["response_text"] = response_obj.text + except Exception as e: + _ra().logger.debug("Could not extract error response details: %s", e) + return info + + def dump_api_request_debug( agent, api_kwargs: Dict[str, Any], @@ -1382,20 +1398,19 @@ def dump_api_request_debug( 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) - + endpoint = "/responses" if agent.api_mode == "codex_responses" else "/chat/completions" 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'}", + "url": f"{agent.base_url.rstrip('/')}{endpoint}", "headers": { "Authorization": f"Bearer {agent._mask_api_key_for_logs(api_key)}", "Content-Type": "application/json", @@ -1403,49 +1418,22 @@ def dump_api_request_debug( "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 - + dump_payload["error"] = _api_error_debug_info(error) 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: @@ -1814,16 +1802,15 @@ def anthropic_prompt_cache_policy( 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. + 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: @@ -1837,13 +1824,11 @@ def _provider_supplied_client(agent, client_kwargs: dict) -> Any | None: 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", + "Provider profile %r failed to create a client; falling back to the standard client path", getattr(profile, "name", provider_name) or "?", exc_info=True, ) @@ -1851,104 +1836,29 @@ def _provider_supplied_client(agent, client_kwargs: dict) -> Any | None: def _profile_for_base_url(base_url: str) -> Any | None: - """Find a registered profile whose own base_url matches ``base_url``. + """Registered profile whose own base_url is a prefix of ``base_url`` (used when the provider name did not resolve). - 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. + 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. """ try: from providers import list_providers - except Exception: - return None - target = base_url.rstrip("/").lower() - try: candidates = list_providers() except Exception: return None + target = base_url.rstrip("/").lower() 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. +def _ensure_copilot_headers(client_kwargs: dict) -> None: + """Defense-in-depth: recovery/restore rebuild from a snapshot without re-running header + wiring; a 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 @@ -1960,17 +1870,81 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo client_kwargs["default_headers"] = existing except Exception: _ra().logger.debug("Copilot default-header guard skipped", exc_info=True) + + +def _gemini_native_client(agent, client_kwargs: dict, httpx_verify, *, reason: str, shared: bool): + """Native Gemini client when the base_url is the Gemini API, else None.""" + from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url + + base_url = str(client_kwargs.get("base_url", "") or "") + if not is_native_gemini_base_url(base_url): + return None + 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 + + +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 (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 (TypeError; 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 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 (what makes an out-of-tree ACP + # provider possible). None (the default) falls through, so existing providers are 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": + client = _gemini_native_client(agent, client_kwargs, httpx_verify, reason=reason, shared=shared) + if client is not None: + return client + # TCP keepalives so dead provider connections are detected (~60s) instead of hanging in + # CLOSE-WAIT. Injected into the local copy only, so each client gets its own httpx.Client; + # pinned by tests/run_agent/test_create_openai_client_reuse.py and 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. auxiliary_client keeps SDK retries as it isn't wrapped. + client_kwargs.setdefault("max_retries", 0) + _ensure_copilot_headers(client_kwargs) # 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. + client_kwargs["default_headers"] = {**(client_kwargs.get("default_headers") or {}), **opencode_zen_free_headers()} + # 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( @@ -1980,12 +1954,7 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo ) # 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(), - ) + _ra().logger.info("OpenAI client created (%s, shared=%s) %s", reason, shared, agent._client_log_context()) return client @@ -2443,6 +2412,23 @@ def switch_model( _persist_switch_billing_route(agent) +def _pre_tool_block_message(agent, function_name, function_args, effective_task_id, tool_call_id, middleware_trace): + """Plugin pre-tool-call hook verdict: ``(block_message, function_args)``; failures never block.""" + 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(middleware_trace), + ) + return block_message, (modified_args if modified_args is not None else function_args) + except Exception: + return None, function_args + + 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, @@ -2463,39 +2449,23 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i if not isinstance(function_args, dict): function_args = {} - + hook_ids = tool_hook_ids(agent, effective_task_id, tool_call_id) _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), - ) + _tool_request_mw = apply_tool_request_middleware(function_name, function_args, **hook_ids) 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 + block_message, function_args = _pre_tool_block_message( + agent, function_name, function_args, effective_task_id, tool_call_id, _tool_middleware_trace + ) if block_message is not None: result = json.dumps({"error": block_message}, ensure_ascii=False) emit_terminal_post_tool_call( @@ -2513,30 +2483,25 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i 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, + 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) + result = inline_executor(agent, next_args, inline_ctx) + emit_terminal_post_tool_call( + agent, + function_name=function_name, + function_args=next_args if isinstance(next_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 else: def _execute(next_args: dict) -> Any: dispatch_kwargs = dict( @@ -2553,16 +2518,10 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i ) 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, - ) + 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( @@ -2570,7 +2529,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i 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), + **hook_ids, ) @@ -2578,19 +2537,16 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i 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). + Suffix stripping is applied twice so ``TodoTool_tool`` reduces fully. 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 + # VolcEngine api/plan 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). + # Do NOT split on whitespace: "write file" must reach ``_norm`` -> ``write_file``. for _xml_sep in ('"', "'", "<", ">"): _idx = tool_name.find(_xml_sep) if _idx > 0: @@ -2618,28 +2574,19 @@ def repair_tool_call(agent, tool_name: str) -> str | None: 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): + for _ in range(2): # strip trailing tool-suffix up to twice (TodoTool_tool needs it) 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)) + extra.update((stripped, _norm(stripped), _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 + return matches[0] if matches else None # Placeholder for an empty non-final message the provider would reject. Kept identical to @@ -3192,6 +3139,17 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] +_ACK_FUTURE_RE = re.compile(r"\b(i['’]ll|i will|let me|i can do that|i can help with that)\b") +_ACK_ACTION_MARKERS = ( + "look into", "look at", "inspect", "scan", "check", "analyz", "review", "explore", "read", "open", + "run", "test", "fix", "debug", "search", "find", "walkthrough", "report back", "summarize", +) +_ACK_WORKSPACE_MARKERS = ( + "directory", "current directory", "current dir", "cwd", "repo", "repository", "codebase", + "project", "folder", "filesystem", "file tree", "files", "path", +) + + def looks_like_codex_intermediate_ack( agent, user_message: Any, @@ -3201,84 +3159,33 @@ def looks_like_codex_intermediate_ack( ) -> 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. + ``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: + if not assistant_text or len(assistant_text) > 1200: return False - if len(assistant_text) > 1200: + if not _ACK_FUTURE_RE.search(assistant_text): 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: + if not any(marker in assistant_text for marker in _ACK_ACTION_MARKERS): 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) + return ( + any(marker in user_text for marker in _ACK_WORKSPACE_MARKERS) or "~/" in user_text or "/" in user_text + or any(marker in assistant_text for marker in _ACK_WORKSPACE_MARKERS) ) - 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): @@ -3302,23 +3209,25 @@ def trailing_continue_intent(text: str) -> bool: return bool(_TRAILING_CONTINUE_INTENT_RE.search(t[-160:])) +_INTENT_ACK_ON = {"true", "always", "yes", "on"} +_INTENT_ACK_OFF = {"false", "never", "no", "off"} + + 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. + 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"}): + if mode is True or (isinstance(mode, str) and mode.lower() in _INTENT_ACK_ON): return "all" - if mode is False or (isinstance(mode, str) and mode.lower() in {"false", "never", "no", "off"}): + if mode is False or (isinstance(mode, str) and mode.lower() in _INTENT_ACK_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. + # "auto" or any unrecognised value: historical codex-only behavior. return "codex_only" if agent.api_mode == "codex_responses" else "off" @@ -3349,31 +3258,22 @@ def reapply_reasoning_echo_for_provider(agent, api_messages: list) -> int: 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). + Keepalive and proxy configs put live connections on ``client._mounts``; walking + only ``_transport`` made ``force_close_tcp_sockets`` miss them. """ 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) + if pool is None and getattr(transport, "_connections", None) is not None: + pool = transport + if pool is not None and id(pool) not in seen_pools: + seen_pools.add(id(pool)) + yield pool try: yield from _pools_for_transport(getattr(http_client, "_transport", None)) @@ -3465,35 +3365,32 @@ def _iter_pool_sockets(client: Any): yield sock +def _socket_is_dead(sock) -> bool: + """Probe socket health with a non-blocking recv peek.""" + import socket as _socket + try: + sock.setblocking(False) + return sock.recv(1, _socket.MSG_PEEK | _socket.MSG_DONTWAIT) == b"" + except BlockingIOError: + return False # no data available: socket is healthy + except OSError: + return True + finally: + try: + sock.setblocking(True) + except OSError: + pass + + 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 + dead_count = sum(1 for sock in _iter_pool_sockets(client) if _socket_is_dead(sock)) if dead_count > 0: - _ra().logger.warning( - "Found %d dead connection(s) in client pool — rebuilding client", - dead_count, - ) + _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: @@ -3578,6 +3475,18 @@ def extract_api_error_context(error: Exception) -> Dict[str, Any]: +def _requeue_pending_steer(agent, steer_text: str) -> None: + """Put drained steer text 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: + existing = agent._pending_steer + agent._pending_steer = (existing + "\n" + steer_text) if existing else steer_text + return + existing = getattr(agent, "_pending_steer", None) + agent._pending_steer = (existing + "\n" + steer_text) if existing else steer_text + + 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. @@ -3590,29 +3499,22 @@ def apply_pending_steer_to_tool_results(agent, messages: list, num_tool_msgs: in 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 + target_idx = next( + ( + j for j in range(len(messages) - 1, max(len(messages) - num_tool_msgs - 1, -1), -1) + if isinstance(messages[j], dict) and messages[j].get("role") == "tool" + ), + None, + ) 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 + # No tool result in this batch (e.g. all skipped by interrupt). + _requeue_pending_steer(agent, steer_text) return marker = format_steer_marker(steer_text) existing_content = messages[target_idx].get("content", "") - if not isinstance(existing_content, str): + if isinstance(existing_content, str): + messages[target_idx]["content"] = existing_content + marker + else: # Anthropic multimodal content blocks: preserve them and append a text block. try: blocks = list(existing_content) if existing_content else [] @@ -3621,8 +3523,6 @@ def apply_pending_steer_to_tool_results(agent, messages: list, num_tool_msgs: in 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), diff --git a/agent/async_utils.py b/agent/async_utils.py index 07442b63c5..15c288f34d 100644 --- a/agent/async_utils.py +++ b/agent/async_utils.py @@ -1,24 +1,10 @@ """Async/sync bridging helpers. -The codebase has ~30 sites that schedule a coroutine onto an event loop from a -worker thread via :func:`asyncio.run_coroutine_threadsafe`. That function can -raise :class:`RuntimeError` (e.g. the loop was closed during a shutdown race), -and when it does the coroutine object is never awaited and never closed — -which triggers a ``"coroutine '' was never awaited"`` RuntimeWarning and -leaks the coroutine's frame until GC. - -:func:`safe_schedule_threadsafe` wraps the call, closes the coroutine on -scheduling failure, and returns ``None`` (instead of a half-formed future) so -callers can branch cleanly: - - fut = safe_schedule_threadsafe(coro, loop) - if fut is None: - return # or fallback behavior - fut.result(timeout=5) - -The helper deliberately does NOT also handle ``future.result()`` failures — -that is a separate concern. Once the loop has accepted the coroutine, its -lifecycle belongs to the loop, not the scheduling thread. +``asyncio.run_coroutine_threadsafe`` can raise ``RuntimeError`` (loop closed during a +shutdown race); the coroutine is then never awaited or closed, which triggers a +"coroutine was never awaited" RuntimeWarning and leaks its frame. The helpers here +close the coroutine on scheduling failure. ``future.result()`` failures are deliberately +NOT handled: once the loop accepts the coroutine its lifecycle belongs to the loop. """ from __future__ import annotations @@ -41,25 +27,14 @@ def safe_schedule_threadsafe( ) -> Optional[Future]: """Schedule ``coro`` on ``loop`` from a sync context, leak-safe. - Returns the :class:`concurrent.futures.Future` on success, or ``None`` if - the loop is missing or :func:`asyncio.run_coroutine_threadsafe` raised - (e.g. the loop was closed during a shutdown race). In all failure paths - the coroutine is :meth:`close`-d so it does not trigger - ``"coroutine was never awaited"`` warnings or leak its frame. - - Callers retain full control over what to do with the returned future - (call ``.result(timeout=...)``, attach ``add_done_callback``, ignore it - fire-and-forget, etc.). + Returns the Future on success, or ``None`` if the loop is missing or scheduling + raised; in every failure path the coroutine is closed. Callers keep full control + over the returned future (``.result(timeout=...)``, callbacks, fire-and-forget). """ log = logger if logger is not None else _DEFAULT_LOGGER - - if loop is None: - if asyncio.iscoroutine(coro): - coro.close() - log.log(log_level, "%s: loop is None", log_message) - return None - try: + if loop is None: + raise RuntimeError("loop is None") return asyncio.run_coroutine_threadsafe(coro, loop) except Exception as exc: if asyncio.iscoroutine(coro): @@ -69,15 +44,9 @@ def safe_schedule_threadsafe( def consume_detached_task_result(task: "asyncio.Future[Any]") -> None: - """Retrieve a detached task's result without surfacing cancellation. - - Used as an ``add_done_callback`` on tasks that were cancelled and - detached (e.g. an adapter close path that swallows ``CancelledError`` - past its teardown deadline). Observing ``task.exception()`` prevents - "exception was never retrieved" noise on the event loop; cancellation - and any terminal error are deliberately swallowed — the task's owner - already gave up on it. - """ + """``add_done_callback`` for cancelled-and-detached tasks: observe the exception so the + loop does not log "exception was never retrieved"; cancellation and terminal errors + are swallowed because the task's owner already gave up on it.""" try: task.exception() except (asyncio.CancelledError, Exception): diff --git a/agent/aux_accounting.py b/agent/aux_accounting.py index a6994d901c..ee9d15bff2 100644 --- a/agent/aux_accounting.py +++ b/agent/aux_accounting.py @@ -1,27 +1,11 @@ """Ambient session-accounting context for auxiliary LLM calls. -Auxiliary calls (vision, compression, title generation, web_extract, -session_search, ...) funnel through ``agent.auxiliary_client`` which has no -session handle — so their token usage was historically discarded, leaving -dashboard analytics blind to aux model spend (issue #23270). - -Instead of threading ``session_db``/``session_id`` parameters through every -aux call site, the agent loop publishes them here (mirroring the Nous Portal -conversation context in ``agent.portal_tags``) and the auxiliary client -records usage at its single response-validation chokepoint. - -ContextVar semantics give us the right isolation for free: - -* concurrent agents in one process (gateway sessions, delegate subagents) - never see each other's accounting context; -* worker threads spawned via ``tools.thread_context.propagate_context_to_thread`` - (MoA fan-out, background review) inherit the parent turn's context; -* asyncio tasks inherit the context of the code that created them. - -MoA reference/aggregator slots are explicitly EXCLUDED from recording: -``agent/conversation_loop.py`` already folds MoA advisor usage and cost into -the main loop's ``update_token_counts`` delta, so recording them here would -double-count (see ``_EXCLUDED_TASKS``). +Aux calls (vision, compression, title generation, web_extract, session_search, ...) go +through ``agent.auxiliary_client`` which has no session handle, so their usage was +historically discarded. The agent loop publishes ``(session_db, session_id)`` here +(mirroring ``agent.portal_tags``) and the aux client records usage at its single +response-validation chokepoint. ContextVar semantics isolate concurrent agents, propagate +to worker threads via ``tools.thread_context`` and to asyncio tasks automatically. """ from __future__ import annotations @@ -33,22 +17,17 @@ from typing import Any, Optional logger = logging.getLogger(__name__) # (session_db, session_id) for the active agent turn, or None outside one. -_accounting: ContextVar[Optional[tuple]] = ContextVar( - "aux_accounting_context", default=None -) +_accounting: ContextVar[Optional[tuple]] = ContextVar("aux_accounting_context", default=None) -# Aux tasks whose usage is already accounted by the main loop — recording -# them here would double-count. MoA advisor/aggregator usage is folded into -# conversation_loop's update_token_counts delta (tokens AND cost). +# MoA advisor/aggregator usage is already folded into conversation_loop's +# update_token_counts delta (tokens AND cost); recording it here would double-count. _EXCLUDED_TASKS = frozenset({"moa_reference", "moa_aggregator"}) def set_accounting_context(session_db: Any, session_id: Optional[str]): - """Publish the active session's accounting handles for aux usage recording. + """Publish the active session's accounting handles; returns the token for ``reset_accounting_context``. - Called by the agent loop at turn entry. Returns the ContextVar token so - callers can ``reset_accounting_context(token)`` on turn exit. Publishing - ``None`` handles (no DB / no session id) clears the context. + ``None`` handles (no DB / no session id) clear the context. """ if session_db is None or not session_id: return _accounting.set(None) @@ -72,17 +51,10 @@ def record_aux_usage( ) -> None: """Record an auxiliary response's token usage against the ambient session. - Called from the auxiliary client's response-validation chokepoint. Strictly - best-effort: any failure is swallowed (accounting must never break an aux - call). No-ops when: - - * no accounting context is published (call is outside any agent turn), - * the task is main-loop-accounted (MoA slots — see ``_EXCLUDED_TASKS``), - * the response carries no usage object. - - The model is read from ``response.model`` (accurate even after the aux - client's provider-fallback chains); *provider*/*base_url* reflect the - originally-resolved route and are best-effort. + Strictly best-effort (accounting must never break an aux call). No-ops outside an + agent turn, for main-loop-accounted tasks (``_EXCLUDED_TASKS``), or without usage. + The model is read from ``response.model`` (accurate after aux provider fallback); + *provider*/*base_url* reflect the originally-resolved route. """ try: if not task or task in _EXCLUDED_TASKS: @@ -104,18 +76,14 @@ def record_aux_usage( or usage.reasoning_tokens ): return - model = str(getattr(response, "model", "") or "") or "unknown" estimated_cost = None try: - cost = estimate_usage_cost( - model, usage, provider=provider, base_url=base_url - ) + cost = estimate_usage_cost(model, usage, provider=provider, base_url=base_url) if cost.amount_usd is not None: estimated_cost = float(cost.amount_usd) except Exception: logger.debug("Aux usage cost estimation failed", exc_info=True) - session_db.record_auxiliary_usage( session_id, task, From bea9bc1ab9a7b35ad33cbf3fddfde0143d39daa6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:12:43 -0700 Subject: [PATCH 10/20] refactor(agent/agent_runtime_helpers): AST-neutral layout pass (hug/pack bracket spans, blank runs) --- agent/agent_runtime_helpers.py | 240 ++++++++------------------------- 1 file changed, 56 insertions(+), 184 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index bfbdbc0136..20ce8367de 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -18,18 +18,13 @@ 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, + _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, + 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 @@ -47,8 +42,7 @@ _REASONING_TAG_NAMES = ("think", "thinking", "reasoning", "REASONING_SCRATCHPAD" _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 + re.compile(rf"<{name}>.*?", re.DOTALL | re.IGNORECASE) for name in _REASONING_TAG_NAMES ) _TOOL_CALL_BLOCK_PATTERNS = tuple( @@ -66,18 +60,15 @@ _NAMED_FUNCTION_BLOCK_PATTERN = re.compile( ) _UNTERMINATED_REASONING_BLOCK_PATTERN = re.compile( - rf'(?:^|\n)[ \t]*<(?:{"|".join(_REASONING_TAG_NAMES)})\b[^>]*>.*$', - re.DOTALL | re.IGNORECASE, + rf'(?:^|\n)[ \t]*<(?:{"|".join(_REASONING_TAG_NAMES)})\b[^>]*>.*$', re.DOTALL | re.IGNORECASE ) _ORPHAN_REASONING_TAG_PATTERN = re.compile( - rf'\s*', - re.IGNORECASE, + rf'\s*', re.IGNORECASE ) _STRAY_TOOL_CALL_CLOSER_PATTERN = re.compile( - rf'\s*', - re.IGNORECASE, + rf'\s*', re.IGNORECASE ) @@ -195,7 +186,6 @@ def convert_to_trajectory_format(agent, messages: List[Dict[str, Any]], user_que return trajectory - def _prepend_corruption_marker(tool_msg: dict, marker: str) -> None: existing = tool_msg.get("content") if isinstance(existing, str): @@ -237,11 +227,7 @@ def _cursor_skip_prefix(messages: list, cursor: Optional[dict]) -> int: def sanitize_tool_call_arguments( - messages: list, - *, - logger=None, - session_id: str = None, - cursor: Optional[dict] = None, + messages: list, *, logger=None, session_id: str = None, cursor: Optional[dict] = None ) -> int: """Repair corrupted assistant tool-call argument JSON in-place. @@ -586,9 +572,7 @@ def _merge_consecutive_users(messages: List[Dict]) -> Tuple[List[Dict], int]: _SEQUENCE_REPAIR_PASSES = ( - _merge_consecutive_assistants, - _drop_stray_tool_results, - _prune_unanswered_tool_calls, + _merge_consecutive_assistants, _drop_stray_tool_results, _prune_unanswered_tool_calls, _merge_consecutive_users, ) @@ -644,7 +628,6 @@ def repair_message_sequence_with_cursor(agent, messages: List[Dict]) -> int: return repairs - def _flatten_content_text(content: Any) -> str: """Flatten list/dict content (e.g. Anthropic-via-OpenRouter block lists) to text. @@ -676,11 +659,8 @@ def _flatten_content_text(content: Any) -> str: # reasoning tags, and finally stray tool-call CLOSERS only (bare/unterminated # is kept: a truncated streaming tail may still be valuable, matching OpenClaw's asymmetry). _THINK_STRIP_PATTERNS = ( - *_REASONING_BLOCK_PATTERNS, - *_TOOL_CALL_BLOCK_PATTERNS, - _NAMED_FUNCTION_BLOCK_PATTERN, - _UNTERMINATED_REASONING_BLOCK_PATTERN, - _ORPHAN_REASONING_TAG_PATTERN, + *_REASONING_BLOCK_PATTERNS, *_TOOL_CALL_BLOCK_PATTERNS, _NAMED_FUNCTION_BLOCK_PATTERN, + _UNTERMINATED_REASONING_BLOCK_PATTERN, _ORPHAN_REASONING_TAG_PATTERN, _STRAY_TOOL_CALL_CLOSER_PATTERN, ) @@ -704,7 +684,6 @@ def strip_think_blocks(agent, content: str) -> str: return content - def sync_credential_pool_entry_id(agent) -> None: """Rebind ``agent._credential_pool_entry_id`` from the current pool + key. @@ -714,18 +693,14 @@ def sync_credential_pool_entry_id(agent) -> None: 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 + 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 _STATUS_TO_FAILOVER_REASON = { - 402: FailoverReason.billing, - 429: FailoverReason.rate_limit, - 401: FailoverReason.auth, + 402: FailoverReason.billing, 429: FailoverReason.rate_limit, 401: FailoverReason.auth, 403: FailoverReason.auth, } @@ -858,13 +833,9 @@ def _recover_rate_limit(pool, *, has_retried_429, error_context, api_key_hint, c def recover_with_credential_pool( - agent, - *, - status_code: Optional[int], - has_retried_429: bool, + 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, + error_context: Optional[Dict[str, Any]] = None, billing_unverified: bool = False, ) -> tuple[bool, bool]: """Attempt credential recovery via pool rotation. @@ -962,7 +933,6 @@ def recover_with_credential_pool( 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``. @@ -1066,7 +1036,6 @@ def try_recover_primary_transport( # ── End provider fallback ────────────────────────────────────────────── - def _merge_user_content(prev_content: Any, cur_content: Any) -> Any: """Merged content for two adjacent user messages; ``_UNMERGEABLE`` for unknown shapes. @@ -1087,9 +1056,7 @@ _UNMERGEABLE = object() def drop_thinking_only_and_merge_users( - messages: List[Dict[str, Any]], - *, - drop_codex_reasoning_items: bool = True, + 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. @@ -1129,7 +1096,6 @@ def drop_thinking_only_and_merge_users( return merged - def _primary_reset_gate_blocks(agent, rt, primary_provider, primary_runtime_base_url, matches_primary, load_primary_pool): """Reset-aware gate: skip a guaranteed-to-fail restore while the primary pool reports a future reset. @@ -1280,12 +1246,9 @@ def restore_primary_runtime(agent) -> bool: agent._use_native_cache_layout = False _rebuild_primary_client(agent, rt, reason="restore_primary") agent.context_compressor.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", ""), + 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_primary_credential_pool( agent, primary_provider, _matches_primary, _load_primary_pool, prefetched_pool, prefetched @@ -1329,7 +1292,6 @@ _TRANSIENT_TRANSPORT_ERRORS = frozenset({ }) - _INLINE_REASONING_PATTERNS = tuple( re.compile(rf"<{tag}>(.*?)", re.DOTALL | re.IGNORECASE) for tag in ("think", "thinking", "thought", "reasoning", "REASONING_SCRATCHPAD") @@ -1369,7 +1331,6 @@ def extract_reasoning(agent, assistant_message) -> Optional[str]: return "\n\n".join(parts) if parts else None - def _api_error_debug_info(error: Exception) -> Dict[str, Any]: info: Dict[str, Any] = {"type": type(error).__name__, "message": str(error)} for attr_name in ("status_code", "request_id", "code", "param", "type", "body"): @@ -1387,11 +1348,7 @@ def _api_error_debug_info(error: Exception) -> Dict[str, Any]: def dump_api_request_debug( - agent, - api_kwargs: Dict[str, Any], - *, - reason: str, - error: Optional[Exception] = None, + 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: @@ -1441,14 +1398,9 @@ def dump_api_request_debug( 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, + 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 "") @@ -1516,24 +1468,13 @@ def blank_cache_policy_stub(cache_disabled: Optional[bool] = None): 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), + 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, + 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). @@ -1544,20 +1485,13 @@ def plan_cache_sections_for_destination( ``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, + 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, + stub, provider=provider, base_url=base_url, api_mode=api_mode, model=model ) if not should_cache: canonical_messages = copy.deepcopy(messages or []) @@ -1577,11 +1511,7 @@ def plan_cache_sections_for_destination( 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, + 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). @@ -1684,12 +1614,8 @@ def _route_may_be_custom(agent, eff_provider: str, provider_lower: str, eff_base def anthropic_prompt_cache_policy( - agent, - *, - provider: Optional[str] = None, - base_url: Optional[str] = None, - api_mode: Optional[str] = None, - model: Optional[str] = None, + 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)``. @@ -1742,9 +1668,7 @@ def anthropic_prompt_cache_policy( 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", + model=eff_model, base_url=eff_base_url, capability="prompt_caching", custom_providers=getattr(agent, "_custom_providers", None), ) if custom_prompt_caching is not None: @@ -1798,7 +1722,6 @@ def anthropic_prompt_cache_policy( return False, False - def _provider_supplied_client(agent, client_kwargs: dict) -> Any | None: """Ask the registered ProviderProfile for a custom client, if any. @@ -1948,8 +1871,7 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo from agent.codex_headers import apply_required_codex_headers apply_required_codex_headers( - client_kwargs, - access_token=client_kwargs.get("api_key", ""), + 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`. @@ -1978,10 +1900,8 @@ def _apply_switched_provider_request_overrides(agent, new_provider): 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 [], + 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 {}) @@ -2056,9 +1976,7 @@ def _resolve_switch_destination(agent, new_model, new_provider, base_url, api_mo dict(capabilities) if isinstance(capabilities, dict) else resolve_native_compaction_capabilities( - model=new_model, - base_url=effective_base_url, - provider=new_provider, + model=new_model, base_url=effective_base_url, provider=new_provider, is_codex_backend=new_norm == "openai-codex", ) ) @@ -2124,16 +2042,14 @@ def _build_switched_client(agent, new_provider, api_key, base_url, api_mode, new agent._client_kwargs = {"api_key": api_key or agent.api_key, "base_url": effective_base} try: from hermes_cli.config import ( - apply_custom_provider_tls_to_client_kwargs, - get_compatible_custom_providers, + 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. apply_custom_provider_tls_to_client_kwargs( - agent._client_kwargs, - str(effective_base or ""), + agent._client_kwargs, str(effective_base or ""), get_compatible_custom_providers(load_config_readonly()), ) except Exception: @@ -2197,9 +2113,7 @@ def _resolve_switch_context_length(agent, snapshot): custom_providers = None try: from hermes_cli.config import ( - get_compatible_custom_providers, - get_custom_provider_context_length, - load_config, + get_compatible_custom_providers, get_custom_provider_context_length, load_config ) custom_providers = get_compatible_custom_providers(load_config()) @@ -2242,12 +2156,8 @@ def _update_switch_compressor(agent, custom_providers, effective_context_length, 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=custom_providers, + agent.model, base_url=agent.base_url, api_key=ctx_api_key, provider=agent.provider, + config_context_length=effective_context_length, custom_providers=custom_providers, ) agent.context_compressor.update_model( model=agent.model, @@ -2330,9 +2240,7 @@ def _persist_switch_billing_route(agent) -> None: return try: session_db.update_session_billing_route( - session_id, - provider=agent.provider, - base_url=agent.base_url, + session_id, provider=agent.provider, base_url=agent.base_url, billing_mode=getattr(agent, "api_mode", None), ) except Exception: @@ -2340,13 +2248,7 @@ def _persist_switch_billing_route(agent) -> None: def switch_model( - agent, - new_model, - new_provider, - api_key='', - base_url='', - api_mode='', - capabilities=None, + 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). @@ -2418,8 +2320,7 @@ def _pre_tool_block_message(agent, function_name, function_args, effective_task_ 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 "", + 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(middleware_trace), @@ -2441,10 +2342,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i 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, + InlineToolContext, emit_terminal_post_tool_call, resolve_invoke_tool_executor, tool_hook_ids ) if not isinstance(function_args, dict): @@ -2469,15 +2367,9 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i 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, + 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 @@ -2492,12 +2384,9 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i def _execute(next_args: dict) -> Any: result = inline_executor(agent, next_args, inline_ctx) emit_terminal_post_tool_call( - agent, - function_name=function_name, + agent, function_name=function_name, function_args=next_args if isinstance(next_args, dict) else function_args, - result=result, - effective_task_id=effective_task_id, - tool_call_id=tool_call_id, + 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, ) @@ -2505,13 +2394,11 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i else: def _execute(next_args: dict) -> Any: dispatch_kwargs = dict( - tool_call_id=tool_call_id, - session_id=agent.session_id or "", + 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, + 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), @@ -2525,15 +2412,12 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i from hermes_cli.middleware import run_tool_execution_middleware return run_tool_execution_middleware( - function_name, - function_args, + function_name, function_args, lambda next_args: _execute(next_args if isinstance(next_args, dict) else function_args), - original_args=function_args, - **hook_ids, + original_args=function_args, **hook_ids, ) - 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. @@ -2847,15 +2731,11 @@ def _classify_tool_call_orphans(messages: List[Dict[str, Any]]): result_call_ids.update(variants) orphaned_results = [ - msg - for msg, variants in result_entries - if variants and not (variants & surviving_call_ids) + 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 + variants for msg, variants in result_entries if variants and id(msg) not in orphaned_ids ] missing_tool_calls = [ tc @@ -3138,7 +3018,6 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] return _realign_tool_result_names(messages) - _ACK_FUTURE_RE = re.compile(r"\b(i['’]ll|i will|let me|i can do that|i can help with that)\b") _ACK_ACTION_MARKERS = ( "look into", "look at", "inspect", "scan", "check", "analyz", "review", "explore", "read", "open", @@ -3151,10 +3030,7 @@ _ACK_WORKSPACE_MARKERS = ( def looks_like_codex_intermediate_ack( - agent, - user_message: Any, - assistant_content: str, - messages: List[Dict[str, Any]], + 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. @@ -3398,7 +3274,6 @@ def cleanup_dead_connections(agent) -> bool: return False - _QUOTA_RESET_DELAY_RE = re.compile(r"quotaResetDelay[:\s\"]+(\d+(?:\.\d+)?)(ms|s)", re.IGNORECASE) _RESETS_IN_RE = re.compile( r"resets?\s+in\s+" @@ -3474,7 +3349,6 @@ def extract_api_error_context(error: Exception) -> Dict[str, Any]: return context - def _requeue_pending_steer(agent, steer_text: str) -> None: """Put drained steer text back so the caller's fallback delivers it as a next-turn user message.""" lock = getattr(agent, "_pending_steer_lock", None) @@ -3530,7 +3404,6 @@ def apply_pending_steer_to_tool_results(agent, messages: list, num_tool_msgs: in ) - def force_close_tcp_sockets(client: Any) -> int: """Abort in-flight TCP I/O via ``shutdown(SHUT_RDWR)`` WITHOUT closing FDs. @@ -3567,7 +3440,6 @@ def force_close_tcp_sockets(client: Any) -> int: return shutdown_count - __all__ = [ "convert_to_trajectory_format", "sanitize_tool_call_arguments", From ee1ef6acb6b9e28228d825fa2b4d86654c41ea91 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:22:29 -0700 Subject: [PATCH 11/20] refactor(agent/agent_runtime_helpers): collapse small helpers, compact docstrings/comments (WHYs kept, issue refs dropped) --- agent/agent_runtime_helpers.py | 616 +++++++++++---------------------- 1 file changed, 211 insertions(+), 405 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 20ce8367de..695e437457 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1,7 +1,7 @@ -"""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``). +"""Assorted AIAgent runtime helpers (message repair/sanitization, credential recovery, primary +runtime restore, prompt-cache policy, client construction, model switching, tool invocation). +Each function takes the parent ``AIAgent`` as ``agent`` except the stateless message helpers. +``_ra()`` resolves ``run_agent`` lazily so tests patching ``run_agent.X`` keep intercepting. """ from __future__ import annotations @@ -33,8 +33,7 @@ from utils import base_url_host_matches, base_url_hostname, env_var_enabled, ato 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). +# Cap same-entry OAuth refreshes on a persistent auth failure, else a single-entry pool re-mints forever. _MAX_AUTH_REFRESH_ATTEMPTS = 2 @@ -50,8 +49,7 @@ _TOOL_CALL_BLOCK_PATTERNS = tuple( for name in _TOOL_CALL_TAG_NAMES ) -# Named blocks; see strip_think_blocks step 1c for the -# boundary/tempered-dot rationale. +# Named blocks; boundary- and name-gated (see _THINK_STRIP_PATTERNS note). _NAMED_FUNCTION_BLOCK_PATTERN = re.compile( r'(?:(?<=^)|(?<=[\n\r.!?:]))[ \t]*' r']*\bname\s*=[^>]*>' @@ -231,12 +229,9 @@ def sanitize_tool_call_arguments( ) -> 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. + ``cursor["prefix"]`` holds strong refs (not ``id()``: address reuse aliases) to the + messages validated last call; the ``is``-identical prefix is skipped. Safe because only + the surrogate sanitizers mutate live dicts; every other path replaces dicts, breaking identity. """ log = logger or logging.getLogger(__name__) if not isinstance(messages, list): @@ -301,21 +296,18 @@ def sanitize_tool_call_arguments( 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). +# Session-scoped in-flight registry for note_turn_start: the gateway caches agents per routing +# key while the transcript is keyed by session_id, so two agent objects can run concurrent +# turns on one session unseen by per-agent state. _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. - """ + """Tripwire: warn when a turn starts while a previous turn of the same agent or session + (on another agent object) has not finished its persist. Does not prevent the overlap; it + names both turn ids so the dispatch route that bypassed the busy guard is findable in logs. + Returns the previous in-flight turn_id on overlap, else None; takes 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 @@ -361,18 +353,14 @@ def note_turn_start(agent, turn_id: str): 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. - """ + """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, so 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 - ) + 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) @@ -449,11 +437,9 @@ def _merge_consecutive_assistants(messages: List[Dict]) -> Tuple[List[Dict], int def _drop_stray_tool_results(messages: List[Dict]) -> Tuple[List[Dict], int]: - """Pass 1: drop tool results not following a known assistant tool call. - - Consumes 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. - """ + """Pass 1: drop tool results not following a known assistant tool call. Consumes 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.""" repairs = 0 known_tool_ids: Dict[str, int] = {} matched_tool_groups: set = set() @@ -493,11 +479,9 @@ def _drop_stray_tool_results(messages: List[Dict]) -> Tuple[List[Dict], int]: def _prune_unanswered_tool_calls(messages: List[Dict]) -> Tuple[List[Dict], int]: - """Pass 2: prune tool_calls not answered in the IMMEDIATELY following tool run. - - A displaced result masks the per-call stub pass and strict providers 400. - Payload-empty turns are dropped; codex interims exempt. - """ + """Pass 2: prune tool_calls not answered in the IMMEDIATELY following tool run (a displaced + result masks the per-call stub pass and strict providers 400). Payload-empty turns are + dropped; codex interims exempt.""" repairs = 0 pruned: List[Dict] = [] n = len(messages) @@ -580,13 +564,11 @@ _SEQUENCE_REPAIR_PASSES = ( def repair_message_sequence(agent, messages: List[Dict]) -> int: """Collapse malformed role-alternation left in the live history; returns repair count. - 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, in order: merge - consecutive assistant turns (BEFORE orphan detection so the merged tool_call-id - union is known); drop stray tool results; prune tool_calls unanswered in the - immediately following tool run; merge consecutive user messages. A user turn - directly after an assistant turn is valid and left alone. + Providers require strict alternation after the system message (violations: silent empty + responses or 400s); this is the pre-call belt for host-fed, resumed or replayed histories. + Passes in order: merge consecutive assistant turns (BEFORE orphan detection so the merged + tool_call-id union is known); drop stray tool results; prune unanswered tool_calls; merge + consecutive user turns. A user turn directly after an assistant turn is valid and left alone. """ if not messages: return 0 @@ -602,38 +584,24 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int: 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 + """Run :func:`repair_message_sequence` and keep ``_last_flushed_db_idx`` consistent. Repair + shrinks the list in place; counting identity-preserved survivors of the flushed prefix gives + the exact new cursor, whereas a ``min()`` clamp would skip unflushed rows (used only without a snapshot).""" 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]} - + flushed_ids = {id(m) for m in messages[:flush_cursor]} if isinstance(flush_cursor, int) and flush_cursor > 0 else None 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 - ) + if flushed_ids is not None: + agent._last_flushed_db_idx = sum(1 for m in messages if id(m) in flushed_ids) else: - agent._last_flushed_db_idx = min( - agent._last_flushed_db_idx, len(messages) - ) - + agent._last_flushed_db_idx = min(agent._last_flushed_db_idx, len(messages)) return repairs def _flatten_content_text(content: Any) -> str: - """Flatten list/dict content (e.g. Anthropic-via-OpenRouter block lists) to text. - - A raw list hitting ``re.sub`` raises TypeError and the conversation loop retries forever. - Thinking/reasoning blocks are dropped outright; their text key varies per provider. - """ + """Flatten list/dict content (e.g. Anthropic-via-OpenRouter block lists) to text: a raw list + hitting ``re.sub`` raises TypeError and the loop retries forever. Thinking/reasoning blocks + are dropped outright; their text key varies per provider.""" if isinstance(content, str): return content if isinstance(content, list): @@ -666,14 +634,10 @@ _THINK_STRIP_PATTERNS = ( 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, all case-insensitive variants - (think/thinking/reasoning/REASONING_SCRATCHPAD/thought), and standalone tool-call XML - blocks some open models emit; the ```` variant is boundary- and - ``name=``-gated so prose mentions survive. - """ + """Remove reasoning/thinking blocks from content, returning only visible text: closed tag + pairs, unterminated open tags at a block boundary (mirrors ``gateway/stream_consumer.py``), + stray orphan tags (all case-insensitive variants), and standalone tool-call XML blocks some + open models emit; ```` is boundary- and ``name=``-gated so prose mentions survive.""" if not content: return "" content = _flatten_content_text(content) @@ -685,11 +649,9 @@ def strip_think_blocks(agent, content: str) -> str: 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. - """ + """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 = ( @@ -709,11 +671,9 @@ _USAGE_LIMIT_MESSAGE_TOKENS = ("usage limit reached", "usage limit has been reac def _failed_credential_identity(agent, pool) -> Tuple[Optional[str], Optional[str]]: - """``(api_key_hint, credential_id)`` of 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. - """ + """``(api_key_hint, credential_id)`` of 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.""" api_key_hint = getattr(agent, "api_key", None) or None raw_id = getattr(agent, "_credential_pool_entry_id", None) credential_id = raw_id if isinstance(raw_id, str) and raw_id else None @@ -729,11 +689,9 @@ def _failed_credential_identity(agent, pool) -> Tuple[Optional[str], Optional[st def _is_entitlement_403(agent, status_code, error_context) -> bool: - """Entitlement 403s look like auth failures but refresh cannot fix them. - - Any xai-oauth 403 is treated as entitlement EXCEPT xAI's stale-token signals - (``[WKE=unauthenticated:...]``, "could not be validated"), which must stay refreshable. - """ + """Entitlement 403s look like auth failures but refresh cannot fix them. Any xai-oauth 403 + is entitlement EXCEPT xAI's stale-token signals (``[WKE=unauthenticated:...]``, + "could not be validated"), which must stay refreshable.""" if agent._is_entitlement_failure(error_context, status_code): return True if status_code != 403: @@ -837,14 +795,10 @@ def recover_with_credential_pool( 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`` persists an - ambiguous billing verdict so the entry gets a short cooldown, not the one-hour bench. - """ + """Attempt credential recovery via pool rotation; returns (recovered, has_retried_429). + Rate limits: retry once, then rotate. Billing: rotate immediately. Auth: refresh before + rotating. ``classified_reason`` beats raw HTTP codes (e.g. Anthropic 400 "out of extra + usage"); ``billing_unverified`` gives the entry a short cooldown, not the one-hour bench.""" pool = agent._credential_pool if pool is None: return False, has_retried_429 @@ -934,10 +888,8 @@ def recover_with_credential_pool( 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. - """ + """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) @@ -982,10 +934,8 @@ def _rebuild_primary_client(agent, rt: Dict[str, Any], *, reason: str) -> 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. - """ + """Rebuild the primary client once and retry after ``max_retries`` exhaust on a transient + transport error. Skipped for aggregators (OpenRouter, Nous) that manage retries server-side.""" if agent._fallback_activated: return False error_type = type(api_error).__name__ @@ -1037,10 +987,8 @@ def try_recover_primary_transport( def _merge_user_content(prev_content: Any, cur_content: Any) -> Any: - """Merged content for two adjacent user messages; ``_UNMERGEABLE`` for unknown shapes. - - String+string joins with a blank line; list sides append as separate blocks. - """ + """Merged content for two adjacent user messages (``_UNMERGEABLE`` for unknown shapes): + string+string joins with a blank line; list sides append as separate blocks.""" if isinstance(prev_content, str) and isinstance(cur_content, str): return prev_content + ("\n\n" if prev_content and cur_content else "") + cur_content if isinstance(prev_content, list) and isinstance(cur_content, list): @@ -1058,12 +1006,9 @@ _UNMERGEABLE = object() 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``). - """ + """Drop thinking-only assistant turns and merge adjacent user messages left behind, 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.""" if not messages: return messages kept = [ @@ -1097,11 +1042,9 @@ def drop_thinking_only_and_merge_users( def _primary_reset_gate_blocks(agent, rt, primary_provider, primary_runtime_base_url, matches_primary, load_primary_pool): - """Reset-aware gate: skip a guaranteed-to-fail restore while the primary pool reports a future reset. - - Fails open on any error/None. Returns ``(blocked, prefetched_pool, prefetched)`` so the - rebind step can reuse the loaded pool (one auth.json read at most). - """ + """Reset-aware gate: skip a guaranteed-to-fail restore while the primary pool reports a + future reset; fails open on any error/None. Returns ``(blocked, prefetched_pool, prefetched)`` + so the rebind step reuses the loaded pool (one auth.json read at most).""" prefetched_pool = None prefetched = False try: @@ -1142,13 +1085,10 @@ def _restore_runtime_capabilities(agent, rt: Dict[str, Any]) -> None: def _rebind_primary_credential_pool(agent, primary_provider, matches_primary, load_primary_pool, prefetched_pool, prefetched) -> None: - """Rebind and re-select the primary credential pool after a fallback turn. - - 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. - 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. - """ + """Rebind and re-select the primary credential pool after a fallback turn. A cross-provider + fallback attaches its own pool, which would trip the provider-mismatch guard on the next + 401/429: reload the primary pool, else clear it. The snapshot api_key may be stale after + rotation; re-select the pool's best entry, keeping the snapshot key when none is usable.""" 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): @@ -1189,10 +1129,8 @@ def _rebind_primary_credential_pool(agent, primary_provider, matches_primary, lo 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``). - """ + """Restore the primary runtime at the start of a new turn so fallback stays turn-scoped + (long-lived CLI agents and the gateway's cached agents).""" 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. @@ -1299,11 +1237,8 @@ _INLINE_REASONING_PATTERNS = tuple( 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 text from ``reasoning`` / ``reasoning_content`` / ``reasoning_details`` + (OpenRouter unified), else inline thinking blocks in the content; None when absent.""" parts: List[str] = [] def _add(text) -> None: @@ -1412,11 +1347,8 @@ def _direct_native_anthropic_tool_cache_capability( 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. - """ + """True when a ``prompt_caching.cache_ttl`` value means caching off (single predicate shared + by ``agent_init`` and the stub policy paths). Unknown values (``"2h"``, ints) are NOT a disable.""" if ttl in ("5m", "1h"): return False if ttl is False or ttl is None: @@ -1428,41 +1360,31 @@ def cache_ttl_means_disabled(ttl: Any) -> bool: 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 +def _raw_cache_ttl_from_config(default: Any) -> Any: + """Raw ``prompt_caching.cache_ttl`` config value, or ``default`` when config cannot be read.""" + try: + from hermes_cli.config import load_config_readonly - pc_cfg = load_config_readonly().get("prompt_caching", {}) or {} - return pc_cfg.get("cache_ttl", "5m") + return (load_config_readonly().get("prompt_caching", {}) or {}).get("cache_ttl", "5m") + except Exception: + return default 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) + """True when ``prompt_caching.cache_ttl`` is configured as off (same detection as ``agent_init``).""" + return cache_ttl_means_disabled(_raw_cache_ttl_from_config("5m")) 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 + """Configured ``prompt_caching.cache_ttl`` tier (``5m``/``1h``), else None; mirrors + ``agent_init`` so stub paths don't regress a configured ``1h`` to 5m.""" + ttl = _raw_cache_ttl_from_config(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. - """ + """Destination-identity-blank stub for ``anthropic_prompt_cache_policy``; the sole sanctioned + constructor so ``_cache_disabled`` is never omitted (None consults the global config).""" from types import SimpleNamespace if cache_disabled is None: @@ -1477,13 +1399,10 @@ def plan_cache_sections_for_destination( 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. - """ + """Plan request-local cache sections for one resolved destination (MoA / auxiliary senders): + stripped copies (non-caching route) or a ``build_prompt_cache_plan`` layout; never mutates + inputs. ``cache_disabled``/``cache_ttl`` default to live config so the operator's disable and + tier are honored; ``static_system_prefix`` gives the system prompt the main loop's early breakpoint.""" 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, @@ -1514,7 +1433,7 @@ def plan_cache_sections_for_destination( 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_result.content[] → non-retryable 400. tool_part_markers=envelope_tool_part_cache_markers_supported( provider, base_url ), @@ -1523,11 +1442,8 @@ def plan_cache_sections_for_destination( 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. - """ + """True when a route is a LiteLLM proxy: ``litellm`` as a whole delimited token (not + substring) in the provider id or host; a path segment never qualifies.""" return _has_litellm_token(provider_lower, ":-_/") or _has_litellm_token(base_url_hostname(base_url), ".-") @@ -1539,11 +1455,8 @@ def _has_litellm_token(value: str, delimiters: str) -> bool: def _moa_aggregator_cache_policy(agent, eff_model: str) -> tuple[bool, bool]: - """MoA virtual provider: resolve the policy from the preset's real aggregator slot. - - The virtual provider matches no caching branch and would silently lose caching - for the acting aggregator. - """ + """MoA virtual provider: resolve the policy from the preset's real aggregator slot (the + virtual provider matches no caching branch and would silently lose caching).""" try: from hermes_cli.config import load_config as _load_moa_cfg from hermes_cli.moa_config import resolve_moa_preset @@ -1619,11 +1532,10 @@ def anthropic_prompt_cache_policy( ) -> 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. An operator disable is read from ``_cache_disabled`` - (not ``_cache_ttl``, unset during init) so it survives switches and restores. - Branch ORDER is load-bearing (see inline notes). + Native layout puts markers on inner content blocks (Anthropic wire), else on the message + envelope (OpenRouter / OpenAI-wire proxies; Qwen/Alibaba too). The operator disable is read + from ``_cache_disabled`` (not ``_cache_ttl``, unset during init) so it survives switches + and restores. Branch ORDER is load-bearing (see inline notes). """ if getattr(agent, "_cache_disabled", False): return (False, False) @@ -1723,13 +1635,10 @@ def anthropic_prompt_cache_policy( 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. - """ + """Ask the registered ProviderProfile for a custom client, if any. Resolves by provider name, + then by ``base_url`` prefix so a URL-only runtime (``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: @@ -1759,12 +1668,9 @@ def _provider_supplied_client(agent, client_kwargs: dict) -> Any | None: def _profile_for_base_url(base_url: str) -> Any | None: - """Registered profile whose own base_url is a prefix of ``base_url`` (used when the provider name did not resolve). - - 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. - """ + """Registered profile whose own base_url is a prefix of ``base_url`` (provider name did not + resolve). Prefix, not equality: the replaced copilot-acp branch keyed on + ``startswith("acp://copilot")``, so a path or user override under the same root must resolve.""" try: from providers import list_providers candidates = list_providers() @@ -1881,13 +1787,10 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo 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. - + """Re-derive the switched-to provider's ``request_overrides`` (custom_providers ``extra_body``). 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. - """ + ``agent_init._merge_custom_provider_extra_body``) so a different model at the same endpoint + never inherits another's ``extra_body``. Stale ``extra_body`` cleared; ``service_tier``/``speed`` kept.""" from agent.agent_init import _custom_provider_extra_body_for_agent # Prefer the init-time cache (agent._custom_providers); reload only if absent. @@ -1898,12 +1801,12 @@ def _apply_switched_provider_request_overrides(agent, new_provider): 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 [], + 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: @@ -1934,11 +1837,9 @@ _MISSING = object() def _snapshot_switch_state(agent) -> Dict[str, Any]: - """Snapshot every field the swap+rebuild mutates so a failed rebuild rolls back atomically. - - Otherwise a new model name + OLD client 400s next turn. The sentinel distinguishes - unset from None: tests build bare agents via ``__new__`` without all fields. - """ + """Snapshot every field the swap+rebuild mutates so a failed rebuild rolls back atomically + (else a new model name + OLD client 400s next turn). The sentinel distinguishes unset from + None: tests build bare agents via ``__new__`` without all fields.""" snapshot = {name: getattr(agent, name, _MISSING) for name in _SWITCH_SNAPSHOT_FIELDS} # Shallow-copy the dict so mutating the live one doesn't poison the rollback target. snapshot["_client_kwargs"] = dict(getattr(agent, "_client_kwargs", {}) or {}) @@ -2250,12 +2151,10 @@ def _persist_switch_billing_route(agent) -> None: 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. Any failure during the swap/rebuild rolls the agent - back to its pre-switch snapshot and re-raises (callers catch). - """ + """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. A failed swap/rebuild rolls back to the pre-switch + snapshot and re-raises (callers catch).""" old_model = agent.model old_provider = agent.provider old_norm = (old_provider or "").strip().lower() @@ -2336,11 +2235,9 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i 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. - """ + """Invoke a single tool (agent-level or registry-dispatched) and return the result string; + no display logic. 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 ) @@ -2419,11 +2316,9 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i 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. - Returns the repaired name if in valid_tool_names, else None. - """ + """Repair a mismatched tool name (case, separators, CamelCase, ``_tool`` suffixes twice so + ``TodoTool_tool`` reduces fully, then fuzzy match) before aborting. Returns the repaired + name if in valid_tool_names, else None.""" from difflib import get_close_matches if not tool_name: @@ -2477,14 +2372,14 @@ def repair_tool_call(agent, tool_name: str) -> str | None: # 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 +# Escalate repeated heals once per session window, then stay quiet. 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). +# so the user is told ONCE per session (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. @@ -2492,10 +2387,8 @@ _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``. - """ + """True if ``msg`` carries anything the API treats as non-empty content (text, multimodal + blocks, tool_calls, reasoning). Role-agnostic counterpart of ``AIAgent._is_thinking_only_assistant``.""" content = msg.get("content") if isinstance(content, str): if content.strip(): @@ -2525,17 +2418,10 @@ def _msg_has_payload(msg: Dict[str, Any]) -> bool: ) -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"): +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; True when filled. + Pass the per-call copy only; durable history must not be mutated.""" + if is_final or not isinstance(msg, dict) or msg.get("role") not in ("user", "assistant"): return False if _msg_has_payload(msg): return False @@ -2568,22 +2454,15 @@ def _heal_escalation_threshold() -> int: 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. - """ + """Drain the one-time user notice for the current session (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``. - """ + """Read-only per-session sanitiser heal counters (``heal_events``, ``messages_healed``, ``escalated``) for diagnostics.""" with _empty_heal_log_lock: return { k: { @@ -2596,11 +2475,9 @@ def get_sanitizer_heal_stats() -> Dict[str, Dict[str, Any]]: def _log_empty_non_final_heal(healed: int) -> None: - """WARNING on the first heals in a window, one ERROR at the threshold, then silent. - - 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. - """ + """WARNING on the first heals in a window, one ERROR at the threshold, then silent. 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() @@ -2665,12 +2542,9 @@ 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. - """ + 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 untouched.""" if not messages or len(messages) < 2: return messages @@ -2700,16 +2574,11 @@ def repair_empty_non_final_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. - """ + """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 is registered so a result matching any alias survives, and + ``orphaned_results`` are the actual dicts (filter by ``id(msg)``). ``sanitize_api_messages`` + pairs positionally instead but shares the ``*_id_variants`` alias policy.""" assistant_call_variants: List[tuple[Any, frozenset[str]]] = [] surviving_call_ids: set[str] = set() for msg in messages: @@ -2780,11 +2649,9 @@ def _drop_empty_tool_calls_arrays(messages: List[Dict[str, Any]]) -> List[Dict[s def _repair_nameless_tool_calls(messages: List[Dict[str, Any]]) -> None: - """Rename empty/missing ``function.name`` to a sentinel (in place). - - Dropping would unpair the anti-priming result the dispatch loop keeps for - empty-name calls, and Responses adapters 400 on nameless calls. - """ + """Rename empty/missing ``function.name`` to a sentinel (in place): dropping would unpair the + anti-priming result the dispatch loop keeps for empty-name calls, and Responses adapters + 400 on nameless calls.""" sentinel = "invalid_tool_call" for msg in messages: if msg.get("role") != "assistant": @@ -2815,11 +2682,9 @@ def _repair_nameless_tool_calls(messages: List[Dict[str, Any]]) -> None: def _drop_results_without_ids(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """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``. - """ + """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``.""" kept = [ m for m in messages if not (m.get("role") == "tool" and not (m.get("tool_call_id") or "").strip()) @@ -2833,11 +2698,9 @@ def _drop_results_without_ids(messages: List[Dict[str, Any]]) -> List[Dict[str, def _pair_tool_calls_positionally(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Positional tool_call <-> tool_result pairing. - - Strict providers (DeepSeek v4, Kimi) require results IMMEDIATELY after their call: - drop positional orphans, stub unanswered declared ids; matching is alias-aware. - """ + """Positional tool_call <-> tool_result pairing: strict providers (DeepSeek v4, Kimi) require + results IMMEDIATELY after their call. Drops positional orphans, stubs unanswered declared + ids; matching is alias-aware.""" paired: List[Dict[str, Any]] = [] declared_calls: Dict[str, tuple] = {} dropped = 0 @@ -2903,13 +2766,10 @@ def _pair_tool_calls_positionally(messages: List[Dict[str, Any]]) -> List[Dict[s def _dedupe_tool_call_ids(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Deduplicate tool_call_ids (strict providers 400 on duplicates). - - Collapses duplicates within an assistant message and drops results answering - no OUTSTANDING call. Tracks outstanding calls (not ids ever seen) because - llama.cpp reuses one constant id, and the whole variant group so - alias-keyed results are not deleted. - """ + """Deduplicate tool_call_ids (strict providers 400 on duplicates): collapse duplicates within + an assistant message, drop results answering no OUTSTANDING call. Tracks outstanding calls + (not ids ever seen) because llama.cpp reuses one constant id, and whole variant groups so + alias-keyed results are not deleted.""" seen_assistant_call_ids: set = set() outstanding_call_ids: set = set() outstanding_groups: Dict[int, frozenset] = {} @@ -2967,10 +2827,8 @@ def _dedupe_tool_call_ids(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] def _realign_tool_result_names(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Align each tool result's wire ``name`` with its call's function name (per-call copy only). - - Google 400s on a mismatch, which is routine when tool_search bridges via ``tool_call``. - """ + """Align each tool result's wire ``name`` with its call's function name (per-call copy only): + Google 400s on a mismatch, routine when tool_search bridges via ``tool_call``.""" call_names: Dict[str, str] = {} for msg in messages: if msg.get("role") == "assistant": @@ -3003,11 +2861,9 @@ def _realign_tool_result_names(messages: List[Dict[str, Any]]) -> List[Dict[str, 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). - - Order matters: empty non-final messages are healed first so the substituted turn - participates in the pairing and dedup passes. - """ + """Fix orphaned tool_call / tool_result pairs before every LLM call; runs unconditionally (not + gated on the compressor). Order matters: empty non-final messages are healed first so the + substituted turn participates in the pairing and dedup passes.""" messages = _drop_invalid_roles(messages) messages = repair_empty_non_final_messages(messages) messages = _drop_empty_tool_calls_arrays(messages) @@ -3034,11 +2890,8 @@ def looks_like_codex_intermediate_ack( 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. - """ + ``require_workspace=False`` (opt-in for all api_modes) drops the filesystem/repo reference + requirement; 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() @@ -3090,11 +2943,9 @@ _INTENT_ACK_OFF = {"false", "never", "no", "off"} 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. - """ + """Intent-ack continuation mode: ``"off"``, ``"codex_only"`` (workspace acks on codex_responses) + or ``"all"``. Mirrors ``agent.tool_use_enforcement``: ``"auto"`` -> codex_only; true-ish -> 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 _INTENT_ACK_ON): return "all" @@ -3117,13 +2968,10 @@ def copy_reasoning_content_for_api(agent, source_msg: dict, api_msg: dict) -> No 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. - """ + """Re-pad or strip assistant turns' reasoning_content for the CURRENT provider after a + fallback switch: ``api_messages`` is shaped for the primary; require-side providers + (DeepSeek/Kimi/MiMo) 400 without the pad, strict ones (Mistral, Cerebras, Groq) 400/422 + with it. Idempotent; returns the number of assistant turns changed.""" from agent.message_sanitization import reapply_reasoning_echo return reapply_reasoning_echo( @@ -3132,11 +2980,9 @@ def reapply_reasoning_echo_for_provider(agent, api_messages: list) -> int: def _iter_httpx_pool_objects(http_client: Any): - """Yield httpcore pool objects reachable from an httpx client, including mounted transports. - - Keepalive and proxy configs put live connections on ``client._mounts``; walking - only ``_transport`` made ``force_close_tcp_sockets`` miss them. - """ + """Yield httpcore pool objects reachable from an httpx client, including mounted transports: + keepalive and proxy configs put live connections on ``client._mounts``, which a + ``_transport``-only walk misses.""" seen_pools: set[int] = set() def _pools_for_transport(transport: Any): @@ -3163,19 +3009,10 @@ def _iter_httpx_pool_objects(http_client: Any): 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) + while conn is not None and id(conn) not in seen: + seen.add(id(conn)) + yield conn + conn = getattr(conn, "_connection", None) def _socket_from_stream(stream: Any): @@ -3205,13 +3042,10 @@ def _socket_from_stream(stream: Any): 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. - """ + """Yield raw sockets reachable from an OpenAI/httpx client pool. Defensive over private + httpcore internals (``conn._connection``, proxy tunnel wrappers) that vary by release; also + walks mount transports and in-flight ``PoolRequest.connection`` objects (``_connections`` + is empty during checkout).""" try: # Some SDK wrappers *are* the httpx client; fall through so mount-aware discovery runs. http_client = getattr(client, "_client", None) @@ -3362,11 +3196,9 @@ def _requeue_pending_steer(agent, steer_text: str) -> None: 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. - """ + """Append pending /steer text to the last ``role:"tool"`` message of this batch (bounded by + ``num_tool_msgs``), marked as user-origin. Modifies existing content only, so role + alternation is preserved.""" if num_tool_msgs <= 0 or not messages: return steer_text = agent._drain_pending_steer() @@ -3405,24 +3237,17 @@ def apply_pending_steer_to_tool_results(agent, messages: list, num_tool_msgs: in 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). - """ + """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 (once clobbered a SQLite header). ``shutdown()`` + is FD-safe from any thread. Returns the count (logged as ``tcp_force_closed=N``).""" 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. + # Clear a blocking timeout so a hung SSL_read notices the shutdown. Still no close(). settimeout = getattr(sock, "settimeout", None) if callable(settimeout): try: @@ -3431,9 +3256,7 @@ def force_close_tcp_sockets(client: Any) -> int: 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. + pass # already shut down / not connected / FD invalid: all benign shutdown_count += 1 except Exception as exc: _ra().logger.debug("Force-close TCP sockets sweep error: %s", exc) @@ -3441,30 +3264,13 @@ def force_close_tcp_sockets(client: Any) -> int: __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", + "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", ] From e758eb2e0f801144bcf71433b0a1959b0432e3c0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:37:48 -0700 Subject: [PATCH 12/20] refactor(agent/agent_runtime_helpers): contextlib.suppress, dedupe-pass single alias map, pack log calls --- agent/agent_runtime_helpers.py | 229 +++++++++------------------------ 1 file changed, 59 insertions(+), 170 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 695e437457..20395aa34b 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -6,6 +6,7 @@ Each function takes the parent ``AIAgent`` as ``agent`` except the stateless mes from __future__ import annotations +import contextlib import copy import json import logging @@ -53,8 +54,7 @@ _TOOL_CALL_BLOCK_PATTERNS = tuple( _NAMED_FUNCTION_BLOCK_PATTERN = re.compile( r'(?:(?<=^)|(?<=[\n\r.!?:]))[ \t]*' r']*\bname\s*=[^>]*>' - r'(?:(?:(?!).)*)', - re.DOTALL | re.IGNORECASE, + r'(?:(?:(?!).)*)', re.DOTALL | re.IGNORECASE, ) _UNTERMINATED_REASONING_BLOCK_PATTERN = re.compile( @@ -270,12 +270,8 @@ def sanitize_tool_call_arguments( 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, - arguments[:_FULL_ARGS_LOG_BOUND], + "original_arguments=%r)", session_id or "-", message_index, tool_call_id or "-", + function_name, arguments[:_FULL_ARGS_LOG_BOUND], ) function["arguments"] = "{}" existing_tool_msg = _find_tool_result(messages, message_index + 1, tool_call) @@ -317,9 +313,7 @@ def note_turn_start(agent, turn_id: str): 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, + "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 "-", ) @@ -342,11 +336,8 @@ def note_turn_start(agent, turn_id: str): "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, + "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 @@ -536,7 +527,6 @@ def _merge_consecutive_users(messages: List[Dict]) -> Tuple[List[Dict], int]: # 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) prev_content = prev.get("content", "") new_content = msg.get("content", "") @@ -719,8 +709,7 @@ def _recover_auth_failure(agent, pool, *, status_code, has_retried_429, error_co _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", + "not a transient auth failure).", status_code if status_code is not None else "auth", agent.provider or "provider", ) return False, has_retried_429 @@ -745,9 +734,7 @@ def _recover_auth_failure(agent, pool, *, status_code, has_retried_429, error_co _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, + "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', '?')) @@ -860,8 +847,7 @@ def recover_with_credential_pool( if upstream: _ra().logger.info( "Upstream provider %s rate-limited via aggregator — skipping " - "credential rotation, deferring to fallback chain", - upstream, + "credential rotation, deferring to fallback chain", upstream, ) else: _ra().logger.info( @@ -922,7 +908,6 @@ def _rebuild_primary_client(agent, rt: Dict[str, Any], *, reason: str) -> None: # MoA has empty client_kwargs; rebuild via the shared facade factory so the # reference_callback relay survives recovery. 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": @@ -955,17 +940,14 @@ def try_recover_primary_transport( # Never hard-close the shared client here: 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: + with contextlib.suppress(Exception): 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": from agent.moa_loop import build_moa_facade - agent.client = build_moa_facade(agent, agent.model) else: agent.client = agent._create_openai_client( @@ -974,8 +956,7 @@ def try_recover_primary_transport( 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, + f"rebuilt client, waiting {wait_time}s before one last primary attempt.", force=True, ) time.sleep(wait_time) return True @@ -1034,9 +1015,7 @@ def drop_thinking_only_and_merge_users( return messages _ra().logger.debug( "Pre-call sanitizer: dropped %d thinking-only assistant turn(s), " - "merged %d adjacent user message(s)", - dropped, - merges, + "merged %d adjacent user message(s)", dropped, merges, ) return merged @@ -1058,10 +1037,8 @@ def _primary_reset_gate_blocks(agent, rt, primary_provider, primary_runtime_base 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, + "%s/%s until the reset elapses", primary_provider or "?", + datetime.fromtimestamp(next_at).isoformat(timespec="seconds"), agent.provider, agent.model, ) return True, prefetched_pool, prefetched @@ -1077,11 +1054,9 @@ def _restore_runtime_capabilities(agent, rt: Dict[str, Any]) -> None: logger.warning("Ignoring malformed runtime capabilities snapshot") else: agent.runtime_capabilities = dict(raw) - elif "capabilities" in rt: + elif isinstance(rt.get("capabilities"), dict): # Snapshots written by the initial capability propagation patch. - raw = rt["capabilities"] - if isinstance(raw, dict): - agent.runtime_capabilities = dict(raw) + agent.runtime_capabilities = dict(rt["capabilities"]) def _rebind_primary_credential_pool(agent, primary_provider, matches_primary, load_primary_pool, prefetched_pool, prefetched) -> None: @@ -1121,8 +1096,7 @@ def _rebind_primary_credential_pool(agent, primary_provider, matches_primary, lo else: logger.info( "Restore skipped pool entry %s (%s): provider %s does not match primary provider %s", - getattr(entry, "id", "?"), - getattr(entry, "label", "?"), + getattr(entry, "id", "?"), getattr(entry, "label", "?"), str(getattr(entry, "provider", "") or "").strip().lower() or "?", primary_provider or "?", ) @@ -1149,7 +1123,6 @@ def restore_primary_runtime(agent) -> bool: 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 @@ -1208,24 +1181,20 @@ def restore_primary_runtime(agent) -> bool: agent._provider_fallback_active = False agent._provider_fallback_route = None if provider_fallback_active: - try: + # Notification surfaces are best-effort and must never undo a successful restore. + with contextlib.suppress(Exception): 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 restore. - 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 failures worth one more attempt with a rebuilt client / connection pool. _TRANSIENT_TRANSPORT_ERRORS = frozenset({ - "ReadTimeout", "ConnectTimeout", "PoolTimeout", - "ConnectError", "RemoteProtocolError", + "ReadTimeout", "ConnectTimeout", "PoolTimeout", "ConnectError", "RemoteProtocolError", "APIConnectionError", "APITimeoutError", }) @@ -1364,7 +1333,6 @@ def _raw_cache_ttl_from_config(default: Any) -> Any: """Raw ``prompt_caching.cache_ttl`` config value, or ``default`` when config cannot be read.""" try: from hermes_cli.config import load_config_readonly - return (load_config_readonly().get("prompt_caching", {}) or {}).get("cache_ttl", "5m") except Exception: return default @@ -1386,7 +1354,6 @@ def blank_cache_policy_stub(cache_disabled: Optional[bool] = None): """Destination-identity-blank stub for ``anthropic_prompt_cache_policy``; the sole sanctioned constructor so ``_cache_disabled`` is never omitted (None consults the global config).""" from types import SimpleNamespace - if cache_disabled is None: cache_disabled = prompt_caching_disabled_from_config() return SimpleNamespace( @@ -1407,7 +1374,6 @@ def plan_cache_sections_for_destination( 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 @@ -1461,7 +1427,6 @@ def _moa_aggregator_cache_policy(agent, eff_model: str) -> tuple[bool, bool]: 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() @@ -1491,7 +1456,6 @@ def _route_may_be_custom(agent, eff_provider: str, provider_lower: str, eff_base # 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:")) @@ -1513,7 +1477,6 @@ def _route_may_be_custom(agent, eff_provider: str, provider_lower: str, eff_base # 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) @@ -1578,7 +1541,6 @@ def anthropic_prompt_cache_policy( ): 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=getattr(agent, "_custom_providers", None), @@ -1598,7 +1560,6 @@ def anthropic_prompt_cache_policy( ) 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: @@ -1628,7 +1589,6 @@ def anthropic_prompt_cache_policy( # (pi-mono's "alibaba" cacheControlFormat). DeepSeek on OpenCode is excluded: its relay # 400s on block-array content. Family set/predicate shared with the effective_cache_ttl clamp. from agent.prompt_caching import ALIBABA_FAMILY_PROVIDERS, is_qwen_model - if provider_lower in ALIBABA_FAMILY_PROVIDERS and is_qwen_model(model_lower): return True, False return False, False @@ -1661,8 +1621,7 @@ def _provider_supplied_client(agent, client_kwargs: dict) -> Any | None: 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, + getattr(profile, "name", provider_name) or "?", exc_info=True, ) return None @@ -1704,7 +1663,6 @@ def _ensure_copilot_headers(client_kwargs: dict) -> None: def _gemini_native_client(agent, client_kwargs: dict, httpx_verify, *, reason: str, shared: bool): """Native Gemini client when the base_url is the Gemini API, else None.""" from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url - base_url = str(client_kwargs.get("base_url", "") or "") if not is_native_gemini_base_url(base_url): return None @@ -1770,12 +1728,10 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo # Authorization default_header overrides the SDK's "Bearer ". if agent.provider == "opencode-free": from hermes_cli.models import opencode_zen_free_headers - client_kwargs["default_headers"] = {**(client_kwargs.get("default_headers") or {}), **opencode_zen_free_headers()} # 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", "")), @@ -1792,7 +1748,6 @@ def _apply_switched_provider_request_overrides(agent, new_provider): ``agent_init._merge_custom_provider_extra_body``) so a different model at the same endpoint never inherits another's ``extra_body``. Stale ``extra_body`` cleared; ``service_tier``/``speed`` kept.""" 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: @@ -1802,10 +1757,8 @@ def _apply_switched_provider_request_overrides(agent, new_provider): 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 [], + 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 @@ -1850,10 +1803,8 @@ def _restore_switch_snapshot(agent, snapshot: Dict[str, Any]) -> None: for name, value in snapshot.items(): if value is _MISSING: continue # attribute did not exist before the swap; don't fabricate it - try: + with contextlib.suppress(Exception): setattr(agent, name, value) - except Exception: # noqa: BLE001 - pass def _resolve_switch_destination(agent, new_model, new_provider, base_url, api_mode, capabilities, old_norm, new_norm): @@ -1861,7 +1812,6 @@ def _resolve_switch_destination(agent, new_model, new_provider, base_url, api_mo from hermes_cli.providers import determine_api_mode from agent.native_compaction import resolve_native_compaction_capabilities from hermes_cli.models import opencode_provider_family - # 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) @@ -1897,7 +1847,6 @@ def _build_switched_client(agent, new_provider, api_key, base_url, api_mode, new """Build the client for the switched-to destination (MoA facade / native Anthropic / OpenAI wire).""" 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). @@ -1909,7 +1858,6 @@ def _build_switched_client(agent, new_provider, api_key, base_url, api_mode, new return if 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" @@ -1923,8 +1871,7 @@ def _build_switched_client(agent, new_provider, api_key, base_url, api_mode, new 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, + "on switch (%s); using static bearer.", _mm_exc, ) agent.api_key = effective_key agent._anthropic_api_key = effective_key @@ -1946,7 +1893,6 @@ def _build_switched_client(agent, new_provider, api_key, base_url, api_mode, new 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. apply_custom_provider_tls_to_client_kwargs( @@ -2002,8 +1948,7 @@ def _swap_switch_runtime(agent, new_model, new_provider, api_key, base_url, api_ 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, + "continuing without pool rotation this turn", new_provider, _pool_exc, ) _build_switched_client(agent, new_provider, api_key, base_url, api_mode, new_norm) sync_credential_pool_entry_id(agent) @@ -2016,7 +1961,6 @@ def _resolve_switch_context_length(agent, snapshot): from hermes_cli.config import ( get_compatible_custom_providers, get_custom_provider_context_length, load_config ) - custom_providers = get_compatible_custom_providers(load_config()) intent = get_custom_provider_context_length( model=agent.model, base_url=agent.base_url, custom_providers=custom_providers @@ -2075,7 +2019,7 @@ def _update_switch_compressor(agent, custom_providers, effective_context_length, def _build_primary_runtime_snapshot(agent, api_mode) -> Dict[str, Any]: """The ``_primary_runtime`` record that persists a switch across turns.""" - cc = agent.context_compressor if hasattr(agent, "context_compressor") and agent.context_compressor else None + cc = getattr(agent, "context_compressor", None) or None rt = { "model": agent.model, "provider": agent.provider, @@ -2092,12 +2036,12 @@ def _build_primary_runtime_snapshot(agent, api_mode) -> Dict[str, Any]: # resurrects PRE-switch overrides from the stale init snapshot. "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_model": getattr(cc, "model", agent.model), + "compressor_base_url": getattr(cc, "base_url", agent.base_url), + "compressor_api_key": getattr(cc, "api_key", ""), + "compressor_provider": getattr(cc, "provider", 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_api_mode": getattr(cc, "api_mode", agent.api_mode), "compressor_threshold_tokens": cc.threshold_tokens if cc else 0, } if api_mode == "anthropic_messages": @@ -2187,7 +2131,6 @@ def switch_model( try: from hermes_constants import resolve_reasoning_config from hermes_cli.config import load_config as _sm_load_config - agent.reasoning_config = resolve_reasoning_config(_sm_load_config() or {}, agent.model) logger.info( "switch_model: reasoning_config resolved for %s: %s", agent.model, agent.reasoning_config @@ -2241,14 +2184,12 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i 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 = {} hook_ids = tool_hook_ids(agent, effective_task_id, tool_call_id) _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, **hook_ids) function_args = _tool_request_mw.payload @@ -2307,7 +2248,6 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i 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), @@ -2320,7 +2260,6 @@ def repair_tool_call(agent, tool_name: str) -> str | None: ``TodoTool_tool`` reduces fully, then fuzzy match) before aborting. Returns the repaired name if in valid_tool_names, else None.""" from difflib import get_close_matches - if not tool_name: return None # VolcEngine api/plan leaks XML attribute fragments into tool_use.name @@ -2432,7 +2371,6 @@ def fill_empty_non_final_wire_payload(msg: Dict[str, Any], *, is_final: bool) -> 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 "" @@ -2440,16 +2378,11 @@ def _session_id_for_heal_log() -> str: def _heal_escalation_threshold() -> int: """Escalation threshold from ``agent.sanitizer_heal_escalation_threshold``, else the module default (fail-safe on any read error).""" - try: + with contextlib.suppress(Exception): from hermes_cli.config import load_config_readonly - - raw = (load_config_readonly().get("agent", {}) or {}).get( - "sanitizer_heal_escalation_threshold" - ) + 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 @@ -2519,13 +2452,8 @@ def _log_empty_non_final_heal(healed: int) -> None: "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, + "is being repaired on every send; /new drops the poisoned turns.", key, healed, + total_events, total_healed, count, threshold, ) return _ra().logger.warning( @@ -2533,36 +2461,25 @@ def _log_empty_non_final_heal(healed: int) -> None: "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, + "poisoned transcript in memory; no restart needed.", healed, ) -def repair_empty_non_final_messages( - messages: List[Dict[str, Any]], -) -> List[Dict[str, Any]]: +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 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) - ): + # Tool results are checked by their own pairing pass; empty ones are a separate concern. + if idx != last_idx and isinstance(msg, dict) 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) + repaired.append({**msg, "content": _INTERRUPTED_PLACEHOLDER}) healed += 1 else: repaired.append(msg) @@ -2667,16 +2584,13 @@ def _repair_nameless_tool_calls(messages: List[Dict[str, Any]]) -> None: continue _ra().logger.warning( "Pre-call sanitizer: repairing tool_call with empty function.name -> %r (id=%s)", - sentinel, - _ra().AIAgent._get_tool_call_id_static(tc), + sentinel, _ra().AIAgent._get_tool_call_id_static(tc), ) if isinstance(fn, dict): fn["name"] = sentinel elif fn is not None and hasattr(fn, "name"): - try: + with contextlib.suppress(Exception): fn.name = sentinel - except Exception: - pass elif isinstance(tc, dict): tc["function"] = {"name": sentinel, "arguments": "{}"} @@ -2759,8 +2673,7 @@ def _pair_tool_calls_positionally(messages: List[Dict[str, Any]]) -> List[Dict[s if stubs: _ra().logger.debug( "Pre-call sanitizer: added %d stub tool result(s) for " - "positionally unanswered tool call(s)", - stubs, + "positionally unanswered tool call(s)", stubs, ) return paired if (dropped or stubs) else messages @@ -2770,10 +2683,8 @@ def _dedupe_tool_call_ids(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] an assistant message, drop results answering no OUTSTANDING call. Tracks outstanding calls (not ids ever seen) because llama.cpp reuses one constant id, and whole variant groups so alias-keyed results are not deleted.""" - seen_assistant_call_ids: set = set() - outstanding_call_ids: set = set() + outstanding: Dict[str, int] = {} # every alias of an unanswered call -> its group id outstanding_groups: Dict[int, frozenset] = {} - variant_to_group: Dict[str, int] = {} next_group_id = 0 deduped: List[Dict[str, Any]] = [] removed = 0 @@ -2783,7 +2694,7 @@ def _dedupe_tool_call_ids(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] kept_tcs = [] for tc in msg.get("tool_calls") or []: variants = tool_call_id_variants(tc) - if variants and variants & seen_assistant_call_ids: + if variants and variants & outstanding.keys(): removed += 1 continue if variants: @@ -2791,9 +2702,7 @@ def _dedupe_tool_call_ids(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] 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) + outstanding.setdefault(variant, group_id) kept_tcs.append(tc) if kept_tcs: msg = {**msg, "tool_calls": kept_tcs} @@ -2801,11 +2710,7 @@ def _dedupe_tool_call_ids(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] msg = {k: v for k, v in msg.items() if k != "tool_calls"} 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 - } + candidate_groups = {outstanding[v] for v in result_variants if v in outstanding} if result_variants and not candidate_groups: removed += 1 continue @@ -2813,10 +2718,8 @@ def _dedupe_tool_call_ids(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] # Consume EVERY variant of the matched call; ids are re-armed by the next call reusing them. group_id = min(candidate_groups) for variant in outstanding_groups.pop(group_id, frozenset()): - 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) + if outstanding.get(variant) == group_id: + del outstanding[variant] deduped.append(msg) if not removed: return messages @@ -2853,8 +2756,7 @@ def _realign_tool_result_names(messages: List[Dict[str, Any]]) -> List[Dict[str, return messages _ra().logger.debug( "Pre-call sanitizer: realigned %d tool result name(s) with their " - "tool_call function name (%s)", - len(realigned), + "tool_call function name (%s)", len(realigned), ", ".join(f"{was} -> {now}" for was, now in realigned), ) return aligned @@ -2907,7 +2809,6 @@ def looks_like_codex_intermediate_ack( # ``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() return ( any(marker in user_text for marker in _ACK_WORKSPACE_MARKERS) @@ -2922,8 +2823,7 @@ def looks_like_codex_intermediate_ack( _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, + r"[^.!?\n]{0,100}[.:\u2026]?\s*$", re.IGNORECASE, ) # Content longer than this is a substantive reply, not a dangling ack. @@ -2961,7 +2861,6 @@ def intent_ack_continuation_mode(agent) -> str: 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() ) @@ -2973,7 +2872,6 @@ def reapply_reasoning_echo_for_provider(agent, api_messages: list) -> int: (DeepSeek/Kimi/MiMo) 400 without the pad, strict ones (Mistral, Cerebras, Groq) 400/422 with it. 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() ) @@ -3086,10 +2984,8 @@ def _socket_is_dead(sock) -> bool: except OSError: return True finally: - try: + with contextlib.suppress(OSError): sock.setblocking(True) - except OSError: - pass def cleanup_dead_connections(agent) -> bool: @@ -3113,8 +3009,7 @@ _RESETS_IN_RE = re.compile( 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)?", - re.IGNORECASE, + r"(?:(\d+(?:\.\d+)?)\s*(?:s|sec|secs|second|seconds)\b)?", re.IGNORECASE, ) _RETRY_AFTER_SECONDS_RE = re.compile(r"retry\s+(?:after\s+)?(\d+(?:\.\d+)?)\s*(?:sec|secs|seconds|s\b)", re.IGNORECASE) @@ -3135,10 +3030,8 @@ def _reset_delay_from_message(message: str) -> Optional[float]: def _set_reset_from_retry_after(context: Dict[str, Any], retry_after: Any) -> None: if retry_after in {None, ""} or "reset_at" in context: return - try: + with contextlib.suppress(TypeError, ValueError): context["reset_at"] = time.time() + float(retry_after) - except (TypeError, ValueError): - pass def extract_api_error_context(error: Exception) -> Dict[str, Any]: @@ -3230,8 +3123,7 @@ def apply_pending_steer_to_tool_results(agent, messages: list, num_tool_msgs: in # Fall back to string replacement if content shape is unexpected. messages[target_idx]["content"] = f"{existing_content}{marker}" _ra().logger.info( - "Delivered /steer to agent after tool batch (%d chars): %s", - len(steer_text), + "Delivered /steer to agent after tool batch (%d chars): %s", len(steer_text), steer_text[:120] + ("..." if len(steer_text) > 120 else ""), ) @@ -3242,7 +3134,6 @@ def force_close_tcp_sockets(client: Any) -> int: flushed TLS record lands in the wrong file (once clobbered a SQLite header). ``shutdown()`` is FD-safe from any thread. Returns the count (logged as ``tcp_force_closed=N``).""" import socket as _socket - shutdown_count = 0 try: for sock in _iter_pool_sockets(client): @@ -3250,10 +3141,8 @@ def force_close_tcp_sockets(client: Any) -> int: # Clear a blocking timeout so a hung SSL_read notices the shutdown. Still no close(). settimeout = getattr(sock, "settimeout", None) if callable(settimeout): - try: + with contextlib.suppress(OSError): settimeout(0) - except OSError: - pass sock.shutdown(_socket.SHUT_RDWR) except OSError: pass # already shut down / not connected / FD invalid: all benign From d1b0553203e0aca55495b1109bd10aa52a09d6a4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:41:28 -0700 Subject: [PATCH 13/20] refactor(agent/agent_runtime_helpers): compact comments and intra-function blank lines --- agent/agent_runtime_helpers.py | 347 ++++++++++++++------------------- 1 file changed, 149 insertions(+), 198 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 20395aa34b..f2b1ddccab 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -5,7 +5,6 @@ Each function takes the parent ``AIAgent`` as ``agent`` except the stateless mes """ from __future__ import annotations - import contextlib import copy import json @@ -16,7 +15,6 @@ 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 @@ -30,21 +28,15 @@ from agent.credential_pool import ( 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 same-entry OAuth refreshes on a persistent auth failure, else a single-entry pool re-mints forever. _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 @@ -56,15 +48,12 @@ _NAMED_FUNCTION_BLOCK_PATTERN = re.compile( 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 ) @@ -76,10 +65,10 @@ def _ra(): 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"} -) - +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", +}) _TRAJECTORY_SYSTEM_PROMPT = ( "You are a function calling AI model. You are provided with function signatures within XML tags. " @@ -157,8 +146,8 @@ def _trajectory_tool_responses(msg: Dict[str, Any], messages: List[Dict[str, Any 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. + # 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 = [ {"from": "system", "value": _TRAJECTORY_SYSTEM_PROMPT.format(tools=agent._format_tools_for_system_message())}, @@ -228,7 +217,6 @@ 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["prefix"]`` holds strong refs (not ``id()``: address reuse aliases) to the messages validated last call; the ``is``-identical prefix is skipped. Safe because only the surrogate sanitizers mutate live dicts; every other path replaces dicts, breaking identity. @@ -261,12 +249,12 @@ def sanitize_tool_call_arguments( continue except json.JSONDecodeError: pass - # 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. + # 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. 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. + # 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. log.warning( "Corrupted tool_call arguments repaired before request " "(session=%s, message_index=%s, tool_call_id=%s, function=%s, " @@ -286,15 +274,15 @@ def sanitize_tool_call_arguments( 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. + # 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, so two agent objects can run concurrent -# turns on one session unseen by per-agent state. +# Session-scoped in-flight registry for note_turn_start: the gateway caches agents per routing key +# while the transcript is keyed by session_id, so two agent objects can run concurrent turns on one +# session unseen by per-agent state. _INFLIGHT_TURNS_BY_SESSION: Dict[str, Tuple[str, float]] = {} _INFLIGHT_TURNS_LOCK = threading.Lock() @@ -318,18 +306,17 @@ def note_turn_start(agent, turn_id: str): 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). + # 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. + # 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( @@ -348,8 +335,8 @@ def note_turn_persisted(agent): design: on a real overlap the first persist clears the second slot, so 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). + # 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: @@ -376,8 +363,8 @@ def _merge_assistant_into(prev: Dict, msg: Dict) -> None: 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. + # Drop a stale ``tool_calls: []`` at the source: strict providers (DeepSeek v4, Kimi) 400 on + # it and it persists into replayed history. prev.pop("tool_calls", None) # Concatenate plain-text content only; leave multimodal (list) content alone. prev_content = prev.get("content") @@ -391,12 +378,12 @@ def _merge_assistant_into(prev: Dict, msg: Dict) -> None: 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). + # 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. + # A stale ``api_content`` sidecar overrides ``content`` at API-build time and would replay + # pre-merge bytes; drop it only when content actually changed. if content_rewritten: drop_stale_api_content(prev) @@ -553,7 +540,6 @@ _SEQUENCE_REPAIR_PASSES = ( def repair_message_sequence(agent, messages: List[Dict]) -> int: """Collapse malformed role-alternation left in the live history; returns repair count. - Providers require strict alternation after the system message (violations: silent empty responses or 400s); this is the pre-call belt for host-fed, resumed or replayed histories. Passes in order: merge consecutive assistant turns (BEFORE orphan detection so the merged @@ -611,11 +597,11 @@ def _flatten_content_text(content: Any) -> str: return str(content) -# Order matters: closed pairs first (case-insensitive so mixed-case tags don't fall through -# to the unterminated pass and eat trailing content), then tool-call XML blocks, the -# boundary+name-gated block, the unterminated reasoning block, stray orphan -# reasoning tags, and finally stray tool-call CLOSERS only (bare/unterminated -# is kept: a truncated streaming tail may still be valuable, matching OpenClaw's asymmetry). +# Order matters: closed pairs first (case-insensitive so mixed-case tags don't fall through to the +# unterminated pass and eat trailing content), then tool-call XML blocks, the boundary+name-gated +# block, the unterminated reasoning block, stray orphan reasoning tags, and finally stray +# tool-call CLOSERS only (bare/unterminated is kept: a truncated streaming tail may still +# be valuable, matching OpenClaw's asymmetry). _THINK_STRIP_PATTERNS = ( *_REASONING_BLOCK_PATTERNS, *_TOOL_CALL_BLOCK_PATTERNS, _NAMED_FUNCTION_BLOCK_PATTERN, _UNTERMINATED_REASONING_BLOCK_PATTERN, _ORPHAN_REASONING_TAG_PATTERN, @@ -655,7 +641,6 @@ _STATUS_TO_FAILOVER_REASON = { 402: FailoverReason.billing, 429: FailoverReason.rate_limit, 401: FailoverReason.auth, 403: FailoverReason.auth, } - _USAGE_LIMIT_REASON_TOKENS = ("usage_limit_reached", "gousagelimit") _USAGE_LIMIT_MESSAGE_TOKENS = ("usage limit reached", "usage limit has been reached") @@ -713,15 +698,15 @@ def _recover_auth_failure(agent, pool, *, status_code, has_retried_429, error_co 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 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. + # try_refresh_matching() reports success even when upstream keeps rejecting; cap same-entry + # refreshes so a single-entry pool falls through to fallback. refreshed_id = getattr(refreshed, "id", None) if refreshed_id is not None: refresh_counts = getattr(agent, "_auth_pool_refresh_counts", None) @@ -747,8 +732,8 @@ def _recover_auth_failure(agent, pool, *, status_code, has_retried_429, error_co def _recover_rate_limit(pool, *, has_retried_429, error_context, api_key_hint, credential_id, rotate_and_swap): - # Already-exhausted credential: rotate immediately. Avoids the "cancel-between-429s" - # trap where the local has_retried_429 resets per prompt and retries forever. + # 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) @@ -789,10 +774,9 @@ def recover_with_credential_pool( 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 and reset base_url to the primary endpoint. Empty pool provider means - # unscoped; empty agent provider is a mismatch (swap would leave provider="" model=""). + # The pool belongs to the PRIMARY provider: acting on fallback errors would corrupt its state + # and reset base_url to the primary endpoint. Empty pool provider means unscoped; empty agent + # provider is a mismatch (swap would leave provider="" model=""). current_provider = (getattr(agent, "provider", "") or "").strip().lower() pool_provider = (getattr(pool, "provider", "") or "").strip().lower() if pool_provider and not credential_pool_matches_provider( @@ -805,7 +789,6 @@ def recover_with_credential_pool( pool_provider, current_provider, ) return False, has_retried_429 - api_key_hint, credential_id = _failed_credential_identity(agent, pool) effective_reason = classified_reason if effective_reason is None: @@ -821,8 +804,8 @@ def recover_with_credential_pool( } 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. + # Pass classified semantics, not just the status: a billing 403 and an edge-throttle 403 + # need opposite cooldowns. if effective_reason is not None: failure_reason = effective_reason.value if effective_reason == FailoverReason.billing and billing_unverified: @@ -839,10 +822,9 @@ def recover_with_credential_pool( ) agent._swap_credential(next_entry) return True - 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 (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( @@ -856,8 +838,8 @@ def recover_with_credential_pool( ) 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. + # A separate pool instance may have resolved runtime credentials, leaving no ``current_id``; + # match the key that failed, not a different account. return (True, False) if _rotate_and_swap(402, "billing") else (False, has_retried_429) if effective_reason == FailoverReason.rate_limit: return _recover_rate_limit( @@ -929,16 +911,16 @@ def try_recover_primary_transport( 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. + # 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: stale streaming workers may still be - # unwinding on the old pool; _retire_shared_openai_client defers FD release to GC. + # Never hard-close the shared client here: 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: with contextlib.suppress(Exception): agent._retire_shared_openai_client(agent.client, reason="primary_recovery") @@ -1008,8 +990,8 @@ def drop_thinking_only_and_merge_users( merged[-1] = {**prev, "content": content} merges += 1 continue - # Unknown content shape: append separately (violates alternation, but safer - # than raising in a hot path). + # Unknown content shape: append separately (violates alternation, but safer than raising + # in a hot path). merged.append(m) if dropped == 0 and merges == 0: return messages @@ -1112,7 +1094,6 @@ def restore_primary_runtime(agent) -> bool: return False if getattr(agent, "_rate_limited_until", 0) > time.monotonic(): return False # primary still in rate-limit cooldown, stay on fallback - 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 "") @@ -1126,14 +1107,12 @@ def restore_primary_runtime(agent) -> bool: 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 - blocked, prefetched_pool, prefetched = _primary_reset_gate_blocks( agent, rt, primary_provider, primary_runtime_base_url, _matches_primary, _load_primary_pool ) if blocked: return False 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") @@ -1174,8 +1153,8 @@ def restore_primary_runtime(agent) -> bool: # Reset the stale-call circuit breaker: its streak measured the fallback provider. from agent.chat_completion_helpers import _reset_stale_streak, rewrite_prompt_model_identity _reset_stale_streak(agent) - # Undo the fallback's identity rewrite so the prompt is byte-identical to the - # stored copy again (prefix cache match). + # Undo the fallback's identity rewrite so the prompt is byte-identical to the stored copy + # again (prefix cache match). 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 @@ -1192,13 +1171,12 @@ def restore_primary_runtime(agent) -> bool: logger.warning("Failed to restore primary runtime: %s", e) return False + # Transient transport failures worth one more attempt with a rebuilt client / connection pool. _TRANSIENT_TRANSPORT_ERRORS = frozenset({ "ReadTimeout", "ConnectTimeout", "PoolTimeout", "ConnectError", "RemoteProtocolError", "APIConnectionError", "APITimeoutError", }) - - _INLINE_REASONING_PATTERNS = tuple( re.compile(rf"<{tag}>(.*?)", re.DOTALL | re.IGNORECASE) for tag in ("think", "thinking", "thought", "reasoning", "REASONING_SCRATCHPAD") @@ -1213,7 +1191,6 @@ def extract_reasoning(agent, assistant_message) -> Optional[str]: def _add(text) -> None: if text and text not in parts: parts.append(text) - _add(getattr(assistant_message, "reasoning", None)) _add(getattr(assistant_message, "reasoning_content", None)) # reasoning_details: [{"type": "reasoning.summary", "summary": "...", ...}, ...] @@ -1223,8 +1200,8 @@ def extract_reasoning(agent, assistant_message) -> Optional[str]: # Fall back to reasoning embedded in content only when no structured field was found. content = getattr(assistant_message, "content", None) if not 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". + # DeepSeek V4 Pro returns typed content blocks ({"type": "thinking", ...}); dropping them + # makes the next turn fail with HTTP 400 "thinking must be passed back". for block in content: if isinstance(block, dict) and block.get("type") == "thinking": _add((block.get("thinking") or block.get("text") or "").strip()) @@ -1282,8 +1259,8 @@ def dump_api_request_debug( if error is not None: dump_payload["error"] = _api_error_debug_info(error) 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. + # 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 @@ -1398,8 +1375,8 @@ def plan_cache_sections_for_destination( 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. + # LiteLLM-style envelope routes forward part-level markers into tool_result.content[] → + # non-retryable 400. tool_part_markers=envelope_tool_part_cache_markers_supported( provider, base_url ), @@ -1473,12 +1450,12 @@ def _route_may_be_custom(agent, eff_provider: str, provider_lower: str, eff_base return False if custom_providers is not None: return False # attached empty list never matches - # None = list not attached yet (early init or blank stub). Avoid rebuilding the - # list for ordinary built-in routes. + # None = list not attached yet (early init or blank stub). 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. + # 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) return provider_def is None or ( bool(provider_def.base_url) @@ -1494,7 +1471,6 @@ def anthropic_prompt_cache_policy( 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)``. - Native layout puts markers on inner content blocks (Anthropic wire), else on the message envelope (OpenRouter / OpenAI-wire proxies; Qwen/Alibaba too). The operator disable is read from ``_cache_disabled`` (not ``_cache_ttl``, unset during init) so it survives switches @@ -1502,22 +1478,20 @@ def anthropic_prompt_cache_policy( """ 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 "" if eff_provider.strip().lower() == "moa": return _moa_aggregator_cache_policy(agent, eff_model) - 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 it serves ~1% cache hits. Family matcher covers bare k1./k2. slugs. + # Kimi/Moonshot via OpenRouter uses the same envelope cache_control as Claude; without this it + # serves ~1% cache hits. 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") @@ -1527,9 +1501,8 @@ def anthropic_prompt_cache_policy( 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. + # Honor a configured route's per-model ``prompt_caching`` capability (explicit false too); only + # for the two transports this planner handles, not Responses/Bedrock. supports_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) @@ -1550,9 +1523,9 @@ def anthropic_prompt_cache_policy( return custom_prompt_caching, custom_prompt_caching and is_anthropic_wire except Exception as _cap_exc: logger.debug("custom-provider prompt_caching capability lookup failed: %s", _cap_exc) - # 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. + # Checked BEFORE the native-Anthropic return since provider="anthropic" may point at a MiniMax + # proxy. is_minimax_route = ( provider_lower in {"minimax", "minimax-cn"} or base_url_host_matches(eff_base_url, "api.minimax.io") @@ -1564,30 +1537,29 @@ def anthropic_prompt_cache_policy( 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. + # 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. + # 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: return True, True # third-party Anthropic-compatible gateway - # LiteLLM fronting Claude on the OpenAI wire supports cache_control but matched no grant - # above. Claude-only: strict relays reject the block format for other models. Envelope - # layout: native top-level markers are only relocated by the anthropic_messages adapter - # and 400 via LiteLLM. Gated on chat_completions; codex_responses/bedrock_converse have - # their own handling. + # LiteLLM fronting Claude on the OpenAI wire supports cache_control but matched no grant above. + # Claude-only: strict relays reject the block format for other models. Envelope layout: native + # top-level markers are only relocated by the anthropic_messages adapter and 400 via LiteLLM. + # Gated on chat_completions; 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. + # 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 - # (pi-mono's "alibaba" cacheControlFormat). DeepSeek on OpenCode is excluded: its relay - # 400s on block-array content. Family set/predicate shared with the effective_cache_ttl clamp. + # (pi-mono's "alibaba" cacheControlFormat). DeepSeek on OpenCode is excluded: its relay 400s on + # block-array content. Family set/predicate shared with the effective_cache_ttl clamp. from agent.prompt_caching import ALIBABA_FAMILY_PROVIDERS, is_qwen_model if provider_lower in ALIBABA_FAMILY_PROVIDERS and is_qwen_model(model_lower): return True, False @@ -1684,11 +1656,11 @@ def _gemini_native_client(agent, client_kwargs: dict, httpx_verify, *, reason: s 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 (a torn-down httpx transport got reused). + # Treat client_kwargs as read-only: callers pass agent._client_kwargs, and in-place mutation + # leaks into later requests (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 (TypeError; relay re-wire). + # The MoA virtual provider has no OpenAI wire endpoint; the facade *is* the client. Rebuild the + # facade, never a native client (TypeError; 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") @@ -1715,7 +1687,8 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo return client # TCP keepalives so dead provider connections are detected (~60s) instead of hanging in # CLOSE-WAIT. Injected into the local copy only, so each client gets its own httpx.Client; - # pinned by tests/run_agent/test_create_openai_client_reuse.py and test_sequential_chats_live.py. + # pinned by tests/run_agent/test_create_openai_client_reuse.py and + # 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: @@ -1767,24 +1740,12 @@ def _apply_switched_provider_request_overrides(agent, new_provider): agent.request_overrides = overrides +# Pool reload is part of the switch and must be reversible on rollback, hence the pool fields. _SWITCH_SNAPSHOT_FIELDS = ( - "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", - # Pool reload is part of the switch and must be reversible on rollback. - "_credential_pool", - "_credential_pool_entry_id", + "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", + "_credential_pool", "_credential_pool_entry_id", ) _MISSING = object() @@ -1818,8 +1779,8 @@ def _resolve_switch_destination(agent, new_model, new_provider, base_url, api_mo 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. + # 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", "") @@ -1831,8 +1792,8 @@ def _resolve_switch_destination(agent, new_model, new_provider, base_url, api_mo 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. + # 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. if ( api_mode == "anthropic_messages" and opencode_provider_family(new_provider) is not None @@ -1847,8 +1808,8 @@ def _build_switched_client(agent, new_provider, api_key, base_url, api_mode, new """Build the client for the switched-to destination (MoA facade / native Anthropic / OpenAI wire).""" 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 + # 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" @@ -1858,12 +1819,12 @@ def _build_switched_client(agent, new_provider, api_key, base_url, api_mode, new return if 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. + # 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() if is_native_anthropic else "") or "" - # MiniMax OAuth: per-request callable token provider survives 15-min expiry - # (rationale in agent_init.py). + # 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 @@ -1893,8 +1854,8 @@ def _build_switched_client(agent, new_provider, api_key, base_url, api_mode, new 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. + # Read live config, not agent._custom_providers, so mid-session ssl_ca_cert / ssl_verify + # edits are honored. apply_custom_provider_tls_to_client_kwargs( agent._client_kwargs, str(effective_base or ""), get_compatible_custom_providers(load_config_readonly()), @@ -1904,8 +1865,8 @@ def _build_switched_client(agent, new_provider, api_key, base_url, api_mode, new timeout = get_provider_request_timeout(agent.provider, agent.model) if timeout is not None: agent._client_kwargs["timeout"] = timeout - # Reapply provider headers (OpenRouter HTTP-Referer/X-Title) lost when - # _client_kwargs was rebuilt; otherwise attribution shows "Unknown". + # 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) @@ -1919,9 +1880,9 @@ def _swap_switch_runtime(agent, new_model, new_provider, api_key, base_url, api_ 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. - # Fail loud. Same-provider re-select (credential refresh) may keep the URL. + # 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. 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: @@ -1936,8 +1897,8 @@ def _swap_switch_runtime(agent, new_model, new_provider, api_key, base_url, api_ agent._transport_cache.clear() if api_key: agent.api_key = api_key - # Reload the credential pool on provider change: a pool with a mismatched provider - # makes recover_with_credential_pool short-circuit. Reload failure is non-fatal. + # Reload the credential pool on provider change: 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 @@ -1996,8 +1957,8 @@ def _update_switch_compressor(agent, custom_providers, effective_context_length, custom_providers = get_compatible_custom_providers(load_config()) except Exception: 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. + # 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( @@ -2032,8 +1993,8 @@ def _build_primary_runtime_snapshot(agent, api_mode) -> Dict[str, Any]: "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. + # Overrides must travel with the switched-to identity or a later recovery/restore resurrects + # PRE-switch overrides from the stale init snapshot. "request_overrides": dict(getattr(agent, "request_overrides", {}) or {}), "runtime_capabilities": dict(getattr(agent, "runtime_capabilities", {}) or {}), "compressor_model": getattr(cc, "model", agent.model), @@ -2114,10 +2075,9 @@ def switch_model( except Exception: _restore_switch_snapshot(agent, snapshot) raise - custom_providers, effective_context_length = _resolve_switch_context_length(agent, snapshot) - # Refresh the custom-provider snapshot from the config just loaded so the prompt_caching - # lookup sees flags added to config.yaml after session start. + # 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 custom_providers is not None: agent._custom_providers = custom_providers agent._use_prompt_caching, agent._use_native_cache_layout = agent._anthropic_prompt_cache_policy( @@ -2125,9 +2085,8 @@ def switch_model( ) if hasattr(agent, "context_compressor") and agent.context_compressor: _update_switch_compressor(agent, custom_providers, effective_context_length, snapshot) - - # Re-read the per-model reasoning_effort override so it applies immediately - # (per-model > global; YAML False = disabled). + # 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 @@ -2137,11 +2096,10 @@ def switch_model( ) 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. + # 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; otherwise the latched streak keeps # short-circuiting the freshly selected healthy provider. @@ -2196,7 +2154,6 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i _tool_middleware_trace = _tool_request_mw.trace except Exception as _mw_err: logger.debug("tool_request middleware error: %s", _mw_err) - block_message: Optional[str] = None if not pre_tool_block_checked: block_message, function_args = _pre_tool_block_message( @@ -2211,7 +2168,6 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i middleware_trace=_tool_middleware_trace, ) return result - tool_start_time = time.monotonic() inline_executor = resolve_invoke_tool_executor(agent, function_name) if inline_executor is not None: @@ -2244,7 +2200,6 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i 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 @@ -2262,9 +2217,9 @@ def repair_tool_call(agent, tool_name: str) -> str | None: from difflib import get_close_matches if not tool_name: return None - # VolcEngine api/plan 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``. + # VolcEngine api/plan 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``. for _xml_sep in ('"', "'", "<", ">"): _idx = tool_name.find(_xml_sep) if _idx > 0: @@ -2284,7 +2239,6 @@ def repair_tool_call(agent, tool_name: str) -> str | None: 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: @@ -2307,19 +2261,21 @@ def repair_tool_call(agent, tool_name: str) -> str | None: return matches[0] if matches else None -# 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. +# 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. Default -# threshold; tunable via ``agent.sanitizer_heal_escalation_threshold`` (<= 0 disables). +# Escalate repeated heals once per session window, then stay quiet. 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 (out-of-band, never in conversation context). + +# Sessions already given the one-time user notice; separate from the windowed log state so the user +# is told ONCE per session (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] = {} @@ -2344,9 +2300,9 @@ def _msg_has_payload(msg: Dict[str, Any]) -> bool: return True elif content not in (None, ""): return True - # Structural payloads that make an "empty-content" message still valid. Codex Responses - # item carriers persist with content:"" by design (text lives in codex_*_items and is - # replayed); treating them as payload keeps the repair from rewriting a designed-empty turn. + # Structural payloads that make an "empty-content" message still valid. Codex Responses item + # carriers persist with content:"" by design (text lives in codex_*_items and is replayed); + # treating them as payload keeps the repair from rewriting a designed-empty turn. return bool( msg.get("tool_calls") or (isinstance(msg.get("reasoning_content"), str) and msg["reasoning_content"].strip()) @@ -2483,7 +2439,6 @@ def repair_empty_non_final_messages(messages: List[Dict[str, Any]]) -> List[Dict healed += 1 else: repaired.append(msg) - if healed: _log_empty_non_final_heal(healed) return repaired @@ -2506,7 +2461,6 @@ def _classify_tool_call_orphans(messages: List[Dict[str, Any]]): 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 @@ -2515,7 +2469,6 @@ def _classify_tool_call_orphans(messages: List[Dict[str, Any]]): 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) ] @@ -2632,19 +2585,18 @@ def _pair_tool_calls_positionally(messages: List[Dict[str, Any]]) -> List[Dict[s }) 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. + # 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. + # 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": @@ -2806,8 +2758,8 @@ def looks_like_codex_intermediate_ack( # 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. + # ``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() return ( @@ -2818,8 +2770,8 @@ def looks_like_codex_intermediate_ack( ) -# 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. +# 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)" @@ -2886,15 +2838,14 @@ def _iter_httpx_pool_objects(http_client: Any): 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. + # 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 None and getattr(transport, "_connections", None) is not None: pool = transport if pool is not None and id(pool) not in seen_pools: seen_pools.add(id(pool)) yield pool - try: yield from _pools_for_transport(getattr(http_client, "_transport", None)) mounts = getattr(http_client, "_mounts", None) or {} From f9d27dfcd03d052e21d77bc5e59e560a1ce5f624 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:47:44 -0700 Subject: [PATCH 14/20] refactor(agent/agent_runtime_helpers): flatten auth recovery, socket walk, payload predicate, heal-log state --- agent/agent_runtime_helpers.py | 192 +++++++++++++-------------------- 1 file changed, 76 insertions(+), 116 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index f2b1ddccab..77b0e915c9 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -704,31 +704,28 @@ def _recover_auth_failure(agent, pool, *, status_code, has_retried_429, error_co 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. - 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 + if refreshed is None: + # Refresh failed; rotate (the failed entry is already marked exhausted). + return (True, False) if rotate_and_swap(401, "auth refresh failed") else (False, has_retried_429) + # try_refresh_matching() reports success even when upstream keeps rejecting; cap same-entry + # refreshes so a single-entry pool falls through to fallback. + refreshed_id = getattr(refreshed, "id", None) + if refreshed_id is not None: + if getattr(agent, "_auth_pool_refresh_counts", None) is None: + agent._auth_pool_refresh_counts = {} + refresh_counts = agent._auth_pool_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 def _recover_rate_limit(pool, *, has_retried_429, error_context, api_key_hint, credential_id, rotate_and_swap): @@ -1214,10 +1211,10 @@ def extract_reasoning(agent, assistant_message) -> Optional[str]: def _api_error_debug_info(error: Exception) -> Dict[str, Any]: info: Dict[str, Any] = {"type": type(error).__name__, "message": str(error)} - for attr_name in ("status_code", "request_id", "code", "param", "type", "body"): - attr_value = getattr(error, attr_name, None) - if attr_value is not None: - info[attr_name] = attr_value + info.update({ + k: v for k in ("status_code", "request_id", "code", "param", "type", "body") + if (v := getattr(error, k, None)) is not None + }) response_obj = getattr(error, "response", None) if response_obj is not None: try: @@ -1377,9 +1374,7 @@ def plan_cache_sections_for_destination( ), # LiteLLM-style envelope routes forward part-level markers into tool_result.content[] → # non-retryable 400. - tool_part_markers=envelope_tool_part_cache_markers_supported( - provider, base_url - ), + tool_part_markers=envelope_tool_part_cache_markers_supported(provider, base_url), ) return plan.messages, plan.tools @@ -2281,29 +2276,25 @@ _empty_heal_user_notified: set = set() _empty_heal_pending_notice: Dict[str, str] = {} +def _content_has_payload(content: Any) -> bool: + if isinstance(content, str): + return bool(content.strip()) + if isinstance(content, list): + # Any typed block counts, as long as a text block is not itself blank. + return any( + (block.get("type") != "text" or (isinstance(block.get("text"), str) and block["text"].strip())) + if isinstance(block, dict) else bool(block) + for block in content + ) + return content not in (None, "") + + 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, 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": - return True - if isinstance(block.get("text"), str) and block["text"].strip(): - return True - elif block: - return True - elif content not in (None, ""): - return True - # Structural payloads that make an "empty-content" message still valid. Codex Responses item - # carriers persist with content:"" by design (text lives in codex_*_items and is replayed); - # treating them as payload keeps the repair from rewriting a designed-empty turn. - return bool( + blocks, tool_calls, reasoning). Role-agnostic counterpart of ``AIAgent._is_thinking_only_assistant``. + Codex Responses item carriers persist with content:"" by design (text lives in codex_*_items + and is replayed); treating them as payload keeps the repair from rewriting a designed-empty turn.""" + return _content_has_payload(msg.get("content")) or bool( msg.get("tool_calls") or (isinstance(msg.get("reasoning_content"), str) and msg["reasoning_content"].strip()) or msg.get("reasoning") @@ -2373,20 +2364,15 @@ def _log_empty_non_final_heal(healed: int) -> None: 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: - state = { - "count": 0, - "window_start": now, - "escalated": False, - "total_events": state.get("total_events", 0) if state else 0, - "total_healed": state.get("total_healed", 0) if state else 0, + prior = state or {} + state = _empty_heal_log_state[key] = { + "count": 0, "window_start": now, "escalated": False, + "total_events": prior.get("total_events", 0), "total_healed": prior.get("total_healed", 0), } - _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"] + count, total_events, total_healed = state["count"], state["total_events"], state["total_healed"] if state["escalated"]: return escalate = threshold > 0 and count >= threshold @@ -2399,8 +2385,7 @@ def _log_empty_non_final_heal(healed: int) -> None: 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." + "doctor` to capture diagnostics, or /new to start a clean session." ) if escalate: _ra().logger.error( @@ -2578,13 +2563,13 @@ def _pair_tool_calls_positionally(messages: List[Dict[str, Any]]) -> List[Dict[s for key in sorted(declared_calls): tc, _variants = declared_calls[key] paired.append({ - "role": "tool", - "name": _ra().AIAgent._get_tool_call_name_static(tc), + "role": "tool", "name": _ra().AIAgent._get_tool_call_name_static(tc), "content": "[Result unavailable — see context summary above]", "tool_call_id": coalesce_tool_call_id(tc) or key, }) stubs += 1 declared_calls.clear() + for msg in messages: role = msg.get("role") if role == "assistant": @@ -2598,30 +2583,22 @@ def _pair_tool_calls_positionally(messages: List[Dict[str, Any]]) -> List[Dict[s # 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, - ) + matched = next((k for k, (_tc, v) in declared_calls.items() if v & result_variants), None) if matched is None: dropped += 1 continue - paired.append(msg) # Consume so a duplicate result reusing the id is dropped (strict providers reject duplicates). declared_calls.pop(matched, None) - else: - if role == "user": - # A user turn closes the tool-result run; later tool messages are orphans. - _flush_unanswered_stubs() - paired.append(msg) + elif 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: - _ra().logger.debug( - "Pre-call sanitizer: removed %d positionally orphaned tool result(s)", dropped - ) + _ra().logger.debug("Pre-call sanitizer: removed %d positionally orphaned tool result(s)", dropped) if stubs: _ra().logger.debug( "Pre-call sanitizer: added %d stub tool result(s) for " @@ -2813,9 +2790,7 @@ def intent_ack_continuation_mode(agent) -> str: 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() - ) + apply_reasoning_content_policy(source_msg, api_msg, agent._needs_thinking_reasoning_pad()) def reapply_reasoning_echo_for_provider(agent, api_messages: list) -> int: @@ -2824,9 +2799,7 @@ def reapply_reasoning_echo_for_provider(agent, api_messages: list) -> int: (DeepSeek/Kimi/MiMo) 400 without the pad, strict ones (Mistral, Cerebras, Groq) 400/422 with it. 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() - ) + return reapply_reasoning_echo(api_messages, agent._needs_thinking_reasoning_pad()) def _iter_httpx_pool_objects(http_client: Any): @@ -2867,26 +2840,16 @@ def _connection_candidates(conn: Any): def _socket_from_stream(stream: Any): """Raw socket behind an httpcore network stream (several backends), or None.""" sock = getattr(stream, "_sock", None) + if sock is None and callable(getattr(stream, "get_extra_info", None)): + with contextlib.suppress(Exception): + sock = stream.get_extra_info("socket") 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: + sock = getattr(getattr(stream, "stream", None), "_sock", None) + if sock is None and callable(getattr(getattr(stream, "_stream", None), "extra", None)): # anyio-backed streams expose the raw socket through SocketAttribute.raw_socket. - extra = getattr(getattr(stream, "_stream", None), "extra", None) - if callable(extra): - try: - from anyio.abc import SocketAttribute - sock = extra(SocketAttribute.raw_socket) - except Exception: - sock = None + with contextlib.suppress(Exception): + from anyio.abc import SocketAttribute + sock = stream._stream.extra(SocketAttribute.raw_socket) return sock @@ -2898,9 +2861,7 @@ def _iter_pool_sockets(client: Any): try: # Some SDK wrappers *are* the httpx client; fall through so mount-aware discovery runs. http_client = getattr(client, "_client", None) - if http_client is None: - http_client = client - pools = list(_iter_httpx_pool_objects(http_client)) + pools = list(_iter_httpx_pool_objects(client if http_client is None else http_client)) except Exception: return seen: set[int] = set() @@ -2910,18 +2871,17 @@ def _iter_pool_sockets(client: Any): 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) + connections += [ + c for c in (getattr(r, "connection", None) for r in list(getattr(pool, "_requests", None) or [])) + if c is not None + ] for conn in connections: for candidate in _connection_candidates(conn): stream = getattr(candidate, "_network_stream", None) or getattr(candidate, "_stream", None) sock = _socket_from_stream(stream) if stream is not None else None - if sock is None or id(sock) in seen: - continue - seen.add(id(sock)) - yield sock + if sock is not None and id(sock) not in seen: + seen.add(id(sock)) + yield sock def _socket_is_dead(sock) -> bool: From a2fb880d790db201b47823a45023e8d4a1357fd6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:51:54 -0700 Subject: [PATCH 15/20] refactor(agent/agent_runtime_helpers): flatten pool walk, steer requeue, error-context, orphan classifier --- agent/agent_runtime_helpers.py | 137 ++++++++++++--------------------- 1 file changed, 49 insertions(+), 88 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 77b0e915c9..f5ab5e7a8f 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -2436,49 +2436,33 @@ def _classify_tool_call_orphans(messages: List[Dict[str, Any]]): every id variant of a tool_call is registered so a result matching any alias survives, and ``orphaned_results`` are the actual dicts (filter by ``id(msg)``). ``sanitize_api_messages`` pairs positionally instead but shares the ``*_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) + assistant_call_variants = [ + (tc, variants) + for msg in messages if msg.get("role") == "assistant" + for tc in msg.get("tool_calls") or [] + if (variants := tool_call_id_variants(tc)) + ] + surviving_call_ids: set[str] = set().union(*(v for _, v in assistant_call_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) + (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().union(*(v for _, v in result_entries)) + orphaned_results = [msg for msg, v in result_entries if v and not (v & 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 - ] + surviving_result_variants = [v for msg, v in result_entries if v 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) + tc for tc, v in assistant_call_variants if not any(v & rv for rv in surviving_result_variants) ] return surviving_call_ids, result_call_ids, orphaned_results, missing_tool_calls def _drop_invalid_roles(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Drop messages whose role the API won't accept.""" - filtered = [] + valid = _ra().AIAgent._VALID_API_ROLES 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) - return filtered + if msg.get("role") not in valid: + _ra().logger.debug("Pre-call sanitizer: dropping message with invalid role %r", msg.get("role")) + return [m for m in messages if m.get("role") in valid] def _drop_empty_tool_calls_arrays(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: @@ -2807,23 +2791,20 @@ def _iter_httpx_pool_objects(http_client: Any): keepalive and proxy configs put live connections on ``client._mounts``, which a ``_transport``-only walk misses.""" seen_pools: set[int] = set() - - 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 None and getattr(transport, "_connections", None) is not None: - pool = transport - if pool is not None and id(pool) not in seen_pools: - seen_pools.add(id(pool)) - yield pool 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) + transports = [getattr(http_client, "_transport", None)] + transports += list((getattr(http_client, "_mounts", None) or {}).values()) + for transport in transports: + if transport is None: + continue + # 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 None and getattr(transport, "_connections", None) is not None: + pool = transport + if pool is not None and id(pool) not in seen_pools: + seen_pools.add(id(pool)) + yield pool except Exception: return @@ -2949,9 +2930,7 @@ 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 + payload = (body.get("error") if isinstance(body.get("error"), dict) else body) if isinstance(body, dict) else None if isinstance(payload, dict): reason = payload.get("code") or payload.get("type") or payload.get("error") if isinstance(reason, str) and reason.strip(): @@ -2962,11 +2941,9 @@ def extract_api_error_context(error: Exception) -> Dict[str, Any]: 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 + reset = next((payload.get(k) for k in ("resets_at", "reset_at") if payload.get(k) not in {None, ""}), None) + if reset is not None: + context["reset_at"] = reset _set_reset_from_retry_after(context, payload.get("retry_after")) headers = getattr(getattr(error, "response", None), "headers", None) if headers: @@ -2974,29 +2951,20 @@ def extract_api_error_context(error: Exception) -> Dict[str, Any]: 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 = _reset_delay_from_message(message) - if delay is not None: - context["reset_at"] = time.time() + delay + if "message" not in context and str(error).strip(): + context["message"] = str(error).strip()[:500] + if "reset_at" not in context and isinstance(context.get("message") or "", str): + delay = _reset_delay_from_message(context.get("message") or "") + if delay is not None: + context["reset_at"] = time.time() + delay return context def _requeue_pending_steer(agent, steer_text: str) -> None: """Put drained steer text 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: - existing = agent._pending_steer - agent._pending_steer = (existing + "\n" + steer_text) if existing else steer_text - return - existing = getattr(agent, "_pending_steer", None) - agent._pending_steer = (existing + "\n" + steer_text) if existing else steer_text + with getattr(agent, "_pending_steer_lock", None) or contextlib.nullcontext(): + existing = getattr(agent, "_pending_steer", None) + agent._pending_steer = (existing + "\n" + steer_text) if existing else steer_text def apply_pending_steer_to_tool_results(agent, messages: list, num_tool_msgs: int) -> None: @@ -3009,30 +2977,23 @@ def apply_pending_steer_to_tool_results(agent, messages: list, num_tool_msgs: in if not steer_text: return # Skip non-tool messages in the tail in case something else is appended at the boundary. - target_idx = next( - ( - j for j in range(len(messages) - 1, max(len(messages) - num_tool_msgs - 1, -1), -1) - if isinstance(messages[j], dict) and messages[j].get("role") == "tool" - ), - None, - ) - if target_idx is None: + tail = range(len(messages) - 1, max(len(messages) - num_tool_msgs - 1, -1), -1) + target = next((messages[j] for j in tail if isinstance(messages[j], dict) and messages[j].get("role") == "tool"), None) + if target is None: # No tool result in this batch (e.g. all skipped by interrupt). _requeue_pending_steer(agent, steer_text) return marker = format_steer_marker(steer_text) - existing_content = messages[target_idx].get("content", "") + existing_content = target.get("content", "") if isinstance(existing_content, str): - messages[target_idx]["content"] = existing_content + marker + target["content"] = existing_content + marker else: # 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 + target["content"] = [*(existing_content or []), {"type": "text", "text": marker.lstrip()}] except Exception: # Fall back to string replacement if content shape is unexpected. - messages[target_idx]["content"] = f"{existing_content}{marker}" + target["content"] = f"{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 ""), From 78a9babc481356d766b31c960d8caf93369d890f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:56:34 -0700 Subject: [PATCH 16/20] refactor(agent/agent_runtime_helpers): tighten repair passes and content flattening --- agent/agent_runtime_helpers.py | 124 +++++++++++++-------------------- 1 file changed, 48 insertions(+), 76 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index f5ab5e7a8f..15651617d3 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -393,16 +393,12 @@ def _merge_consecutive_assistants(messages: List[Dict]) -> Tuple[List[Dict], int repairs = 0 collapsed: List[Dict] = [] for msg in messages: + prev = collapsed[-1] if collapsed and isinstance(collapsed[-1], dict) else None 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 is not None and prev.get("role") == "assistant" + and isinstance(msg, dict) and msg.get("role") == "assistant" + and not _is_codex_interim(msg) and not _is_codex_interim(prev) ): - prev = collapsed[-1] # A provisional verification candidate is superseded, not unioned. if prev.get("finish_reason") in {"verification_required", "verify_hook_continue"}: collapsed[-1] = msg @@ -419,28 +415,27 @@ def _drop_stray_tool_results(messages: List[Dict]) -> Tuple[List[Dict], int]: alias group (call_id/id/response_item_id/composite) so a duplicate keyed on a sibling alias is not replayed to strict providers.""" repairs = 0 - known_tool_ids: Dict[str, int] = {} + known_tool_ids: Dict[str, int] = {} # alias -> group id; reset by assistant/user turns matched_tool_groups: set = set() next_tool_group = 0 filtered: List[Dict] = [] for msg in messages: role = msg.get("role") if isinstance(msg, dict) else None - if role == "assistant": + if role in ("assistant", "user"): + # An assistant turn starts a new tool-result run; a user turn closes it (later tool + # messages are orphans). known_tool_ids = {} matched_tool_groups = set() - for tc in (msg.get("tool_calls") or []): + for tc in (msg.get("tool_calls") or []) if role == "assistant" else (): 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) + if variants: + for tc_id in variants: + known_tool_ids.setdefault(tc_id, next_tool_group) + next_tool_group += 1 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 + 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 result_variants and not candidate_groups: @@ -448,10 +443,6 @@ def _drop_stray_tool_results(messages: List[Dict]) -> Tuple[List[Dict], int]: continue if candidate_groups: matched_tool_groups.add(min(candidate_groups)) - elif 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) return filtered, repairs @@ -462,32 +453,24 @@ def _prune_unanswered_tool_calls(messages: List[Dict]) -> Tuple[List[Dict], int] dropped; codex interims exempt.""" repairs = 0 pruned: List[Dict] = [] - n = len(messages) - i = 0 - while i < n: - msg = messages[i] - i += 1 + for i, msg in enumerate(messages): if not ( - isinstance(msg, dict) - and msg.get("role") == "assistant" - and msg.get("tool_calls") + isinstance(msg, dict) and msg.get("role") == "assistant" and msg.get("tool_calls") and not _is_codex_interim(msg) ): pruned.append(msg) continue answered: set = set() - j = i - while j < n and isinstance(messages[j], dict) and messages[j].get("role") == "tool": - tid = (messages[j].get("tool_call_id") or "").strip() + for follower in messages[i + 1:]: + if not (isinstance(follower, dict) and follower.get("role") == "tool"): + break + tid = (follower.get("tool_call_id") or "").strip() if tid: answered.update(tool_result_id_variants(tid)) - j += 1 kept_calls = [tc for tc in msg["tool_calls"] if tool_call_id_variants(tc) & answered] if len(kept_calls) != len(msg["tool_calls"]): repairs += 1 - if not kept_calls and not _msg_has_payload( - {k: v for k, v in msg.items() if k != "tool_calls"} - ): + 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). continue if kept_calls: @@ -500,34 +483,29 @@ def _prune_unanswered_tool_calls(messages: List[Dict]) -> Tuple[List[Dict], int] def _merge_consecutive_users(messages: List[Dict]) -> Tuple[List[Dict], int]: """Pass 3: merge consecutive plain-text user messages (no user input lost).""" + from agent.context_compressor import split_user_originated_turn + repairs = 0 merged: List[Dict] = [] for msg in messages: + prev = merged[-1] if merged and isinstance(merged[-1], dict) else None 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] + prev is not None and prev.get("role") == "user" + and isinstance(msg, dict) and msg.get("role") == "user" # 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) - prev_content = prev.get("content", "") - new_content = msg.get("content", "") + and split_user_originated_turn(prev)[0] is None # Only merge plain-text content; leave multimodal (list) content alone. - if handoff is None and 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 + and isinstance(prev.get("content", ""), str) and isinstance(msg.get("content", ""), str) + ): + prev_content, new_content = prev.get("content", ""), msg.get("content", "") + 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) return merged, repairs @@ -581,17 +559,15 @@ def _flatten_content_text(content: Any) -> str: if isinstance(content, str): return content if isinstance(content, list): - parts: list[str] = [] - for part in content: - if isinstance(part, str): - parts.append(part) - elif isinstance(part, dict): - if str(part.get("type") or "").strip().lower() in {"thinking", "reasoning", "redacted_thinking"}: - continue - text = part.get("text") - if isinstance(text, str) and text: - parts.append(text) - return "".join(parts) + return "".join( + part if isinstance(part, str) else part.get("text") + for part in content + if isinstance(part, str) or ( + isinstance(part, dict) + and str(part.get("type") or "").strip().lower() not in {"thinking", "reasoning", "redacted_thinking"} + and isinstance(part.get("text"), str) and part.get("text") + ) + ) if isinstance(content, dict): return str(content.get("text") or content.get("content") or "") return str(content) @@ -614,12 +590,8 @@ def strip_think_blocks(agent, content: str) -> str: pairs, unterminated open tags at a block boundary (mirrors ``gateway/stream_consumer.py``), stray orphan tags (all case-insensitive variants), and standalone tool-call XML blocks some open models emit; ```` is boundary- and ``name=``-gated so prose mentions survive.""" - if not content: - return "" - content = _flatten_content_text(content) - if not content: - return "" - for pattern in _THINK_STRIP_PATTERNS: + content = _flatten_content_text(content) if content else "" + for pattern in _THINK_STRIP_PATTERNS if content else (): content = pattern.sub('', content) return content From d85caa9360bad56e524f63ec945626096fad7e53 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:01:00 -0700 Subject: [PATCH 17/20] refactor(agent/agent_runtime_helpers): compact cache-plan/MoA/custom-route helpers --- agent/agent_runtime_helpers.py | 63 ++++++++++------------------------ 1 file changed, 18 insertions(+), 45 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 15651617d3..c4c348eac8 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1255,10 +1255,7 @@ def _direct_native_anthropic_tool_cache_capability( """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" - ) + return eff_api_mode == "anthropic_messages" and base_url_hostname(eff_base_url) == "api.anthropic.com" def cache_ttl_means_disabled(ttl: Any) -> bool: @@ -1302,9 +1299,7 @@ def blank_cache_policy_stub(cache_disabled: Optional[bool] = None): 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) - ) + return SimpleNamespace(provider="", base_url="", api_mode="", model="", _cache_disabled=bool(cache_disabled)) def plan_cache_sections_for_destination( @@ -1321,29 +1316,19 @@ def plan_cache_sections_for_destination( 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 - ) + dest = dict(provider=provider, base_url=base_url, api_mode=api_mode, model=model) + should_cache, native_layout = anthropic_prompt_cache_policy(stub, **dest) 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, - ), + messages, tools, + # effective_cache_ttl resolves None → "5m"; cache-disabled agents never reach here. + cache_ttl=effective_cache_ttl(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 - ), + 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, **dest), # LiteLLM-style envelope routes forward part-level markers into tool_result.content[] → # non-retryable 400. tool_part_markers=envelope_tool_part_cache_markers_supported(provider, base_url), @@ -1371,19 +1356,15 @@ def _moa_aggregator_cache_policy(agent, eff_model: str) -> tuple[bool, bool]: 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 = resolve_moa_preset(_load_moa_cfg().get("moa") or {}, eff_model or None).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: + agg_base_url = agg_api_mode = "" + with contextlib.suppress(Exception): 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 ) @@ -1400,21 +1381,13 @@ def _route_may_be_custom(agent, eff_provider: str, provider_lower: str, eff_base # 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:")) + provider_ids = {provider_lower, 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 - ): - return True - return False + return any( + provider_ids & custom_provider_aliases(str(entry.get("name") or ""), str(entry.get("provider_key") or "")) + or (eff_url_normalized and normalize_route_base_url(entry.get("base_url")) == eff_url_normalized) + for entry in custom_providers if isinstance(entry, dict) + ) if custom_providers is not None: return False # attached empty list never matches # None = list not attached yet (early init or blank stub). Avoid rebuilding the list for From 58ae7dcff61e5349fb0e62a02f3f3853933658d3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:05:25 -0700 Subject: [PATCH 18/20] refactor(agent/agent_runtime_helpers): compact transport recovery, user-merge, reset gate, turn tripwire --- agent/agent_runtime_helpers.py | 37 ++++++++++++---------------------- 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index c4c348eac8..cffe6eb66c 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -302,8 +302,7 @@ def note_turn_start(agent, turn_id: str): "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 "-", + 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 @@ -872,18 +871,13 @@ def try_recover_primary_transport( ) -> bool: """Rebuild the primary client once and retry after ``max_retries`` exhaust on a transient transport error. Skipped for aggregators (OpenRouter, Nous) that manage retries server-side.""" - if agent._fallback_activated: - return False error_type = type(api_error).__name__ - if error_type not in _TRANSIENT_TRANSPORT_ERRORS: + if agent._fallback_activated or error_type not in _TRANSIENT_TRANSPORT_ERRORS or agent._is_openrouter_url(): return False - 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"} + (agent.provider or "").strip().lower() in {"nous", "nous-portal", "nousresearch"} and getattr(agent, "api_mode", None) != "anthropic_messages" ): return False @@ -901,9 +895,7 @@ def try_recover_primary_transport( 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 - ) + 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} — " @@ -915,8 +907,6 @@ def try_recover_primary_transport( logger.warning("Primary transport recovery failed: %s", e) return False -# ── End provider fallback ────────────────────────────────────────────── - def _merge_user_content(prev_content: Any, cur_content: Any) -> Any: """Merged content for two adjacent user messages (``_UNMERGEABLE`` for unknown shapes): @@ -952,16 +942,16 @@ def drop_thinking_only_and_merge_users( merges = 0 for m in kept: prev = merged[-1] if merged else None + content = _UNMERGEABLE if prev is not None and prev.get("role") == "user" and m.get("role") == "user": content = _merge_user_content(prev.get("content", ""), m.get("content", "")) - if content is not _UNMERGEABLE: - # Copy ``prev`` so caller dicts are never mutated. - merged[-1] = {**prev, "content": content} - merges += 1 - continue - # Unknown content shape: append separately (violates alternation, but safer than raising - # in a hot path). - merged.append(m) + if content is _UNMERGEABLE: + # Not a user pair, or an unknown content shape: append separately (the latter violates + # alternation, but is safer than raising in a hot path). + merged.append(m) + else: + merged[-1] = {**prev, "content": content} # copy so caller dicts are never mutated + merges += 1 if dropped == 0 and merges == 0: return messages _ra().logger.debug( @@ -975,8 +965,7 @@ def _primary_reset_gate_blocks(agent, rt, primary_provider, primary_runtime_base """Reset-aware gate: skip a guaranteed-to-fail restore while the primary pool reports a future reset; fails open on any error/None. Returns ``(blocked, prefetched_pool, prefetched)`` so the rebind step reuses the loaded pool (one auth.json read at most).""" - prefetched_pool = None - prefetched = False + prefetched_pool, prefetched = None, False try: pool = getattr(agent, "_credential_pool", None) if not matches_primary(pool): From db579c75a9b8e06b363401929cfc2513911a51b6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:10:03 -0700 Subject: [PATCH 19/20] refactor(agent/agent_runtime_helpers): collapse marker prepend, cursor prefix, ttl predicate --- agent/agent_runtime_helpers.py | 47 +++++++++++++--------------------- 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index cffe6eb66c..bdf9f8dbef 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -175,20 +175,14 @@ def convert_to_trajectory_format(agent, messages: List[Dict[str, Any]], user_que def _prepend_corruption_marker(tool_msg: dict, marker: str) -> 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}" + if isinstance(existing, str) and existing.startswith(marker): 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}" + if not isinstance(existing, (str, type(None))): + try: + existing = json.dumps(existing) + except TypeError: + existing = str(existing) + tool_msg["content"] = f"{marker}\n{existing}" if existing else marker def _find_tool_result(messages: list, start: int, tool_call: dict) -> Optional[dict]: @@ -203,13 +197,11 @@ def _find_tool_result(messages: list, start: int, tool_call: dict) -> Optional[d def _cursor_skip_prefix(messages: list, cursor: Optional[dict]) -> int: """Length of the ``is``-identical prefix already validated on the previous call.""" + prev_prefix = cursor.get("prefix") if cursor is not None else None start = 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 < limit and messages[start] is prev_prefix[start]: - start += 1 + if isinstance(prev_prefix, list): + while start < min(len(prev_prefix), len(messages)) and messages[start] is prev_prefix[start]: + start += 1 return start @@ -1216,11 +1208,10 @@ def dump_api_request_debug( } if error is not None: dump_payload["error"] = _api_error_debug_info(error) - 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" + dump_file = agent.logs_dir / f"request_dump_{safe_sid}_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.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 @@ -1247,18 +1238,16 @@ def _direct_native_anthropic_tool_cache_capability( return eff_api_mode == "anthropic_messages" and base_url_hostname(eff_base_url) == "api.anthropic.com" +# The cache_ttl tiers accepted by config; mirrored by agent_init's live-agent snapshot. +VALID_CACHE_TTLS = ("5m", "1h") + + def cache_ttl_means_disabled(ttl: Any) -> bool: """True when a ``prompt_caching.cache_ttl`` value means caching off (single predicate shared by ``agent_init`` and the stub policy paths). Unknown values (``"2h"``, ints) are NOT a disable.""" - if ttl in ("5m", "1h"): + if ttl in VALID_CACHE_TTLS: 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") + return ttl is False or ttl is None or str(ttl).lower() in ("off", "false", "disabled", "no", "none") def _raw_cache_ttl_from_config(default: Any) -> Any: From 1bb0ebf47cbc92ab74a598997e38cfeec37ea83c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:17:58 -0700 Subject: [PATCH 20/20] refactor(agent/agent_runtime_helpers): final defensive collapses (debug dump, pool rebind, tool-name repair) --- agent/agent_runtime_helpers.py | 93 ++++++++++++---------------------- 1 file changed, 32 insertions(+), 61 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index bdf9f8dbef..839181801e 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -980,15 +980,12 @@ def _primary_reset_gate_blocks(agent, rt, primary_provider, primary_runtime_base def _restore_runtime_capabilities(agent, rt: Dict[str, Any]) -> None: - if "runtime_capabilities" in rt: - raw = rt["runtime_capabilities"] - if not isinstance(raw, dict): - logger.warning("Ignoring malformed runtime capabilities snapshot") - else: - agent.runtime_capabilities = dict(raw) - elif isinstance(rt.get("capabilities"), dict): - # Snapshots written by the initial capability propagation patch. - agent.runtime_capabilities = dict(rt["capabilities"]) + # ``capabilities`` is the legacy key from the initial capability propagation patch. + raw = rt["runtime_capabilities"] if "runtime_capabilities" in rt else rt.get("capabilities") + if isinstance(raw, dict): + agent.runtime_capabilities = dict(raw) + elif "runtime_capabilities" in rt: + logger.warning("Ignoring malformed runtime capabilities snapshot") def _rebind_primary_credential_pool(agent, primary_provider, matches_primary, load_primary_pool, prefetched_pool, prefetched) -> None: @@ -1010,13 +1007,8 @@ def _rebind_primary_credential_pool(agent, primary_provider, matches_primary, lo ) agent._credential_pool_entry_id = None pool = getattr(agent, "_credential_pool", None) - if pool is None or not pool.has_available(): - return - entry = pool.select() - if entry is None: - return - entry_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "") - if not entry_key: + entry = pool.select() if pool is not None and pool.has_available() else None + if entry is None or not (getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "")): return if matches_primary(entry): # _swap_credential rebuilds the client and reapplies base-url-scoped headers. @@ -1064,12 +1056,9 @@ def restore_primary_runtime(agent) -> bool: return False 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") + if not (isinstance(fallback_route, (list, tuple)) and len(fallback_route) == 2): + fallback_route = (getattr(agent, "model", ""), getattr(agent, "provider", "")) + previous_model, previous_provider = (str(v or "unknown") for v in fallback_route) provider_fallback_active = bool(getattr(agent, "_provider_fallback_active", False)) try: _apply_primary_runtime_fields(agent, rt) @@ -1183,9 +1172,7 @@ def dump_api_request_debug( ) -> 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} + body = {k: v for k, v in copy.deepcopy(api_kwargs).items() if v is not None and k != "timeout"} api_key = None try: api_key = getattr(agent.client, "api_key", None) @@ -1193,12 +1180,9 @@ def dump_api_request_debug( _ra().logger.debug("Could not extract API key for debug dump: %s", e) endpoint = "/responses" if agent.api_mode == "codex_responses" else "/chat/completions" dump_payload: Dict[str, Any] = { - "timestamp": datetime.now().isoformat(), - "session_id": agent.session_id, - "reason": reason, + "timestamp": datetime.now().isoformat(), "session_id": agent.session_id, "reason": reason, "request": { - "method": "POST", - "url": f"{agent.base_url.rstrip('/')}{endpoint}", + "method": "POST", "url": f"{agent.base_url.rstrip('/')}{endpoint}", "headers": { "Authorization": f"Bearer {agent._mask_api_key_for_logs(api_key)}", "Content-Type": "application/json", @@ -1752,16 +1736,13 @@ def _build_switched_client(agent, new_provider, api_key, base_url, api_mode, new "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.api_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._is_anthropic_oauth = bool(is_native_anthropic and isinstance(effective_key, str) and _is_oauth_token(effective_key)) agent.client = None agent._client_kwargs = {} return @@ -1794,8 +1775,7 @@ def _swap_switch_runtime(agent, new_model, new_provider, api_key, base_url, api_ # Clear the per-config override so the new model's context window is re-resolved. agent._config_context_length = None agent.model = new_model - agent.provider = new_provider - agent.requested_provider = new_provider + agent.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 @@ -2144,19 +2124,12 @@ def repair_tool_call(agent, tool_name: str) -> str | None: 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 + return next((s[: -len(sfx)].rstrip("_-") for sfx in ("_tool", "-tool", "tool") if lc.endswith(sfx)), None) # Cheap fast-paths first. lowered = tool_name.lower() if lowered in agent.valid_tool_names: @@ -2189,27 +2162,25 @@ _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 (out-of-band, never in conversation context). +# Sessions already told ONCE (out-of-band, never in conversation context); kept apart from the +# windowed log state so a new window never re-arms the notice. _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. +# One-shot pending notices keyed by session, drained via ``consume_pending_sanitizer_heal_notice`` +# and delivered via the status/warning callback. _empty_heal_pending_notice: Dict[str, str] = {} def _content_has_payload(content: Any) -> bool: if isinstance(content, str): return bool(content.strip()) - if isinstance(content, list): - # Any typed block counts, as long as a text block is not itself blank. - return any( - (block.get("type") != "text" or (isinstance(block.get("text"), str) and block["text"].strip())) - if isinstance(block, dict) else bool(block) - for block in content - ) - return content not in (None, "") + if not isinstance(content, list): + return content not in (None, "") + # Any typed block counts, as long as a text block is not itself blank. + return any( + (block.get("type") != "text" or (isinstance(block.get("text"), str) and block["text"].strip())) + if isinstance(block, dict) else bool(block) + for block in content + ) def _msg_has_payload(msg: Dict[str, Any]) -> bool: