From eba201ff6e4881ef9bb12ff49a9979a2319488f5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:41:26 -0700 Subject: [PATCH 01/12] refactor(agent/background_review): split _run_review_in_thread into fork/summary phases, extract fork kwargs and action-line helpers --- agent/background_review.py | 859 +++++++++----------- tests/test_background_review_list_shapes.py | 3 +- 2 files changed, 366 insertions(+), 496 deletions(-) diff --git a/agent/background_review.py b/agent/background_review.py index 70c83dea14..85373db03a 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -1,15 +1,11 @@ """Background memory/skill review — fork the agent to evaluate the turn. -After every turn ``AIAgent.run_conversation`` may spawn a daemon thread that -replays the conversation snapshot in a forked :class:`AIAgent` and asks -"should any skill/memory be saved or updated?". Writes go straight to the -memory + skill stores; the main conversation and prompt cache are never touched. -The fork inherits the parent's live runtime (provider, model, credentials, -cached system prompt) so it hits the same prefix cache, and runs under a +After every turn ``AIAgent.run_conversation`` may spawn a daemon thread that replays the +conversation snapshot in a forked :class:`AIAgent` and asks "should any skill/memory be saved +or updated?". Writes go straight to the memory + skill stores; the main conversation and prompt +cache are never touched. The fork inherits the parent's live runtime (provider, model, +credentials, cached system prompt) so it hits the same prefix cache, and runs under a dispatch-side tool whitelist limited to memory/skill tools. - -See the ``hermes-agent-dev`` skill (``references/self-improvement-loop.md``) -for invariants and PR review criteria. """ from __future__ import annotations @@ -20,6 +16,7 @@ import logging import os import threading from contextlib import contextmanager +from dataclasses import dataclass, field from typing import Any, Dict, Iterator, List, Optional, Tuple from agent.thread_scoped_output import thread_scoped_silence @@ -96,10 +93,7 @@ def prepare_background_review_run(agent: Any) -> Optional[_BackgroundReviewRun]: return run -def finish_background_review_run( - agent: Any, - run: Optional[_BackgroundReviewRun], -) -> None: +def finish_background_review_run(agent: Any, run: Optional[_BackgroundReviewRun]) -> None: """Publish one run's request exit without clearing a successor (ABA-safe).""" if run is None or not run.mark_request_finished(): return @@ -110,38 +104,31 @@ def finish_background_review_run( def _interrupt_background_review(review_agent: Any) -> None: - """Request abort off-thread so a wedged abort hook cannot stall the live turn - (the bounded ``request_done`` wait in the canceller relies on this returning fast).""" + """Request abort off-thread so a wedged abort hook cannot stall the live turn (the bounded + ``request_done`` wait in the canceller relies on this returning fast).""" def _interrupt() -> None: try: from agent.interrupt_compat import request_hard_interrupt request_hard_interrupt( - review_agent, - "superseded by a new live turn", + review_agent, "superseded by a new live turn", tool_reason="background review superseded", ) except Exception: - logger.debug( - "Failed to cancel in-flight background review for a new turn", - exc_info=True, - ) + logger.debug("Failed to cancel in-flight background review for a new turn", exc_info=True) try: threading.Thread(target=_interrupt, daemon=True, name="bg-review-cancel").start() except Exception: - logger.debug( - "Failed to start background-review cancellation thread", - exc_info=True, - ) + logger.debug("Failed to start background-review cancellation thread", exc_info=True) def cancel_background_review_for_live_turn(agent: Any) -> None: """Cancel the current review and await its request-phase acknowledgement. - Foreground priority: past the bounded deadline, warn and let the live turn - proceed — self-improvement work must never block a user-facing turn. + Foreground priority: past the bounded deadline, warn and let the live turn proceed — + self-improvement work must never block a user-facing turn. """ with _optional_lock(agent, "_background_review_lock"): run = getattr(agent, "_background_review_run", None) @@ -164,19 +151,17 @@ def cancel_background_review_for_live_turn(agent: Any) -> None: ) -# Aux-model routing: by default ("auto") the fork runs on the MAIN model and -# replays the full conversation as warm cache reads. When -# auxiliary.background_review.{provider,model} routes it to a DIFFERENT model -# the cache is cold anyway, so the fork replays a compact digest instead. +# Aux-model routing: by default ("auto") the fork runs on the MAIN model and replays the full +# conversation as warm cache reads. When auxiliary.background_review.{provider,model} routes it +# to a DIFFERENT model the cache is cold anyway, so the fork replays a compact digest instead. _REVIEW_MAX_ITERATIONS = 16 # Aggregate INPUT-token budget for one review fork (checked in conversation_loop's -# ``_review_input_budget_exhausted``). Request #1 replays the full snapshot as a -# warm cache read (both compression gates deferred until the first response); -# compaction then bounds each request, but nothing else caps the SUM across the -# tool loop. 2x the historical 300k foreground trigger. Override via -# ``auxiliary.background_review.max_input_tokens``; <= 0 disables. +# ``_review_input_budget_exhausted``). Request #1 replays the full snapshot as a warm cache read +# (both compression gates deferred until the first response); compaction then bounds each +# request, but nothing else caps the SUM across the tool loop. 2x the historical 300k foreground +# trigger. Override via ``auxiliary.background_review.max_input_tokens``; <= 0 disables. _REVIEW_MAX_INPUT_TOKENS_DEFAULT = 600_000 @@ -187,14 +172,9 @@ def _task_block(cfg: Any) -> Dict[str, Any]: return task if isinstance(task, dict) else {} -def _background_review_task_config( - task_cfg: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: - """Return ``auxiliary.background_review`` (or ``{}`` on any failure). - - Pass ``task_cfg`` when the caller already loaded the block so the spawn / - resolve / prompt paths do not re-read config on every turn. - """ +def _background_review_task_config(task_cfg: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """``auxiliary.background_review`` (or ``{}`` on any failure); pass a pre-loaded ``task_cfg`` + so the spawn / resolve / prompt paths do not re-read config on every turn.""" if task_cfg is not None: return task_cfg if isinstance(task_cfg, dict) else {} try: @@ -205,13 +185,9 @@ def _background_review_task_config( return {} -def _review_input_token_budget( - task_cfg: Optional[Dict[str, Any]] = None, -) -> Optional[int]: +def _review_input_token_budget(task_cfg: Optional[Dict[str, Any]] = None) -> Optional[int]: """Aggregate input-token budget for one review fork (None = unlimited; <= 0 disables).""" - raw = _background_review_task_config(task_cfg).get( - "max_input_tokens", _REVIEW_MAX_INPUT_TOKENS_DEFAULT - ) + raw = _background_review_task_config(task_cfg).get("max_input_tokens", _REVIEW_MAX_INPUT_TOKENS_DEFAULT) try: budget = int(raw) except (TypeError, ValueError): @@ -220,11 +196,8 @@ def _review_input_token_budget( def load_background_review_settings() -> tuple[bool, Dict[str, Any]]: - """Single config read for the automatic-review gate + task block. - - Returns ``(enabled, task_cfg)``. Fail-open (``enabled=True``) so a broken - config never silently disables reviews — but WARN so the cost is visible. - """ + """Single config read -> ``(enabled, task_cfg)``. Fail-open (``enabled=True``) so a broken + config never silently disables reviews — but WARN so the cost is visible.""" try: from hermes_cli.config import load_config_readonly from utils import is_truthy_value @@ -240,15 +213,10 @@ def load_background_review_settings() -> tuple[bool, Dict[str, Any]]: return True, {} -def is_background_review_enabled( - task_cfg: Optional[Dict[str, Any]] = None, -) -> bool: - """Whether automatic post-turn review may spawn (``enabled``, default true). - - Explicit ``/refine`` (``focus`` set) bypasses this gate. Prefer - :func:`load_background_review_settings` at the spawn site so the block is - not re-read on the same turn. - """ +def is_background_review_enabled(task_cfg: Optional[Dict[str, Any]] = None) -> bool: + """Whether automatic post-turn review may spawn (``enabled``, default true). Explicit + ``/refine`` (``focus`` set) bypasses this gate; prefer :func:`load_background_review_settings` + at the spawn site so the block is not re-read on the same turn.""" if task_cfg is None: return load_background_review_settings()[0] try: @@ -264,16 +232,13 @@ def is_background_review_enabled( return True -def _resolve_review_runtime( - agent: Any, - task_cfg: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: +def _resolve_review_runtime(agent: Any, task_cfg: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Resolve provider/model/credentials for the review fork. - Default (auto / unset / same as parent): the parent's live runtime with - ``routed=False`` (codex_app_server -> codex_responses downgrade applied). - When ``auxiliary.background_review.{provider,model}`` names a different - concrete model, resolve that runtime and set ``routed=True``. + Default (auto / unset / same as parent): the parent's live runtime with ``routed=False`` + (codex_app_server -> codex_responses downgrade applied). When + ``auxiliary.background_review.{provider,model}`` names a different concrete model, resolve + that runtime and set ``routed=True``. """ parent_runtime = agent._current_main_runtime() parent_api_mode = parent_runtime.get("api_mode") or None @@ -294,8 +259,7 @@ def _resolve_review_runtime( } task = _background_review_task_config(task_cfg) task_provider, task_model, task_base_url, task_api_key = ( - str(task.get(key, "")).strip() or None - for key in ("provider", "model", "base_url", "api_key") + str(task.get(key, "")).strip() or None for key in ("provider", "model", "base_url", "api_key") ) if not (task_provider and task_provider != "auto" and task_model): return parent @@ -304,10 +268,8 @@ def _resolve_review_runtime( try: from hermes_cli.runtime_provider import resolve_runtime_provider rp = resolve_runtime_provider( - requested=task_provider, - target_model=task_model, - explicit_api_key=task_api_key, - explicit_base_url=task_base_url, + requested=task_provider, target_model=task_model, + explicit_api_key=task_api_key, explicit_base_url=task_base_url, ) return { "provider": rp.get("provider") or task_provider, @@ -328,13 +290,9 @@ def _resolve_review_runtime( def _parent_can_emit_tool_calls(agent: Any) -> bool: - """Whether a fork inheriting ``agent``'s runtime could act at all. - - An agent-as-provider client shim that cannot carry Hermes tool calls back - declares ``SUPPORTS_HERMES_TOOL_CALLS = False`` (instance or class) and is - skipped — the fork would be a guaranteed no-op that still pays a full - spawn. Anything that doesn't say otherwise is assumed capable. - """ + """Whether a fork inheriting ``agent``'s runtime could act at all: an agent-as-provider client + shim declaring ``SUPPORTS_HERMES_TOOL_CALLS = False`` (instance or class) is skipped — the fork + would be a guaranteed no-op that still pays a full spawn. Silence means capable.""" client = getattr(agent, "client", None) for candidate in (client, type(client) if client is not None else None): supported = getattr(candidate, "SUPPORTS_HERMES_TOOL_CALLS", None) @@ -353,13 +311,9 @@ def _msg_text(m: Dict) -> str: def _digest_history(messages_snapshot: List[Dict], tail: int = 24) -> List[Dict]: - """Compact replay for the routed (different-model) path only. - - Keeps the recent ``tail`` messages verbatim (extended so the kept run never - starts on a tool result) and collapses older turns into one synthetic - user-role digest, preserving role alternation. Never used on the - main-model path, where the full replay stays warm. - """ + """Compact replay for the routed (different-model) path only: keep the recent ``tail`` + messages verbatim (extended so the kept run never starts on a tool result) and collapse older + turns into one synthetic user-role digest, preserving role alternation.""" msgs = list(messages_snapshot or []) if len(msgs) <= tail: return msgs @@ -380,9 +334,7 @@ def _digest_history(messages_snapshot: List[Dict], tail: int = 24) -> List[Dict] elif role == "assistant": if m.get("tool_calls"): names = [ - (tc.get("function") or {}).get("name", "?") - for tc in m["tool_calls"] - if isinstance(tc, dict) + (tc.get("function") or {}).get("name", "?") for tc in m["tool_calls"] if isinstance(tc, dict) ] lines.append(f"ASSISTANT[tools: {', '.join(names)}]") if text: @@ -663,16 +615,13 @@ _COMBINED_REVIEW_PROMPT = ( ) - def _preview(text: str, limit: int) -> str: return text[:limit] + ("…" if len(text) > limit else "") # Memory op -> (glyph, which field carries the preview, preview length). _MEMORY_OP_FORMATS: Dict[str, Tuple[str, str, int]] = { - "add": ("➕", "content", 120), - "replace": ("✏️", "content", 120), - "remove": ("➖", "old_text", 60), + "add": ("➕", "content", 120), "replace": ("✏️", "content", 120), "remove": ("➖", "old_text", 60) } @@ -681,8 +630,8 @@ def _memory_op_line(label: str, action: str, fields: Dict[str, str]) -> Optional fmt = _MEMORY_OP_FORMATS.get(action) if fmt is None: return None - glyph, field, limit = fmt - text = fields.get(field) or "" + glyph, field_name, limit = fmt + text = fields.get(field_name) or "" return f"{label} {glyph} {_preview(text, limit)}" if text else None @@ -711,11 +660,7 @@ def _verbose_memory_lines(label: str, detail: Dict) -> List[str]: ops_raw = detail.get("operations") operations: list = ops_raw if isinstance(ops_raw, list) else [] if operations: - lines = [ - _memory_op_line(label, op.get("action", ""), op) - for op in operations - if isinstance(op, dict) - ] + lines = [_memory_op_line(label, op.get("action", ""), op) for op in operations if isinstance(op, dict)] return [line for line in lines if line] line = _memory_op_line(label, detail.get("action", ""), detail) return [line or f"{label} updated"] @@ -724,9 +669,9 @@ def _verbose_memory_lines(label: str, detail: Dict) -> List[str]: def _collect_review_call_details(review_messages: List[Dict]) -> Tuple[set, dict]: """Map review-agent tool_call ids -> parsed call arguments for notify tools. - Result JSON only says "Entry added"; the call arguments carry action, - target and content previews. Restricting to notify tools keeps helper - tools from surfacing as memory work just because they succeeded. + Result JSON only says "Entry added"; the call arguments carry action, target and content + previews. Restricting to notify tools keeps helper tools from surfacing as memory work just + because they succeeded. """ notify_tools = {"memory", "skill_manage"} all_tool_call_ids: set = set() @@ -763,37 +708,61 @@ def _collect_review_call_details(review_messages: List[Dict]) -> Tuple[set, dict return all_tool_call_ids, call_details -def summarize_background_review_actions( - review_messages: List[Dict], - prior_snapshot: List[Dict], - notification_mode: str = "on", -) -> List[str]: - """Build the human-facing action summary for a background review pass. - - Collects successful memory / skill-management tool results from the review - agent's messages, skipping tool messages already present in - ``prior_snapshot`` so inherited results are not re-surfaced as fresh work. - - ``notification_mode``: ``off`` -> no actions; ``on`` -> generic - "Memory updated"/tool messages; ``verbose`` -> content previews from the - tool-call arguments. - """ - mode = str(notification_mode or "on").lower() - if mode == "off": - return [] - verbose = mode == "verbose" - - existing_tool_call_ids = set() - existing_tool_contents = set() +def _prior_tool_keys(prior_snapshot: List[Dict]) -> Tuple[set, set]: + """``(tool_call_ids, contents)`` of tool messages already in the parent snapshot.""" + ids: set = set() + contents: set = set() for prior in prior_snapshot or []: if not isinstance(prior, dict) or prior.get("role") != "tool": continue tcid = prior.get("tool_call_id") if tcid: - existing_tool_call_ids.add(tcid) + ids.add(tcid) elif isinstance(prior.get("content"), str): - existing_tool_contents.add(prior["content"]) + contents.add(prior["content"]) + return ids, contents + +def _action_lines(data: Dict, detail: Dict, verbose: bool) -> List[str]: + """Summary line(s) for one successful notify-tool result (``[]`` when nothing to report).""" + message = data.get("message", "") + target = data.get("target", "") or detail.get("target", "") + is_skill = detail.get("tool") == "skill_manage" + message_lower = message.lower() + if not verbose and ( + "created" in message_lower or "updated" in message_lower or (is_skill and "patched" in message_lower) + ): + return [message] + if is_skill: + label = "Skill" + elif target: + label = "Memory" if target == "memory" else "User profile" if target == "user" else target + else: + return [] + if verbose: + return [_verbose_skill_line(data, detail, message)] if is_skill else _verbose_memory_lines(label, detail) + if any(k in message_lower for k in ("added", "replaced", "removed", "applied")) or ( + target and "add" in message_lower + ): + return [f"{label} updated"] + return [] + + +def summarize_background_review_actions( + review_messages: List[Dict], prior_snapshot: List[Dict], notification_mode: str = "on" +) -> List[str]: + """Build the human-facing action summary for a background review pass. + + Collects successful memory / skill-management tool results from the review agent's messages, + skipping tool messages already present in ``prior_snapshot`` so inherited results are not + re-surfaced as fresh work. ``notification_mode``: ``off`` -> no actions; ``on`` -> generic + "Memory updated"/tool messages; ``verbose`` -> content previews from the tool-call arguments. + """ + mode = str(notification_mode or "on").lower() + if mode == "off": + return [] + verbose = mode == "verbose" + existing_tool_call_ids, existing_tool_contents = _prior_tool_keys(prior_snapshot) all_tool_call_ids, call_details = _collect_review_call_details(review_messages) actions: List[str] = [] @@ -813,65 +782,22 @@ def summarize_background_review_actions( data = json.loads(msg.get("content", "{}")) except (json.JSONDecodeError, TypeError): continue - # Wrapper MCP servers may return a top-level list/scalar; only dict - # payloads carry ``success``/``_change``. + # Wrapper MCP servers may return a top-level list/scalar; only dict payloads carry + # ``success``/``_change``. if not isinstance(data, dict) or not data.get("success"): continue - message = data.get("message", "") - detail = call_details.get(tcid) or {} - if not isinstance(detail, dict): - detail = {} - target = data.get("target", "") or detail.get("target", "") - is_skill = detail.get("tool") == "skill_manage" - - message_lower = message.lower() - if not verbose and ( - "created" in message_lower - or "updated" in message_lower - or (is_skill and "patched" in message_lower) - ): - actions.append(message) - continue - - if is_skill: - label = "Skill" - elif target: - label = "Memory" if target == "memory" else "User profile" if target == "user" else target - else: - continue - - if verbose: - if is_skill: - actions.append(_verbose_skill_line(data, detail, message)) - else: - actions.extend(_verbose_memory_lines(label, detail)) - elif ( - "added" in message_lower - or "replaced" in message_lower - or "removed" in message_lower - or "applied" in message_lower - or (target and "add" in message.lower()) - or "Entry added" in message - ): - actions.append(f"{label} updated") + actions.extend(_action_lines(data, call_details.get(tcid) or {}, verbose)) return actions def build_memory_write_metadata( - agent: Any, - *, - write_origin: Optional[str] = None, - execution_context: Optional[str] = None, - task_id: Optional[str] = None, - tool_call_id: Optional[str] = None, + agent: Any, *, write_origin: Optional[str] = None, execution_context: Optional[str] = None, + task_id: Optional[str] = None, tool_call_id: Optional[str] = None, ) -> Dict[str, Any]: """Build provenance metadata for external memory-provider mirrors.""" metadata: Dict[str, Any] = { "write_origin": write_origin or getattr(agent, "_memory_write_origin", "assistant_tool"), - "execution_context": ( - execution_context - or getattr(agent, "_memory_write_context", "foreground") - ), + "execution_context": execution_context or getattr(agent, "_memory_write_context", "foreground"), "session_id": agent.session_id or "", "parent_session_id": agent._parent_session_id or "", "platform": agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"), @@ -885,36 +811,25 @@ def build_memory_write_metadata( _USAGE_COUNTERS = ( - "input_tokens", - "output_tokens", - "cache_read_tokens", - "cache_write_tokens", - "reasoning_tokens", - "api_calls", + "input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens", "reasoning_tokens", "api_calls", ) def _snapshot_review_usage(review_agent: Any) -> Dict[str, Any]: """Snapshot in-memory usage counters from a review fork (pre-close).""" - usage: Dict[str, Any] = { - key: getattr(review_agent, key, None) - for key in ("model", "provider", "base_url") - } + usage: Dict[str, Any] = {key: getattr(review_agent, key, None) for key in ("model", "provider", "base_url")} for key in _USAGE_COUNTERS: usage[key] = int(getattr(review_agent, f"session_{key}", 0) or 0) usage["estimated_cost_usd"] = getattr(review_agent, "session_estimated_cost_usd", None) return usage -def _record_review_usage_to_parent( - parent_agent: Any, - usage: Dict[str, Any], -) -> None: +def _record_review_usage_to_parent(parent_agent: Any, usage: Dict[str, Any]) -> None: """Record a fork's usage against the parent session (best-effort, never raises). - The fork has ``_session_db = None`` so conversation_loop's DB-gated accounting - never sees its calls; route them through the aux-accounting chokepoint, which - writes only ``session_model_usage`` — never the transcript or ``sessions`` row. + The fork has ``_session_db = None`` so conversation_loop's DB-gated accounting never sees its + calls; route them through the aux-accounting chokepoint, which writes only + ``session_model_usage`` — never the transcript or ``sessions`` row. """ try: session_db = getattr(parent_agent, "_session_db", None) @@ -925,27 +840,21 @@ def _record_review_usage_to_parent( if not any(counts.values()): return # fork made no successful API calls (e.g. failed at spawn) session_db.record_auxiliary_usage( - session_id, - task="background_review", - model=usage.get("model"), - billing_provider=usage.get("provider"), - billing_base_url=usage.get("base_url"), + session_id, task="background_review", model=usage.get("model"), + billing_provider=usage.get("provider"), billing_base_url=usage.get("base_url"), estimated_cost_usd=usage.get("estimated_cost_usd"), - api_call_count=counts.pop("api_calls"), - **counts, + api_call_count=counts.pop("api_calls"), **counts, ) except Exception as e: - logger.debug( - "Background review usage recording failed (non-fatal): %s", e - ) + logger.debug("Background review usage recording failed (non-fatal): %s", e) def _classify_review_result(actions: List[str]) -> str: """Map a review action summary to ``none`` / ``skill`` / ``memory`` / ``skill+memory``. - Prefix-based on the formats :func:`summarize_background_review_actions` - emits (``Skill …``, ``📝 Skill …``, ``Memory …``, ``User profile …``), so a - free-text line like ``Skipped: no skill worth saving`` stays ``none``. + Prefix-based on the formats :func:`summarize_background_review_actions` emits (``Skill …``, + ``📝 Skill …``, ``Memory …``, ``User profile …``), so a free-text line like ``Skipped: no + skill worth saving`` stays ``none``. """ has_skill = has_memory = False for action in actions or []: @@ -957,9 +866,7 @@ def _classify_review_result(actions: List[str]) -> str: has_skill = True elif lower.startswith("memory") or lower.startswith("user profile"): has_memory = True - return "+".join( - kind for kind, hit in (("skill", has_skill), ("memory", has_memory)) if hit - ) or "none" + return "+".join(kind for kind, hit in (("skill", has_skill), ("memory", has_memory)) if hit) or "none" def _log_review_completion(usage: Dict[str, Any], result: str) -> None: @@ -975,37 +882,32 @@ def _log_review_completion(usage: Dict[str, Any], result: str) -> None: ) -# OpenRouter provider-routing pins: prompt caches live per UPSTREAM provider, -# so a fork without the parent's pins can land on a different upstream and -# miss the warm cache even with byte-identical prompt/tools bytes. +# OpenRouter provider-routing pins: prompt caches live per UPSTREAM provider, so a fork without +# the parent's pins can land on a different upstream and miss the warm cache even with +# byte-identical prompt/tools bytes. _PROVIDER_PIN_ATTRS = ( - "providers_allowed", - "providers_ignored", - "providers_order", - "provider_sort", - "provider_require_parameters", - "provider_data_collection", + "providers_allowed", "providers_ignored", "providers_order", "provider_sort", + "provider_require_parameters", "provider_data_collection", ) def _same_model_parity_kwargs(agent: Any) -> Dict[str, Any]: """AIAgent kwargs that keep a SAME-model fork's request bytes identical to the parent's. - Only for the un-routed path: on a different model the cache is cold - anyway, and the parent's reasoning-effort vocabulary may be invalid for - the routed provider (OpenRouter forwards ``reasoning.effort`` unclamped; - codex_responses passes ``max``/``ultra`` through unmapped). + Only for the un-routed path: on a different model the cache is cold anyway, and the parent's + reasoning-effort vocabulary may be invalid for the routed provider (OpenRouter forwards + ``reasoning.effort`` unclamped; codex_responses passes ``max``/``ultra`` through unmapped). """ kwargs: Dict[str, Any] = { # Anthropic's cache key is namespaced by ``thinking`` presence. "reasoning_config": getattr(agent, "reasoning_config", None), - # Gateway session context appended to the cached system prompt at - # API-call time; without it the effective system prompt diverges. + # Gateway session context appended to the cached system prompt at API-call time; + # without it the effective system prompt diverges. "ephemeral_system_prompt": getattr(agent, "ephemeral_system_prompt", None), } - # Prefill sits right after the system message, so a parent with prefill - # would diverge at index 1. Deep copy: unicode-error recovery sanitizes - # prefill entries IN PLACE and must not rewrite the parent's bytes. + # Prefill sits right after the system message, so a parent with prefill would diverge at + # index 1. Deep copy: unicode-error recovery sanitizes prefill entries IN PLACE and must not + # rewrite the parent's bytes. parent_prefill = copy.deepcopy(getattr(agent, "prefill_messages", None) or []) if parent_prefill: kwargs["prefill_messages"] = parent_prefill @@ -1019,25 +921,24 @@ def _same_model_parity_kwargs(agent: Any) -> Dict[str, Any]: def _detach_fork_compression(review_agent: Any) -> None: """Detached in-memory compaction for a fork sharing the parent's session_id. - Disabling compression (the old guard against compacting the parent's live - session) removed the only bound on the review's snapshot. Persistence is - already off, so compaction can only rewrite the fork's transcript — but the - compressor's own SessionDB/session_id binding must be severed too, or - cooldown/streak counters land on the parent's row. Force in-place mode and - re-enable compression ONLY after the rebind succeeded (fail-closed); gates - stay deferred until the first response so request #1 is a warm cache read. + Disabling compression (the old guard against compacting the parent's live session) removed + the only bound on the review's snapshot. Persistence is already off, so compaction can only + rewrite the fork's transcript — but the compressor's own SessionDB/session_id binding must be + severed too, or cooldown/streak counters land on the parent's row. Force in-place mode and + re-enable compression ONLY after the rebind succeeded (fail-closed); gates stay deferred + until the first response so request #1 is a warm cache read. """ bind = getattr(getattr(review_agent, "context_compressor", None), "bind_session_state", None) detached = False if callable(bind): try: - # Plugin/third-party context engines may reject these kwargs; they - # own their persistence policy, so a failed rebind never aborts the review. + # Plugin/third-party context engines may reject these kwargs; they own their + # persistence policy, so a failed rebind never aborts the review. bind(session_db=None, session_id="") detached = True except Exception: - # FAIL-CLOSED: the compressor may still point at the parent's - # SessionDB; enabling compression would re-open the sibling race. + # FAIL-CLOSED: the compressor may still point at the parent's SessionDB; enabling + # compression would re-open the sibling race. logger.warning( "background-review compressor detachment failed; " "keeping compression DISABLED on this review fork " @@ -1050,102 +951,98 @@ def _detach_fork_compression(review_agent: Any) -> None: review_agent._review_defer_compaction_before_first_response = True +def _fork_init_kwargs(agent: Any, rt: Dict[str, Any], routed: bool, max_iterations: int) -> Dict[str, Any]: + """AIAgent constructor kwargs for the review fork. + + skip_memory=True: an external memory plugin scoped to the parent's session_id would leak the + harness prompt into the user's real memory namespace; built-in MEMORY.md/USER.md state is + re-bound by the caller. Toolsets match the parent so ``tools[]`` is byte-identical + (Anthropic's cache key includes it); the runtime whitelist restricts dispatch. + """ + kwargs: Dict[str, Any] = { + "model": rt.get("model") or agent.model, + "max_iterations": max_iterations, + "quiet_mode": True, + "platform": agent.platform, + "provider": rt.get("provider") or agent.provider, + "api_mode": rt.get("api_mode"), + "base_url": rt.get("base_url") or None, + "api_key": rt.get("api_key") or None, + "credential_pool": rt.get("credential_pool"), + "request_overrides": rt.get("request_overrides") or {}, + "parent_session_id": agent.session_id, + "enabled_toolsets": getattr(agent, "enabled_toolsets", None), + "disabled_toolsets": getattr(agent, "disabled_toolsets", None), + "skip_memory": True, + } + if isinstance(rt.get("max_tokens"), int): + kwargs["max_tokens"] = rt["max_tokens"] + if isinstance(rt.get("command"), str) and rt["command"]: + kwargs["acp_command"] = rt["command"] + kwargs["acp_args"] = rt.get("args") or [] + if not routed: + kwargs.update(_same_model_parity_kwargs(agent)) + return kwargs + + def build_cache_parity_fork( - agent: Any, - task_cfg: Optional[Dict[str, Any]] = None, - *, - max_iterations: int, + agent: Any, task_cfg: Optional[Dict[str, Any]] = None, *, max_iterations: int, write_origin: str = "background_review", ) -> Tuple[Any, Dict[str, Any], bool]: """Construct a detached AIAgent fork with warm prompt-cache parity (shared with ``/btw``). - Same runtime/credentials as the parent, byte-identical system prompt / - tools[] / reasoning config on the same-model path, shared session_id for - prefix warmth, full persistence detachment (no state.db writes, rotation, - or external memory providers; in-place-only compaction). - Returns ``(fork_agent, runtime_dict, routed)``; ``routed`` means a different - model (cache cold — replay a digest). The caller owns registration, - whitelisting, running, usage attribution and teardown. + Same runtime/credentials as the parent, byte-identical system prompt / tools[] / reasoning + config on the same-model path, shared session_id for prefix warmth, full persistence + detachment (no state.db writes, rotation, or external memory providers; in-place-only + compaction). Returns ``(fork_agent, runtime_dict, routed)``; ``routed`` means a different + model (cache cold — replay a digest). The caller owns registration, whitelisting, running, + usage attribution and teardown. """ from run_agent import AIAgent # local: avoids a circular import at load - # Inherit the parent's live runtime: AIAgent.__init__'s env auto-resolution - # fails for OAuth-only providers, session-scoped creds and credential pools. + # Inherit the parent's live runtime: AIAgent.__init__'s env auto-resolution fails for + # OAuth-only providers, session-scoped creds and credential pools. _rt = _resolve_review_runtime(agent, task_cfg) _routed = bool(_rt.get("routed")) - _fork_kwargs: Dict[str, Any] = {} - if isinstance(_rt.get("max_tokens"), int): - _fork_kwargs["max_tokens"] = _rt["max_tokens"] - if isinstance(_rt.get("command"), str) and _rt["command"]: - _fork_kwargs["acp_command"] = _rt["command"] - _fork_kwargs["acp_args"] = _rt.get("args") or [] - if not _routed: - _fork_kwargs.update(_same_model_parity_kwargs(agent)) - # skip_memory=True: an external memory plugin scoped to the parent's - # session_id would leak the harness prompt into the user's real memory - # namespace; built-in MEMORY.md/USER.md state is re-bound below. Toolsets - # match the parent so ``tools[]`` is byte-identical (Anthropic's cache key - # includes it); the runtime whitelist restricts dispatch. - review_agent = AIAgent( - model=_rt.get("model") or agent.model, - max_iterations=max_iterations, - quiet_mode=True, - platform=agent.platform, - provider=_rt.get("provider") or agent.provider, - api_mode=_rt.get("api_mode"), - base_url=_rt.get("base_url") or None, - api_key=_rt.get("api_key") or None, - credential_pool=_rt.get("credential_pool"), - request_overrides=_rt.get("request_overrides") or {}, - parent_session_id=agent.session_id, - enabled_toolsets=getattr(agent, "enabled_toolsets", None), - disabled_toolsets=getattr(agent, "disabled_toolsets", None), - skip_memory=True, - **_fork_kwargs, - ) + review_agent = AIAgent(**_fork_init_kwargs(agent, _rt, _routed, max_iterations)) review_agent._memory_write_origin = write_origin review_agent._memory_write_context = write_origin - # The between-turns MCP refresh would add late-connecting MCP tools and - # break tools[] parity, so opt out. + # The between-turns MCP refresh would add late-connecting MCP tools and break tools[] parity. review_agent._skip_mcp_refresh = True review_agent._memory_store = agent._memory_store review_agent._memory_enabled = agent._memory_enabled review_agent._user_profile_enabled = agent._user_profile_enabled review_agent._memory_nudge_interval = 0 review_agent._skill_nudge_interval = 0 - # PERSISTENCE ISOLATION (curator-takeover root cause): sharing the parent's - # session_id, the fork would otherwise write its harness turn into the REAL - # session, which the next live turn re-reads as a standing instruction. + # PERSISTENCE ISOLATION (curator-takeover root cause): sharing the parent's session_id, the + # fork would otherwise write its harness turn into the REAL session, which the next live turn + # re-reads as a standing instruction. review_agent._persist_disabled = True review_agent._session_db = None review_agent._session_json_enabled = False - # Fork status/warning emits go via _print_fn/status_callback, which bypass - # the stdout redirect — suppress them. + # Fork status/warning emits go via _print_fn/status_callback, which bypass the stdout redirect. review_agent.suppress_status_output = True - # Same model only: share the warm cached system prompt (~26% cost cut; a - # rebuilt prompt misses the byte-exact prefix key) and pin session_start so - # any re-render (compression, plugin hooks) stays byte-identical. + # Same model only: share the warm cached system prompt (~26% cost cut; a rebuilt prompt misses + # the byte-exact prefix key) and pin session_start so any re-render (compression, plugin + # hooks) stays byte-identical. if not _routed: review_agent._cached_system_prompt = agent._cached_system_prompt review_agent.session_start = agent.session_start review_agent.session_id = agent.session_id - # Single-lifecycle fork sharing the live session_id: close() must not - # finalize the parent's still-active session row. + # Single-lifecycle fork sharing the live session_id: close() must not finalize the parent's + # still-active session row. review_agent._end_session_on_close = False _detach_fork_compression(review_agent) - # Compaction bounds a single request; this bounds the WHOLE review - # (checked in conversation_loop via _review_input_budget_exhausted). + # Compaction bounds a single request; this bounds the WHOLE review (checked in + # conversation_loop via _review_input_budget_exhausted). review_agent._review_input_token_budget = _review_input_token_budget(task_cfg) return review_agent, _rt, _routed def _bg_review_auto_deny(command, description, **kwargs): - """Non-interactive approval: dangerous-command guards resolve to "deny" - instead of input(), which would deadlock against the parent's TUI.""" - logger.warning( - "Background review auto-denied dangerous command: %s (%s)", - command, description, - ) + """Non-interactive approval: dangerous-command guards resolve to "deny" instead of input(), + which would deadlock against the parent's TUI.""" + logger.warning("Background review auto-denied dangerous command: %s (%s)", command, description) return "deny" @@ -1160,10 +1057,10 @@ def _set_thread_approval_callback(callback: Any) -> None: def _track_review_fork(agent: Any, review_agent: Any, *, register: bool) -> None: """Add (``register=True``) or remove the fork on the PARENT's tracking slots: - ``_background_review_agent`` (direct pointer the next live turn interrupts) - and ``_active_children`` (interrupt() fan-out). Removal is identity-scoped - and idempotent; both are best-effort for direct test stubs — the prepared - run token is the live-turn cancellation authority.""" + ``_background_review_agent`` (direct pointer the next live turn interrupts) and + ``_active_children`` (interrupt() fan-out). Removal is identity-scoped and idempotent; both + are best-effort for direct test stubs — the prepared run token is the live-turn cancellation + authority.""" if review_agent is None: return if hasattr(agent, "_background_review_agent"): @@ -1184,72 +1081,159 @@ def _track_review_fork(agent: Any, review_agent: Any, *, register: bool) -> None raise -def _review_tool_whitelist( - review_agent: Any, task_cfg: Optional[Dict[str, Any]] -) -> Tuple[set, set]: - """Return ``(whitelist, configured_extra_tools)`` for the review fork. - - DISPATCH-side only: the advertised ``tools[]`` stays byte-identical to the - parent's, so prompt-cache parity is untouched. - """ +def _review_tool_whitelist(review_agent: Any, task_cfg: Optional[Dict[str, Any]]) -> Tuple[set, set]: + """``(whitelist, configured_extra_tools)`` for the review fork — DISPATCH-side only, so the + advertised ``tools[]`` stays byte-identical to the parent's (prompt-cache parity).""" from model_tools import get_tool_definitions - # Gate the built-in memory tool on the profile's memory flags so a - # memory-disabled profile is never contaminated by the review LLM. + # Gate the built-in memory tool on the profile's memory flags so a memory-disabled profile + # is never contaminated by the review LLM. review_toolsets = ["skills"] if review_agent._memory_enabled or review_agent._user_profile_enabled: review_toolsets.insert(0, "memory") - whitelist = { - t["function"]["name"] - for t in get_tool_definitions(enabled_toolsets=review_toolsets, quiet_mode=True) - } - # Read-only file tools: denying read_file/search_files caused a per-review - # denial storm that starved the loop (read_file also registers the read - # with the read-before-write guard). Write tools stay denied — autonomous - # maintenance goes through skill_manage's validation. + whitelist = {t["function"]["name"] for t in get_tool_definitions(enabled_toolsets=review_toolsets, quiet_mode=True)} + # Read-only file tools: denying read_file/search_files caused a per-review denial storm that + # starved the loop (read_file also registers the read with the read-before-write guard). + # Write tools stay denied — autonomous maintenance goes through skill_manage's validation. whitelist |= {"read_file", "search_files"} - # ``extra_tools`` admits named parent tools (e.g. a human-gated proposal - # tool). The whitelist can only admit, never advertise: a listed tool must - # already exist in the inherited schema. + # ``extra_tools`` admits named parent tools (e.g. a human-gated proposal tool). The whitelist + # can only admit, never advertise: a listed tool must already exist in the inherited schema. configured_extra_tools: set = set() try: _extra_raw = _background_review_task_config(task_cfg).get("extra_tools", []) if isinstance(_extra_raw, list): - configured_extra_tools = { - name.strip() - for name in _extra_raw - if isinstance(name, str) and name.strip() - } + configured_extra_tools = {name.strip() for name in _extra_raw if isinstance(name, str) and name.strip()} whitelist |= configured_extra_tools except Exception: logger.debug("background_review extra_tools parse failed", exc_info=True) return whitelist, configured_extra_tools -def _run_review_in_thread( - agent: Any, - messages_snapshot: List[Dict], - prompt: str, - task_cfg: Optional[Dict[str, Any]] = None, - review_run: Optional[_BackgroundReviewRun] = None, +@dataclass +class _ReviewForkState: + """Mutable hand-off between the fork phase and the outer worker's error/cleanup paths.""" + + review_agent: Any = None + review_messages: List[Dict] = field(default_factory=list) + review_usage: Dict[str, Any] = field(default_factory=dict) + + +def _release_fork_clients(review_agent: Any) -> None: + """The fork shares the foreground session ID: close() / shutdown_memory_provider() are + session-bound (close() kills that session's terminal processes), so release only clients.""" + try: + review_agent.release_clients() + except Exception: + pass + + +def _finish_request_phase(agent: Any, review_agent: Any, review_run: Optional[_BackgroundReviewRun]) -> None: + """Unregister the fork and publish request completion (identity-scoped, idempotent).""" + _track_review_fork(agent, review_agent, register=False) + finish_background_review_run(agent, review_run) + + +def _run_review_fork( + agent: Any, messages_snapshot: List[Dict], prompt: str, task_cfg: Optional[Dict[str, Any]], + review_run: Optional[_BackgroundReviewRun], st: _ReviewForkState, ) -> None: - """Daemon-thread worker: build the fork, run the prompt, surface the action - summary via ``agent._safe_print`` / ``background_review_callback``. - ``review_run`` (from :func:`prepare_background_review_run`) cancelled before - the first provider call aborts without entering ``run_conversation()``.""" + """Fork phase (inside thread-scoped silence): build the fork, run the prompt under the tool + whitelist, snapshot its messages/usage, release its clients. Partial progress lands on ``st`` + so the caller's error path still sees usage and the fork to clean up.""" + st.review_agent, _rt, _routed = build_cache_parity_fork(agent, task_cfg, max_iterations=_REVIEW_MAX_ITERATIONS) + _track_review_fork(agent, st.review_agent, register=True) + + from hermes_cli.plugins import set_thread_tool_whitelist, clear_thread_tool_whitelist + + review_whitelist, configured_extra_tools = _review_tool_whitelist(st.review_agent, task_cfg) + extra_list = ", ".join(sorted(configured_extra_tools)) + set_thread_tool_whitelist( + review_whitelist, + deny_msg_fmt=( + "Background review denied non-whitelisted tool: " + "{tool_name}. Allowed here: skill_view/skills_list/" + "read_file/search_files to read, " + "skill_manage(action='patch'|...) to change skills, and " + "memory for notes." + + ( + " Configured extra tools also allowed: " + extra_list + "." + if configured_extra_tools + else "" + ) + + " Do not retry {tool_name}." + ), + ) + try: + from tools.skill_manager_tool import _reset_background_review_read_marks + + _reset_background_review_read_marks() + except Exception: + pass + + try: + if review_run is None or review_run.begin_request(st.review_agent): + # Routed -> digest (cache cold anyway); same model -> full snapshot (warm cache reads). + st.review_agent.run_conversation( + user_message=( + prompt + + "\n\nYou can only call memory and skill " + "management tools. Other tools will be denied " + "at runtime — do not attempt them." + + ( + " Exception — these configured tools are " + "also allowed: " + extra_list + "." + if configured_extra_tools + else "" + ) + ), + conversation_history=_digest_history(messages_snapshot) if _routed else messages_snapshot, + ) + finally: + clear_thread_tool_whitelist() + # Attribute usage to the PARENT session. Snapshot BEFORE unregister/close so counters + # survive teardown, and in this finally so a fork that consumed tokens then raised is + # still attributed. The recorder never raises. + if st.review_agent is not None: + st.review_usage.update(_snapshot_review_usage(st.review_agent)) + _record_review_usage_to_parent(agent, st.review_usage) + # Publish completion as soon as the provider-capable phase has returned or startup + # cancellation has fenced it out. + _finish_request_phase(agent, st.review_agent, review_run) + + st.review_messages = list(getattr(st.review_agent, "_session_messages", [])) + _release_fork_clients(st.review_agent) + st.review_agent = None + + +def _publish_review_summary(agent: Any, actions: List[str]) -> None: + summary = " · ".join(dict.fromkeys(actions)) + agent._safe_print(f" 💾 Self-improvement review: {summary}") + _bg_cb = agent.background_review_callback + if _bg_cb: + try: + _bg_cb(f"💾 Self-improvement review: {summary}") + except Exception: + pass + + +def _run_review_in_thread( + agent: Any, messages_snapshot: List[Dict], prompt: str, + task_cfg: Optional[Dict[str, Any]] = None, review_run: Optional[_BackgroundReviewRun] = None, +) -> None: + """Daemon-thread worker: build the fork, run the prompt, surface the action summary via + ``agent._safe_print`` / ``background_review_callback``. ``review_run`` (from + :func:`prepare_background_review_run`) cancelled before the first provider call aborts + without entering ``run_conversation()``.""" if review_run is not None and review_run.cancel_requested.is_set(): finish_background_review_run(agent, review_run) return _set_thread_approval_callback(_bg_review_auto_deny) - # A client that can't carry Hermes tool calls back would spawn a fork that - # cannot write anything. Checked BEFORE the thread-scoped silence so the - # warning is not swallowed; cheap check first so the normal path never - # resolves the runtime twice. - if not _parent_can_emit_tool_calls(agent) and not bool( - _resolve_review_runtime(agent, task_cfg).get("routed") - ): + # A client that can't carry Hermes tool calls back would spawn a fork that cannot write + # anything. Checked BEFORE the thread-scoped silence so the warning is not swallowed; cheap + # check first so the normal path never resolves the runtime twice. + if not _parent_can_emit_tool_calls(agent) and not bool(_resolve_review_runtime(agent, task_cfg).get("routed")): logger.warning( "Background review skipped: provider %r cannot emit Hermes tool calls, " "so the review fork could not write memories or skills. Set " @@ -1260,112 +1244,18 @@ def _run_review_in_thread( _set_thread_approval_callback(None) return - review_agent = None - review_messages: List[Dict] = [] - review_usage: Dict[str, Any] = {} - - def _finish_request_phase(agent_ref) -> None: - _track_review_fork(agent, agent_ref, register=False) - finish_background_review_run(agent, review_run) - - def _release(agent_ref) -> None: - try: - agent_ref.release_clients() - except Exception: - pass - + st = _ReviewForkState() try: - # Silence stdout/stderr for THIS thread only: a process-global redirect - # would blank every other thread's console for the whole review. + # Silence stdout/stderr for THIS thread only: a process-global redirect would blank every + # other thread's console for the whole review. with thread_scoped_silence(): - review_agent, _rt, _routed = build_cache_parity_fork( - agent, task_cfg, max_iterations=_REVIEW_MAX_ITERATIONS - ) - _track_review_fork(agent, review_agent, register=True) + _run_review_fork(agent, messages_snapshot, prompt, task_cfg, review_run, st) - from hermes_cli.plugins import ( - set_thread_tool_whitelist, - clear_thread_tool_whitelist, - ) - - review_whitelist, configured_extra_tools = _review_tool_whitelist( - review_agent, task_cfg - ) - extra_list = ", ".join(sorted(configured_extra_tools)) - set_thread_tool_whitelist( - review_whitelist, - deny_msg_fmt=( - "Background review denied non-whitelisted tool: " - "{tool_name}. Allowed here: skill_view/skills_list/" - "read_file/search_files to read, " - "skill_manage(action='patch'|...) to change skills, and " - "memory for notes." - + ( - " Configured extra tools also allowed: " + extra_list + "." - if configured_extra_tools - else "" - ) - + " Do not retry {tool_name}." - ), - ) - try: - from tools.skill_manager_tool import _reset_background_review_read_marks - - _reset_background_review_read_marks() - except Exception: - pass - - try: - if review_run is None or review_run.begin_request(review_agent): - # Routed -> digest (cache cold anyway); same model -> full - # snapshot (warm cache reads). - review_agent.run_conversation( - user_message=( - prompt - + "\n\nYou can only call memory and skill " - "management tools. Other tools will be denied " - "at runtime — do not attempt them." - + ( - " Exception — these configured tools are " - "also allowed: " + extra_list + "." - if configured_extra_tools - else "" - ) - ), - conversation_history=( - _digest_history(messages_snapshot) if _routed - else messages_snapshot - ), - ) - finally: - clear_thread_tool_whitelist() - # Attribute usage to the PARENT session. Snapshot BEFORE - # unregister/close so counters survive teardown, and in this - # finally so a fork that consumed tokens then raised is still - # attributed. The recorder never raises. - if review_agent is not None: - review_usage.update(_snapshot_review_usage(review_agent)) - _record_review_usage_to_parent(agent, review_usage) - # Publish completion as soon as the provider-capable phase has - # returned or startup cancellation has fenced it out. - _finish_request_phase(review_agent) - - # Snapshot review actions before teardown. - review_messages = list(getattr(review_agent, "_session_messages", [])) - - # The fork shares the foreground session ID: close() / - # shutdown_memory_provider() are session-bound (close() kills that - # session's terminal processes), so release only this fork's clients. - _release(review_agent) - review_agent = None - - # A buggy/legacy tool response shape must NOT take down the whole - # review (the outer except would discard every action the fork DID - # complete), so coerce to an empty list. + # A buggy/legacy tool response shape must NOT take down the whole review (the outer + # except would discard every action the fork DID complete), so coerce to an empty list. try: actions = summarize_background_review_actions( - review_messages, - messages_snapshot, + st.review_messages, messages_snapshot, notification_mode=getattr(agent, "memory_notifications", "on"), ) except Exception as e: @@ -1377,33 +1267,24 @@ def _run_review_in_thread( ) actions = [] - _log_review_completion(review_usage, _classify_review_result(actions)) - + _log_review_completion(st.review_usage, _classify_review_result(actions)) if actions: - summary = " · ".join(dict.fromkeys(actions)) - agent._safe_print(f" 💾 Self-improvement review: {summary}") - _bg_cb = agent.background_review_callback - if _bg_cb: - try: - _bg_cb(f"💾 Self-improvement review: {summary}") - except Exception: - pass + _publish_review_summary(agent, actions) except Exception as e: logger.warning("Background memory/skill review failed: %s", e) - if review_usage: - _log_review_completion(review_usage, "error") + if st.review_usage: + _log_review_completion(st.review_usage, "error") agent._emit_auxiliary_failure("background review", e) finally: - # Safety net for the exception path (setup failures before the - # request-phase finally). Both cleanups are identity-scoped and - # idempotent; re-enter thread-scoped silence so cleanup output stays - # quiet without blanking other threads. - _finish_request_phase(review_agent) - if review_agent is not None: + # Safety net for the exception path (setup failures before the request-phase finally). + # Both cleanups are identity-scoped and idempotent; re-enter thread-scoped silence so + # cleanup output stays quiet without blanking other threads. + _finish_request_phase(agent, st.review_agent, review_run) + if st.review_agent is not None: try: with thread_scoped_silence(): - _release(review_agent) + _release_fork_clients(st.review_agent) except Exception: pass # Clear the approval callback so a recycled thread-id doesn't inherit it. @@ -1411,20 +1292,16 @@ def _run_review_in_thread( def spawn_background_review_thread( - agent: Any, - messages_snapshot: List[Dict], - review_memory: bool = False, - review_skills: bool = False, - focus: Optional[str] = None, - task_cfg: Optional[Dict[str, Any]] = None, - review_run: Optional[_BackgroundReviewRun] = None, + agent: Any, messages_snapshot: List[Dict], review_memory: bool = False, + review_skills: bool = False, focus: Optional[str] = None, + task_cfg: Optional[Dict[str, Any]] = None, review_run: Optional[_BackgroundReviewRun] = None, ): - """Return ``(target, prompt)``; the caller builds the ``threading.Thread`` so - test patches of ``run_agent.threading.Thread`` keep working. + """Return ``(target, prompt)``; the caller builds the ``threading.Thread`` so test patches of + ``run_agent.threading.Thread`` keep working. - ``focus`` (``/refine [instructions]``) is appended to the chosen prompt; - automatic reviews pass ``None``. ``task_cfg`` is the pre-loaded - ``auxiliary.background_review`` block; when omitted it is read once here. + ``focus`` (``/refine [instructions]``) is appended to the chosen prompt; automatic reviews + pass ``None``. ``task_cfg`` is the pre-loaded ``auxiliary.background_review`` block; when + omitted it is read once here. """ if task_cfg is None: task_cfg = _background_review_task_config() @@ -1445,14 +1322,8 @@ def spawn_background_review_thread( f"{focus}" ) - def _target() -> None: - _run_review_in_thread( - agent, - messages_snapshot, - prompt, - task_cfg=task_cfg, - review_run=review_run, - ) + def _target() -> None: # resolves _run_review_in_thread at call time (tests patch it) + _run_review_in_thread(agent, messages_snapshot, prompt, task_cfg=task_cfg, review_run=review_run) return _target, prompt diff --git a/tests/test_background_review_list_shapes.py b/tests/test_background_review_list_shapes.py index 79b003a2bb..ada4c151d9 100644 --- a/tests/test_background_review_list_shapes.py +++ b/tests/test_background_review_list_shapes.py @@ -286,8 +286,7 @@ def test_e_call_does_not_unwind_module_callables(): "summarize_background_review_actions returned partial results" in src ), "expected partial-results guard message present" - # And the prior-tonon-dict guard for the call_details lookup. - assert "if not isinstance(detail, dict):" in src + # And the non-dict guards on free-form tool payload fields. assert "if isinstance(ops_raw, list)" in src assert "if isinstance(change_raw, dict)" in src From 5eb0f4f7d125020400c76dabaaf52b2033d4677c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:03:50 -0700 Subject: [PATCH 02/12] refactor(agent/anthropic_message_convert): share _strip_thinking, extract latest-turn thinking filter, compact docstrings; endpoints docstrings --- agent/anthropic_endpoints.py | 110 +++------ agent/anthropic_message_convert.py | 376 ++++++++++++----------------- 2 files changed, 188 insertions(+), 298 deletions(-) diff --git a/agent/anthropic_endpoints.py b/agent/anthropic_endpoints.py index a9052567d2..fbd871dde0 100644 --- a/agent/anthropic_endpoints.py +++ b/agent/anthropic_endpoints.py @@ -1,14 +1,11 @@ """Endpoint-family detection for Anthropic-compatible base URLs. -A dozen services speak the Anthropic Messages API but differ in auth style, -accepted beta headers, and request quirks (MiniMax, Kimi/Moonshot, DeepSeek, -OpenCode, Azure AI Foundry, Nous Portal, Bedrock). Every such difference is -decided from the configured base URL, so the predicates live together here. - -Pure functions over a base-URL string - no I/O, no SDK, no credentials - so -both ``agent/anthropic_adapter.py`` and ``agent/anthropic_message_convert.py`` -can depend on this module without a cycle. ``agent.anthropic_adapter`` -re-exports every name below. +A dozen services speak the Anthropic Messages API but differ in auth style, accepted beta +headers, and request quirks (MiniMax, Kimi/Moonshot, DeepSeek, OpenCode, Azure AI Foundry, Nous +Portal, Bedrock). Every such difference is decided from the configured base URL, so the +predicates live together here as pure functions (no I/O, SDK or credentials) that both +``agent/anthropic_adapter.py`` and ``agent/anthropic_message_convert.py`` can import without a +cycle. ``agent.anthropic_adapter`` re-exports every name below. """ from urllib.parse import urlparse @@ -20,9 +17,7 @@ _MINIMAX_ANTHROPIC_PREFIXES = ("https://api.minimax.io/anthropic", "https://api. def _normalize_base_url_text(base_url) -> str: """Coerce a base URL (str or ``httpx.URL``) to a stripped string; "" when falsy.""" - if not base_url: - return "" - return str(base_url).strip() + return str(base_url).strip() if base_url else "" def _normalized_lower(base_url) -> str: @@ -31,39 +26,30 @@ def _normalized_lower(base_url) -> str: def _is_third_party_anthropic_endpoint(base_url: str | None) -> bool: - """True for any non-anthropic.com endpoint (own API keys via x-api-key; skip OAuth detection). - - No base_url means the direct Anthropic API. - """ + """Any non-anthropic.com endpoint (own x-api-key keys; skip OAuth detection). No base_url = + direct Anthropic API.""" normalized = _normalized_lower(base_url) return bool(normalized) and "anthropic.com" not in normalized def _is_kimi_coding_endpoint(base_url: str | None) -> bool: - """True for Kimi's /coding endpoint, which requires a claude-code User-Agent.""" + """Kimi's /coding endpoint, which requires a claude-code User-Agent.""" return _normalized_lower(base_url).startswith("https://api.kimi.com/coding") def _is_opencode_endpoint(base_url: str | None) -> bool: - """True for OpenCode's Zen/Go relay (opencode.ai).""" + """OpenCode's Zen/Go relay (opencode.ai).""" return base_url_host_matches(base_url or "", "opencode.ai") -# Model-name prefixes identifying the Kimi / Moonshot family: official slugs -# (``kimi-k2.5``, ``kimi_thinking``, ``moonshot-v1-8k``) and release lines -# (``k1.5-…``, ``k2-thinking``, ``k25-…``, ``k3.x``/``k3-…``). Matched -# case-insensitively after stripping any ``vendor/`` prefix. +# Kimi / Moonshot family model-name prefixes: official slugs (``kimi-k2.5``, ``kimi_thinking``, +# ``moonshot-v1-8k``) and release lines (``k1.5-…``, ``k2-thinking``, ``k25-…``, ``k3.x``/``k3-…``). +# Matched case-insensitively after stripping any ``vendor/`` prefix. _KIMI_FAMILY_MODEL_PREFIXES = ( - "kimi-", "kimi_", - "moonshot-", "moonshot_", - "k1.", "k1-", - "k2.", "k2-", - "k25", "k2.5", - "k3.", "k3-", + "kimi-", "kimi_", "moonshot-", "moonshot_", "k1.", "k1-", "k2.", "k2-", "k25", "k2.5", "k3.", "k3-", ) - -# Bare release slugs with no separator suffix (Kimi Coding Plan serves K3 as -# exactly ``k3``). Exact-match so unrelated names sharing the prefix don't match. +# Bare release slugs with no separator suffix (Kimi Coding Plan serves K3 as exactly ``k3``); +# exact-match so unrelated names sharing the prefix don't match. _KIMI_FAMILY_EXACT_SLUGS = frozenset({"k3"}) @@ -79,14 +65,10 @@ def _model_name_is_kimi_family(model: str | None) -> bool: def _is_kimi_family_endpoint(base_url: str | None, model: str | None = None) -> bool: - """True for any Kimi / Moonshot Anthropic-Messages endpoint. - - Broader than ``_is_kimi_coding_endpoint``: also matches any api.kimi.com / - moonshot.ai / moonshot.cn host, and any endpoint (e.g. a private gateway) - whose *model* is in the Kimi family — the upstream still enforces Kimi's - thinking semantics regardless of hostname. Decides whether unsigned - reasoning_content-derived thinking blocks are preserved on replay. - """ + """Any Kimi / Moonshot Anthropic-Messages endpoint: the /coding endpoint, any api.kimi.com / + moonshot.ai / moonshot.cn host, or any endpoint (e.g. a private gateway) whose *model* is in + the Kimi family — the upstream enforces Kimi's thinking semantics regardless of hostname. + Decides whether unsigned reasoning_content-derived thinking blocks are preserved on replay.""" if _is_kimi_coding_endpoint(base_url): return True if any(base_url_host_matches(base_url or "", d) for d in ("api.kimi.com", "moonshot.ai", "moonshot.cn")): @@ -95,27 +77,20 @@ def _is_kimi_family_endpoint(base_url: str | None, model: str | None = None) -> def _is_deepseek_anthropic_endpoint(base_url: str | None) -> bool: - """True for DeepSeek's ``/anthropic`` route. - - In thinking mode DeepSeek requires prior-turn ``thinking`` blocks to round-trip - ("The content[].thinking in the thinking mode must be passed back to the API"), - while the generic third-party path strips them. Its blocks are unsigned, so it - gets the same strip-signed / keep-unsigned policy as Kimi. Pinned to the - ``/anthropic`` path so the OpenAI-compatible base URL is not misclassified. - """ + """DeepSeek's ``/anthropic`` route. In thinking mode DeepSeek requires prior-turn ``thinking`` + blocks to round-trip while the generic third-party path strips them; its blocks are unsigned, + so it gets the same strip-signed / keep-unsigned policy as Kimi. Pinned to the ``/anthropic`` + path so the OpenAI-compatible base URL is not misclassified.""" if not base_url_host_matches(base_url or "", "api.deepseek.com"): return False return "/anthropic" in _normalized_lower(base_url) def _is_nous_portal_endpoint(base_url: str | None) -> bool: - """True for Nous Portal's Anthropic Messages route (Bearer JWT, verbatim catalog - ids, native thinking-signature replay). - - Trusted hosts only: prod ``inference-api.nousresearch.com`` or the operator-set - ``NOUS_INFERENCE_BASE_URL`` host (exact hostname equality, so neither lookalike - domains nor sibling hosts of the override match). - """ + """Nous Portal's Anthropic Messages route (Bearer JWT, verbatim catalog ids, native + thinking-signature replay). Trusted hosts only: prod ``inference-api.nousresearch.com`` or the + operator-set ``NOUS_INFERENCE_BASE_URL`` host (exact hostname equality, so neither lookalike + domains nor sibling hosts of the override match).""" if base_url_host_matches(base_url or "", "inference-api.nousresearch.com"): return True try: @@ -131,13 +106,9 @@ def _is_nous_portal_endpoint(base_url: str | None) -> bool: def _requires_bearer_auth(base_url: str | None) -> bool: - """True for Anthropic-compatible providers that need ``Authorization: Bearer`` - instead of ``x-api-key``: MiniMax, Azure AI Foundry, Palantir Foundry's LLM - proxy, CommandCode, and Nous Portal. - - Palantir/CommandCode use hostname matching (not substring) so e.g. - ``evil.com/palantirfoundry`` paths don't trigger Bearer auth. - """ + """Providers needing ``Authorization: Bearer`` instead of ``x-api-key``: MiniMax, Azure AI + Foundry, Palantir Foundry's LLM proxy, CommandCode, Nous Portal. Palantir/CommandCode use + hostname matching (not substring) so ``evil.com/palantirfoundry`` paths don't trigger it.""" if _is_nous_portal_endpoint(base_url): return True normalized = _normalized_lower(base_url) @@ -152,24 +123,21 @@ def _requires_bearer_auth(base_url: str | None) -> bool: def _base_url_needs_context_1m_beta(base_url: str | None) -> bool: - """True for endpoints that still gate 1M context behind a beta (Azure).""" + """Endpoints that still gate 1M context behind a beta (Azure).""" return "azure.com" in _normalize_base_url_text(base_url).lower() def _is_minimax_anthropic_endpoint(base_url: str | None) -> bool: - """True for MiniMax's Anthropic-compatible endpoints, which reject the - fine-grained-tool-streaming and context-1m betas (stripped even though MiniMax - also uses Bearer auth).""" + """MiniMax's Anthropic-compatible endpoints, which reject the fine-grained-tool-streaming and + context-1m betas (stripped even though MiniMax also uses Bearer auth).""" return _normalized_lower(base_url).startswith(_MINIMAX_ANTHROPIC_PREFIXES) def _is_azure_anthropic_endpoint(base_url: str | None) -> bool: - """True for Azure-hosted Anthropic Messages endpoints serving ``/anthropic``: - modern Foundry (``*.services.ai.azure.*``) and legacy Azure OpenAI - (``*.openai.azure.*``) hosts. Opts them into ``api-version`` query plumbing. - - Deliberately no finite TLD allow-list, so sovereign/private clouds work. - """ + """Azure-hosted Anthropic Messages endpoints serving ``/anthropic``: modern Foundry + (``*.services.ai.azure.*``) and legacy Azure OpenAI (``*.openai.azure.*``) hosts; opts them + into ``api-version`` query plumbing. Deliberately no finite TLD allow-list, so + sovereign/private clouds work.""" normalized = _normalize_base_url_text(base_url) if not normalized: return False diff --git a/agent/anthropic_message_convert.py b/agent/anthropic_message_convert.py index f3bca75ff6..c053be4311 100644 --- a/agent/anthropic_message_convert.py +++ b/agent/anthropic_message_convert.py @@ -1,14 +1,10 @@ """OpenAI-style -> Anthropic Messages API request conversion. -Everything here rewrites *request payloads*: model-id normalization, tool -schemas, and the message list (content blocks, thinking blocks and their -signatures, tool_use/tool_result pairing, cache_control placement, screenshot -eviction, blank-block scrubbing). - -Split out of ``agent/anthropic_adapter.py`` so the adapter keeps client -construction and the API call itself. Endpoint predicates come from -``agent/anthropic_endpoints.py``, so this module never imports the adapter and -there is no cycle. ``agent.anthropic_adapter`` re-exports every name below. +Everything here rewrites *request payloads*: model-id normalization, tool schemas, and the +message list (content blocks, thinking blocks and their signatures, tool_use/tool_result +pairing, cache_control placement, screenshot eviction, blank-block scrubbing). Endpoint +predicates come from ``agent/anthropic_endpoints.py``, so this module never imports the adapter +and there is no cycle. ``agent.anthropic_adapter`` re-exports every name below. """ import copy @@ -18,9 +14,7 @@ import re from typing import Any, Dict, List, Optional, Tuple from agent.anthropic_endpoints import ( - _is_deepseek_anthropic_endpoint, - _is_kimi_family_endpoint, - _is_nous_portal_endpoint, + _is_deepseek_anthropic_endpoint, _is_kimi_family_endpoint, _is_nous_portal_endpoint, _is_third_party_anthropic_endpoint, ) @@ -29,14 +23,13 @@ logger = logging.getLogger(__name__) _THINKING_TYPES = frozenset(("thinking", "redacted_thinking")) _CACHEABLE_TYPES = frozenset(("text", "tool_use")) _EMPTY_TEXT_PLACEHOLDER = "(empty)" +_EMPTY_SCHEMA = {"type": "object", "properties": {}} _BEDROCK_REGION_PREFIXES = ( "global.", "us.", "eu.", "apac.", "ap.", "au.", "jp.", "ca.", "sa.", "me.", "af.", ) -# --------------------------------------------------------------------------- -# Small shared predicates -# --------------------------------------------------------------------------- +# ----- small shared predicates ----- def _block_type(b: Any) -> Any: @@ -49,12 +42,8 @@ def _has_block_type(blocks: List[Any], types) -> bool: def _is_blank_text_block(b: Any) -> bool: - """A text block whose ``text`` is not a non-whitespace string (None/int/blank all count). - - Anthropic rejects such blocks with HTTP 400 ("text content blocks must contain - non-whitespace text"); checking isinstance first keeps a non-string from - reaching ``.strip()``. - """ + """A text block whose ``text`` is not a non-whitespace string (None/int/blank all count) — + Anthropic 400s on them ("text content blocks must contain non-whitespace text").""" if _block_type(b) != "text": return False text = b.get("text") @@ -78,26 +67,24 @@ def _parse_tool_args(raw: Any) -> Any: return {} -# --------------------------------------------------------------------------- -# Model / tool conversion -# --------------------------------------------------------------------------- +def _strip_thinking(blocks: List[Any]) -> List[Any]: + return [b for b in blocks if _block_type(b) not in _THINKING_TYPES] + + +# ----- model / tool conversion ----- def _is_bedrock_model_id(model: str) -> bool: - """Bedrock ids (``anthropic.claude-opus-4-7``, ``us.anthropic.claude-*``) use dots - as namespace separators that must be preserved verbatim.""" + """Bedrock ids (``anthropic.claude-opus-4-7``, ``us.anthropic.claude-*``) use dots as namespace + separators that must be preserved verbatim.""" return model.lower().startswith(_BEDROCK_REGION_PREFIXES + ("anthropic.",)) def normalize_model_name(model: str, preserve_dots: bool = False) -> str: - """Normalize a model name for the Anthropic API. - - Strips the ``anthropic/`` prefix (case-insensitive) and, unless - ``preserve_dots`` (DashScope: ``qwen3.5-plus``), converts version dots to - hyphens for Claude models only (``claude-opus-4.6`` -> ``claude-opus-4-6``). - Bedrock ids keep their namespace dots; non-Anthropic names (``gpt-5.4``) - keep dots as part of their canonical form. - """ + """Strip the ``anthropic/`` prefix (case-insensitive) and, unless ``preserve_dots`` (DashScope: + ``qwen3.5-plus``), convert version dots to hyphens for Claude models only (``claude-opus-4.6`` + -> ``claude-opus-4-6``). Bedrock ids keep their namespace dots; non-Anthropic names + (``gpt-5.4``) keep dots as part of their canonical form.""" if model.lower().startswith("anthropic/"): model = model[len("anthropic/"):] if not preserve_dots and not _is_bedrock_model_id(model) and model.lower().startswith(("claude-", "anthropic/")): @@ -113,22 +100,19 @@ def _sanitize_tool_id(tool_id: str) -> str: def _normalize_tool_input_schema(schema: Any) -> Dict[str, Any]: - """Normalize a tool schema for Anthropic's validator. - - Collapses nullable unions (``anyOf: [{type: string}, {type: null}]``, which - Pydantic/MCP emit for optional fields) to the non-null branch — optionality is - already expressed by ``required``. ``keep_nullable_hint=False`` because the - OpenAPI ``nullable`` keyword is not recognized. Top-level oneOf/allOf/anyOf are - rejected with a generic 400, so they are dropped in favour of a plain object. - """ + """Normalize a tool schema for Anthropic's validator: collapse nullable unions (``anyOf: + [{type: string}, {type: null}]`` from Pydantic/MCP optional fields) to the non-null branch — + optionality is already expressed by ``required``; ``keep_nullable_hint=False`` because the + OpenAPI ``nullable`` keyword is not recognized. Top-level oneOf/allOf/anyOf are rejected with a + generic 400, so they are dropped in favour of a plain object.""" if not schema: - return {"type": "object", "properties": {}} + return dict(_EMPTY_SCHEMA) from tools.schema_sanitizer import strip_nullable_unions normalized = strip_nullable_unions(schema, keep_nullable_hint=False) if not isinstance(normalized, dict): - return {"type": "object", "properties": {}} + return dict(_EMPTY_SCHEMA) banned = {"oneOf", "allOf", "anyOf"} if banned & normalized.keys(): normalized = {k: v for k, v in normalized.items() if k not in banned} @@ -139,11 +123,8 @@ def _normalize_tool_input_schema(schema: Any) -> Dict[str, Any]: def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]: - """Convert OpenAI tool definitions to Anthropic format. - - Duplicate names are dropped with a warning (Anthropic hard-400s on them); - ``cache_control`` on the OpenAI tool dict is forwarded. - """ + """Convert OpenAI tool definitions to Anthropic format. Duplicate names are dropped with a + warning (Anthropic hard-400s on them); ``cache_control`` on the OpenAI tool dict is forwarded.""" if not tools: return [] result = [] @@ -152,9 +133,7 @@ def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]: fn = t.get("function", {}) name = fn.get("name", "") if name and name in seen_names: - logger.warning( - "convert_tools_to_anthropic: duplicate tool name '%s' — dropping second occurrence", name - ) + logger.warning("convert_tools_to_anthropic: duplicate tool name '%s' — dropping second occurrence", name) continue if name: seen_names.add(name) @@ -170,9 +149,7 @@ def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]: return result -# --------------------------------------------------------------------------- -# Content-part conversion -# --------------------------------------------------------------------------- +# ----- content-part conversion ----- def _image_source_from_openai_url(url: str) -> Dict[str, str]: @@ -197,9 +174,8 @@ def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]: ptype = part.get("type") if ptype in ("input_text", "text"): - # Rebuild from whitelisted fields only: stored SDK text blocks carry - # output-only siblings (parsed_output, citations=None) that the INPUT - # schema rejects with 400 "Extra inputs are not permitted". + # Rebuild from whitelisted fields only: stored SDK text blocks carry output-only siblings + # (parsed_output, citations=None) that the INPUT schema rejects with 400. block: Dict[str, Any] = _text_block(part.get("text", "")) cits = part.get("citations") if ptype == "text" and isinstance(cits, list) and cits: @@ -218,12 +194,9 @@ def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]: def _to_plain_data(value: Any, *, _depth: int = 0, _path: Optional[set] = None) -> Any: - """Recursively convert SDK objects to plain Python data. - - ``_path`` tracks ids on the *current* recursion path (so shared but - non-cyclic objects convert normally while true cycles stringify); depth is - capped at 20. - """ + """Recursively convert SDK objects to plain Python data. ``_path`` tracks ids on the *current* + recursion path (shared but non-cyclic objects convert normally while true cycles stringify); + depth is capped at 20.""" if _depth > 20: return str(value) if _path is None: @@ -238,8 +211,8 @@ def _to_plain_data(value: Any, *, _depth: int = 0, _path: Optional[set] = None) _path.add(obj_id) if hasattr(value, "model_dump"): try: - # warnings=False: streaming-accumulator blocks trip pydantic's - # serializer-mismatch UserWarning, which otherwise leaks to the terminal. + # warnings=False: streaming-accumulator blocks trip pydantic's serializer-mismatch + # UserWarning, which otherwise leaks to the terminal. dumped = value.model_dump(warnings=False) except TypeError: # duck-typed model_dump without pydantic's signature dumped = value.model_dump() @@ -276,8 +249,8 @@ def _convert_content_to_anthropic(content: Any) -> Any: def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]: - """Tool-message content parts -> tool_result inner blocks (text + image only, - the types Anthropic accepts there). Used for multimodal tool results.""" + """Tool-message content parts -> tool_result inner blocks (text + image only, the types + Anthropic accepts there). Used for multimodal tool results.""" if not isinstance(parts, list): return [] out: List[Dict[str, Any]] = [] @@ -293,12 +266,9 @@ def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]: def _safe_text(text: Any) -> str: - """``text`` if non-whitespace, else the placeholder. - - A blank text block stored in history (e.g. by compression) is replayed on every - turn and wedges the session with HTTP 400; the placeholder is self-healing. - Mirrors ``bedrock_adapter._safe_text`` (kept separate on purpose). - """ + """``text`` if non-whitespace, else the placeholder. A blank text block stored in history (e.g. + by compression) is replayed on every turn and wedges the session with HTTP 400; the placeholder + is self-healing. Mirrors ``bedrock_adapter._safe_text`` (kept separate on purpose).""" if text is None: return _EMPTY_TEXT_PLACEHOLDER if not isinstance(text, str): @@ -306,15 +276,13 @@ def _safe_text(text: Any) -> str: return text if text.strip() else _EMPTY_TEXT_PLACEHOLDER -# --------------------------------------------------------------------------- -# Replay-block sanitizing (per-type whitelist) -# --------------------------------------------------------------------------- +# ----- replay-block sanitizing (per-type whitelist) ----- def _replay_text(b: Dict[str, Any]) -> Optional[Dict[str, Any]]: - # Drop blank blocks rather than coerce in place: the caller relocates any - # cache_control and falls back to a placeholder only when nothing survives, - # so "(empty)" never sits as model-visible noise next to real blocks. + # Drop blank blocks rather than coerce in place: the caller relocates any cache_control and + # falls back to a placeholder only when nothing survives, so "(empty)" never sits as + # model-visible noise next to real blocks. if _is_blank_text_block(b): return None out: Dict[str, Any] = _text_block(b["text"]) @@ -339,9 +307,7 @@ def _replay_redacted_thinking(b: Dict[str, Any]) -> Optional[Dict[str, Any]]: def _replay_tool_use(b: Dict[str, Any]) -> Dict[str, Any]: out = { - "type": "tool_use", - "id": _sanitize_tool_id(b.get("id", "")), - "name": b.get("name", ""), + "type": "tool_use", "id": _sanitize_tool_id(b.get("id", "")), "name": b.get("name", ""), "input": b.get("input", {}), } if _cache_control_of(b) is not None: @@ -355,33 +321,24 @@ def _replay_image(b: Dict[str, Any]) -> Optional[Dict[str, Any]]: _REPLAY_SANITIZERS = { - "text": _replay_text, - "thinking": _replay_thinking, - "redacted_thinking": _replay_redacted_thinking, - "tool_use": _replay_tool_use, - "image": _replay_image, + "text": _replay_text, "thinking": _replay_thinking, "redacted_thinking": _replay_redacted_thinking, + "tool_use": _replay_tool_use, "image": _replay_image, } def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """Whitelist a stored Anthropic block so it is valid as REQUEST input. - - SDK response blocks carry output-only fields the INPUT schema forbids - ("Extra inputs are not permitted": ``parsed_output``, ``caller``, - ``citations=None``), and ``_to_plain_data`` captured them verbatim. Whitelist - per type (not blacklist) so future SDK fields can't reintroduce the bug; - unknown types are dropped. Returns a clean block or None. - """ + """Whitelist a stored Anthropic block so it is valid as REQUEST input. SDK response blocks carry + output-only fields the INPUT schema forbids ("Extra inputs are not permitted": ``parsed_output``, + ``caller``, ``citations=None``), and ``_to_plain_data`` captured them verbatim. Whitelist per + type (not blacklist) so future SDK fields can't reintroduce the bug; unknown types are dropped. + Returns a clean block or None.""" if not isinstance(b, dict): return None sanitizer = _REPLAY_SANITIZERS.get(b.get("type")) return sanitizer(b) if sanitizer else None -def _apply_assistant_cache_control_to_last_cacheable_block( - blocks: List[Dict[str, Any]], - cache_control: Any, -) -> None: +def _apply_assistant_cache_control_to_last_cacheable_block(blocks: List[Dict[str, Any]], cache_control: Any) -> None: if not isinstance(cache_control, dict): return for block in reversed(blocks): @@ -390,20 +347,15 @@ def _apply_assistant_cache_control_to_last_cacheable_block( break -# --------------------------------------------------------------------------- -# Per-message conversion -# --------------------------------------------------------------------------- +# ----- per-message conversion ----- def _replay_ordered_blocks(m: Dict[str, Any], ordered_blocks: List[Any]) -> Optional[List[Dict[str, Any]]]: - """Interleaved-thinking replay: rebuild the assistant turn from the verbatim - block list normalize_response stored (only for turns interleaving SIGNED - thinking with tool_use). Preserves block ORDER; returns None if nothing survives. - - tool_use ``input`` is re-sourced from ``tool_calls`` (redacted at storage time) - rather than the captured block (raw API response, NOT redacted), so a secret - the model inlined into a tool call never goes back on the wire. - """ + """Interleaved-thinking replay: rebuild the assistant turn from the verbatim block list + normalize_response stored (only for turns interleaving SIGNED thinking with tool_use). + Preserves block ORDER; returns None if nothing survives. tool_use ``input`` is re-sourced from + ``tool_calls`` (redacted at storage time) rather than the captured block (raw API response, NOT + redacted), so a secret the model inlined into a tool call never goes back on the wire.""" redacted_input_by_id = { _sanitize_tool_id(tc.get("id", "")): _parse_tool_args((tc.get("function", {}) or {}).get("arguments", "{}")) for tc in m.get("tool_calls", []) or [] @@ -425,17 +377,17 @@ def _replay_ordered_blocks(m: Dict[str, Any], ordered_blocks: List[Any]) -> Opti if redacted is not None: clean["input"] = redacted replayed.append(clean) - # Nothing cacheable survived (e.g. signed thinking + blank text): emit the - # placeholder so the turn stays schema-valid and a relocated marker has a carrier. + # Nothing cacheable survived (e.g. signed thinking + blank text): emit the placeholder so the + # turn stays schema-valid and a relocated marker has a carrier. if not _has_block_type(replayed, _CACHEABLE_TYPES) and (dropped_blank_text or relocated_cc is not None): replayed.append(_text_block(_EMPTY_TEXT_PLACEHOLDER)) if not replayed: return None _apply_assistant_cache_control_to_last_cacheable_block(replayed, relocated_cc) _apply_assistant_cache_control_to_last_cacheable_block(replayed, m.get("cache_control")) - # prompt_caching marks an assistant turn with text by writing cache_control - # INTO ``content`` (not top-level). This path never reads ``content``, so - # carry that marker over or the breakpoint is burned rather than relocated. + # prompt_caching marks an assistant turn with text by writing cache_control INTO ``content`` + # (not top-level). This path never reads ``content``, so carry that marker over or the + # breakpoint is burned rather than relocated. msg_content = m.get("content") if isinstance(msg_content, list): inline_cc = next((cc for cc in map(_cache_control_of, msg_content) if cc is not None), None) @@ -444,8 +396,8 @@ def _replay_ordered_blocks(m: Dict[str, Any], ordered_blocks: List[Any]) -> Opti def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: - """Assistant message -> Anthropic content blocks (thinking, text, tool_use, - Kimi/DeepSeek reasoning_content injection).""" + """Assistant message -> Anthropic content blocks (thinking, text, tool_use, Kimi/DeepSeek + reasoning_content injection).""" content = m.get("content", "") ordered_blocks = m.get("anthropic_content_blocks") if isinstance(ordered_blocks, list) and ordered_blocks: @@ -454,9 +406,9 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: return {"role": "assistant", "content": replayed} blocks = _extract_preserved_thinking_blocks(m) - # Blank text blocks are dropped; a cache marker riding on one is relocated - # onto the last surviving cacheable block (prompt_caching sets cache_control - # on content[-1], which may be exactly the blank block). + # Blank text blocks are dropped; a cache marker riding on one is relocated onto the last + # surviving cacheable block (prompt_caching sets cache_control on content[-1], which may be + # exactly the blank block). relocated_cc = None if isinstance(content, list): for blk in _convert_content_to_anthropic(content): @@ -472,24 +424,20 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: continue fn = tc.get("function", {}) blocks.append({ - "type": "tool_use", - "id": _sanitize_tool_id(tc.get("id", "")), - "name": fn.get("name", ""), + "type": "tool_use", "id": _sanitize_tool_id(tc.get("id", "")), "name": fn.get("name", ""), "input": _parse_tool_args(fn.get("arguments", "{}")), }) - # Kimi's /coding endpoint requires reasoning_content on replayed tool-call - # turns — even "" (injected as a fallback upstream). Prepend, since thinking - # must precede text/tool_use. Skip when reasoning_details already supplied - # (signed) thinking blocks: a duplicate unsigned one would be downgraded to a - # spurious text block on the last assistant message. + # Kimi's /coding endpoint requires reasoning_content on replayed tool-call turns — even "" + # (injected as a fallback upstream). Prepend, since thinking must precede text/tool_use. Skip + # when reasoning_details already supplied (signed) thinking blocks: a duplicate unsigned one + # would be downgraded to a spurious text block on the last assistant message. reasoning_content = m.get("reasoning_content") if isinstance(reasoning_content, str) and not _has_block_type(blocks, _THINKING_TYPES): blocks.insert(0, {"type": "thinking", "thinking": reasoning_content}) - # Empty assistant content is rejected. Fall back ONLY to the placeholder, - # never to raw ``content`` — that is the unfiltered blank payload just removed. + # Empty assistant content is rejected. Fall back ONLY to the placeholder, never to raw + # ``content`` — that is the unfiltered blank payload just removed. Markers are applied after + # the fallback so one from a sole dropped blank block lands on the placeholder. effective = blocks or [_text_block(_EMPTY_TEXT_PLACEHOLDER)] - # Applied after the fallback so a marker from a sole dropped blank block - # lands on the placeholder instead of being lost. _apply_assistant_cache_control_to_last_cacheable_block(effective, relocated_cc) _apply_assistant_cache_control_to_last_cacheable_block(effective, m.get("cache_control")) return {"role": "assistant", "content": effective} @@ -521,11 +469,10 @@ def _tool_result_content(m: Dict[str, Any]) -> Any: def _convert_tool_message_to_result(result: List[Dict[str, Any]], m: Dict[str, Any]) -> None: - """Append a tool_result to ``result``, merging into a trailing tool_result user - message when there is one. Mutates ``result`` in place.""" + """Append a tool_result to ``result``, merging into a trailing tool_result user message when + there is one. Mutates ``result`` in place.""" tool_result = { - "type": "tool_result", - "tool_use_id": _sanitize_tool_id(m.get("tool_call_id", "")), + "type": "tool_result", "tool_use_id": _sanitize_tool_id(m.get("tool_call_id", "")), "content": _tool_result_content(m), } cache_control = _cache_control_of(m) @@ -552,18 +499,14 @@ def _convert_user_message(content: Any) -> Dict[str, Any]: return {"role": "user", "content": content} -# --------------------------------------------------------------------------- -# Whole-list passes -# --------------------------------------------------------------------------- +# ----- whole-list passes ----- def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None: - """Strip tool_use blocks with no matching tool_result, and vice versa. - - Compression/truncation can remove either side of a pair or insert messages - between them. Anthropic requires the tool_result in the IMMEDIATELY FOLLOWING - user message — a global id match is not enough. Mutates ``result`` in place. - """ + """Strip tool_use blocks with no matching tool_result, and vice versa. Compression/truncation + can remove either side of a pair or insert messages between them. Anthropic requires the + tool_result in the IMMEDIATELY FOLLOWING user message — a global id match is not enough. + Mutates ``result`` in place.""" # Pass 1: tool_use without an adjacent result. for i, m in enumerate(result): if m.get("role") != "assistant" or not isinstance(m.get("content"), list): @@ -580,9 +523,9 @@ def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None: if not orphaned: continue kept = [b for b in m["content"] if not (_block_type(b) == "tool_use" and b.get("id") in orphaned)] - # A signed thinking block on this turn was signed against the ORIGINAL - # content and is now dead (400 "thinking blocks in the latest assistant - # message cannot be modified"). Flag so _manage_thinking_signatures demotes it. + # A signed thinking block on this turn was signed against the ORIGINAL content and is now + # dead (400 "thinking blocks in the latest assistant message cannot be modified"). Flag so + # _manage_thinking_signatures demotes it. if len(kept) != len(m["content"]) and _has_block_type(m["content"], _THINKING_TYPES): m["_thinking_signature_invalidated"] = True m["content"] = kept if kept else [_text_block("(tool call removed)")] @@ -599,16 +542,15 @@ def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None: if m.get("role") != "user" or not isinstance(m.get("content"), list): continue new_content = [ - b for b in m["content"] - if _block_type(b) != "tool_result" or b.get("tool_use_id") in surviving_tool_use_ids + b for b in m["content"] if _block_type(b) != "tool_result" or b.get("tool_use_id") in surviving_tool_use_ids ] if len(new_content) != len(m["content"]): m["content"] = new_content if new_content else [_text_block("(tool result removed)")] def _concat_content(prev: Any, curr: Any) -> Any: - """Merge two message contents: str+str joined by newline, list+list concatenated, - mixed shapes promoted to block lists.""" + """Merge two message contents: str+str joined by newline, list+list concatenated, mixed shapes + promoted to block lists.""" if isinstance(prev, str) and isinstance(curr, str): return prev + "\n" + curr if isinstance(prev, str): @@ -629,24 +571,42 @@ def _merge_consecutive_roles(result: List[Dict[str, Any]]) -> List[Dict[str, Any # Keep the orphan-strip flag visible to _manage_thinking_signatures. if m.get("_thinking_signature_invalidated"): fixed[-1]["_thinking_signature_invalidated"] = True - # The second message's thinking blocks were signed against a - # different turn boundary and become invalid once merged. + # The second message's thinking blocks were signed against a different turn boundary + # and become invalid once merged. if isinstance(m["content"], list): - m["content"] = [b for b in m["content"] if _block_type(b) not in _THINKING_TYPES] + m["content"] = _strip_thinking(m["content"]) fixed[-1]["content"] = _concat_content(fixed[-1]["content"], m["content"]) return fixed +def _keep_valid_latest_thinking(content: List[Any], signature_dead: bool) -> List[Any]: + """Latest assistant turn on direct Anthropic: keep signed thinking, demote unsigned to text so + the reasoning isn't lost. If orphan-stripping mutated THIS turn every signature is dead (and a + bare signed block with no tool_use is also invalid), so demote ALL of them.""" + new_content = [] + for b in content: + if _block_type(b) not in _THINKING_TYPES: + new_content.append(b) + continue + is_redacted = b.get("type") == "redacted_thinking" + signed = b.get("data") if is_redacted else b.get("signature") # redacted 'data' IS the signature + if signed and not signature_dead: + new_content.append(b) + elif (signature_dead or not is_redacted) and b.get("thinking"): + new_content.append(_text_block(b["thinking"])) # demote to plain text + # else: redacted_thinking without data — unverifiable, dropped + return new_content + + def _manage_thinking_signatures(result: List[Dict[str, Any]], base_url: str | None, model: str | None) -> None: """Strip or preserve thinking blocks per endpoint. Mutates ``result`` in place. - Anthropic signs thinking blocks against the full turn; any upstream mutation - invalidates them (400 "Invalid signature in thinking block"), so on direct - Anthropic only the LATEST assistant turn keeps signed blocks. Signatures are - proprietary: third-party endpoints strip all thinking. Kimi replays as-is; - DeepSeek needs unsigned blocks round-tripped but rejects signed ones. Nous - Portal proxies Claude with sticky sessions and validates the same signatures, - so it takes the native path despite not being anthropic.com. + Anthropic signs thinking blocks against the full turn; any upstream mutation invalidates them + (400 "Invalid signature in thinking block"), so on direct Anthropic only the LATEST assistant + turn keeps signed blocks. Signatures are proprietary: third-party endpoints strip all thinking. + Kimi replays as-is; DeepSeek needs unsigned blocks round-tripped but rejects signed ones. Nous + Portal proxies Claude with sticky sessions and validates the same signatures, so it takes the + native path despite not being anthropic.com. """ is_third_party = _is_third_party_anthropic_endpoint(base_url) and not _is_nous_portal_endpoint(base_url) is_kimi = _is_kimi_family_endpoint(base_url, model) @@ -666,26 +626,9 @@ def _manage_thinking_signatures(result: List[Dict[str, Any]], base_url: str | No ] m["content"] = new_content or [_text_block("(empty)")] elif is_third_party or idx != last_assistant_idx: - stripped = [b for b in m["content"] if _block_type(b) not in _THINKING_TYPES] - m["content"] = stripped or [_text_block("(thinking elided)")] + m["content"] = _strip_thinking(m["content"]) or [_text_block("(thinking elided)")] else: - # Latest assistant on direct Anthropic: keep signed, demote unsigned to - # text so the reasoning isn't lost. If orphan-stripping mutated THIS - # turn every signature is dead (and a bare signed block with no - # tool_use is also invalid), so demote ALL of them. - signature_dead = bool(m.get("_thinking_signature_invalidated")) - new_content = [] - for b in m["content"]: - if _block_type(b) not in _THINKING_TYPES: - new_content.append(b) - continue - is_redacted = b.get("type") == "redacted_thinking" - signed = b.get("data") if is_redacted else b.get("signature") # redacted 'data' IS the signature - if signed and not signature_dead: - new_content.append(b) - elif (signature_dead or not is_redacted) and b.get("thinking"): - new_content.append(_text_block(b["thinking"])) # demote to plain text - # else: redacted_thinking without data — unverifiable, dropped + new_content = _keep_valid_latest_thinking(m["content"], bool(m.get("_thinking_signature_invalidated"))) m["content"] = new_content or [_text_block("(empty)")] # cache_control on thinking blocks interferes with signature validation. @@ -696,8 +639,8 @@ def _manage_thinking_signatures(result: List[Dict[str, Any]], base_url: str | No def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None: - """Keep only the 3 most recent computer-use screenshots (~1,465 tokens each); - older images become a placeholder text block. Mutates ``result`` in place.""" + """Keep only the 3 most recent computer-use screenshots (~1,465 tokens each); older images + become a placeholder text block. Mutates ``result`` in place.""" image_count = 0 for msg in reversed(result): content = msg.get("content") @@ -712,34 +655,25 @@ def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None: image_count += 1 if image_count > 3: block["content"] = [ - b if b.get("type") != "image" else _text_block("[screenshot removed to save context]") - for b in inner + b if b.get("type") != "image" else _text_block("[screenshot removed to save context]") for b in inner ] def _ensure_leading_user_turn(result: List[Dict[str, Any]]) -> None: - """Anthropic requires messages[0].role == user; prepend a placeholder turn otherwise. - - A second auto-compaction can leave a role=assistant summary first, which the - API rejects (often masked as a misleading tool_use/tool_result 400). The filler - must be non-whitespace text or it trades that 400 for the blank-block one. - """ + """Anthropic requires messages[0].role == user; prepend a placeholder turn otherwise. A second + auto-compaction can leave a role=assistant summary first, which the API rejects (often masked + as a misleading tool_use/tool_result 400). The filler must be non-whitespace text or it trades + that 400 for the blank-block one.""" if result and result[0].get("role") != "user": result.insert(0, {"role": "user", "content": [_text_block(_EMPTY_TEXT_PLACEHOLDER)]}) def _fix_blank_text_blocks_in_list( - blocks: List[Any], - *, - placeholder_text: str, - msg_index: int, - role: Any, - location: str, + blocks: List[Any], *, placeholder_text: str, msg_index: int, role: Any, location: str ) -> List[Any]: - """Drop blank text blocks; relocate any cache_control they carried onto the last - surviving cacheable block; if nothing survives, substitute one placeholder block - (carrying the relocated marker). Non-text blocks and order are untouched. - Returns a new list; logs structure only (never text).""" + """Drop blank text blocks; relocate any cache_control they carried onto the last surviving + cacheable block; if nothing survives, substitute one placeholder block (carrying the relocated + marker). Non-text blocks and order are untouched. Returns a new list; logs structure only.""" kept: List[Any] = [] relocated_cache_control = None for block_index, blk in enumerate(blocks): @@ -760,12 +694,9 @@ def _fix_blank_text_blocks_in_list( def _scrub_blank_text_blocks(result: List[Dict[str, Any]]) -> None: - """Final boundary guard against blank text blocks (HTTP 400 "text content blocks - must contain non-whitespace text"), including inside tool_result content. - - Runs LAST so a blank block from any current or future producer never reaches - the wire. Diagnostics are structural only. Mutates ``result`` in place. - """ + """Final boundary guard against blank text blocks (HTTP 400 "text content blocks must contain + non-whitespace text"), including inside tool_result content. Runs LAST so a blank block from + any current or future producer never reaches the wire. Mutates ``result`` in place.""" for msg_index, msg in enumerate(result): if not isinstance(msg, dict): continue @@ -774,8 +705,7 @@ def _scrub_blank_text_blocks(result: List[Dict[str, Any]]) -> None: if not isinstance(content, list) or not content: continue new_content = _fix_blank_text_blocks_in_list( - content, - placeholder_text=_EMPTY_TEXT_PLACEHOLDER if role == "assistant" else "(empty message)", + content, placeholder_text=_EMPTY_TEXT_PLACEHOLDER if role == "assistant" else "(empty message)", msg_index=msg_index, role=role, location="content", ) for blk in new_content: @@ -790,13 +720,10 @@ def _scrub_blank_text_blocks(result: List[Dict[str, Any]]) -> None: def _convert_system_content(content: Any) -> Any: - """System message content -> Anthropic ``system`` param (str, or block list when - cache_control is present). - - With cache markers the blocks are copied (never mutating the caller's dicts) - and blank text is replaced by the placeholder: Anthropic rejects blank system - blocks too, and a blank block carrying a breakpoint can't simply be dropped. - """ + """System message content -> Anthropic ``system`` param (str, or block list when cache_control + is present). With cache markers the blocks are copied (never mutating the caller's dicts) and + blank text is replaced by the placeholder: Anthropic rejects blank system blocks too, and a + blank block carrying a breakpoint can't simply be dropped.""" if not isinstance(content, list): return content if not any(p.get("cache_control") for p in content if isinstance(p, dict)): @@ -812,18 +739,13 @@ def _convert_system_content(content: Any) -> Any: def convert_messages_to_anthropic( - messages: List[Dict], - base_url: str | None = None, - model: str | None = None, + messages: List[Dict], base_url: str | None = None, model: str | None = None ) -> Tuple[Optional[Any], List[Dict]]: - """Convert OpenAI-format messages to Anthropic format. - - Returns ``(system, messages)``: system is extracted into its own param (a - string, or a block list when cache_control is present). ``base_url``/``model`` - drive thinking-signature policy — third-party endpoints strip signatures + """Convert OpenAI-format messages to Anthropic format -> ``(system, messages)``. System is + extracted into its own param (a string, or a block list when cache_control is present). + ``base_url``/``model`` drive thinking-signature policy — third-party endpoints strip signatures (proprietary, they 400 on them); Kimi-family endpoints/models keep unsigned - reasoning_content-derived blocks, which Kimi requires even when empty. - """ + reasoning_content-derived blocks, which Kimi requires even when empty.""" system = None result: List[Dict[str, Any]] = [] From 776220640f2f3e2598b18d34c7891a65d9cee2a7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:03:53 -0700 Subject: [PATCH 03/12] refactor(agent/anthropic_adapter): _auth_style classifier replaces header ladder, extract _stream_final_message, trim re-export block to externally used names --- agent/anthropic_adapter.py | 638 +++++++++++++++---------------------- 1 file changed, 249 insertions(+), 389 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 3903d26895..fb264505e4 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -1,12 +1,11 @@ """Anthropic Messages API adapter for Hermes Agent. -Translates between Hermes's internal OpenAI-style message format and -Anthropic's Messages API; all provider-specific logic is isolated here. - -Auth supports: - - Regular API keys (sk-ant-api*) -> x-api-key header - - OAuth setup-tokens (sk-ant-oat*) -> Bearer auth + beta header - - Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json) -> Bearer auth +Client construction and the Messages API call for Hermes's OpenAI-style internals. Auth: +regular API keys (``sk-ant-api*``) -> x-api-key; OAuth setup-tokens (``sk-ant-oat*``) -> Bearer + +beta header; Claude Code credentials (``~/.claude.json`` / ``~/.claude/.credentials.json``) -> +Bearer. Endpoint predicates, payload conversion and credential handling live in +``agent/anthropic_endpoints.py``, ``agent/anthropic_message_convert.py`` and +``agent/anthropic_credentials.py`` and are re-exported here for long-standing imports. """ import logging @@ -18,39 +17,26 @@ from typing import Any, Dict, List, Optional from utils import normalize_proxy_env_vars -# This module keeps client construction and the Messages API call itself; the -# three surfaces it used to inline live next to it and are re-exported below so -# long-standing ``from agent.anthropic_adapter import ...`` imports keep resolving: -# agent/anthropic_endpoints.py base-URL/endpoint-family predicates -# agent/anthropic_message_convert.py OpenAI -> Anthropic payload conversion -# agent/anthropic_credentials.py credential sources, OAuth, refresh commit from agent.anthropic_endpoints import ( # noqa: F401 - _KIMI_FAMILY_EXACT_SLUGS, _KIMI_FAMILY_MODEL_PREFIXES, _base_url_needs_context_1m_beta, - _is_azure_anthropic_endpoint, _is_deepseek_anthropic_endpoint, _is_kimi_coding_endpoint, - _is_kimi_family_endpoint, _is_minimax_anthropic_endpoint, _is_nous_portal_endpoint, - _is_opencode_endpoint, _is_third_party_anthropic_endpoint, _model_name_is_kimi_family, - _normalize_base_url_text, _requires_bearer_auth + _base_url_needs_context_1m_beta, _is_azure_anthropic_endpoint, _is_kimi_coding_endpoint, + _is_minimax_anthropic_endpoint, _is_nous_portal_endpoint, _is_opencode_endpoint, + _is_third_party_anthropic_endpoint, _model_name_is_kimi_family, _normalize_base_url_text, + _requires_bearer_auth, ) from agent.anthropic_message_convert import ( # noqa: F401 - _EMPTY_TEXT_PLACEHOLDER, _apply_assistant_cache_control_to_last_cacheable_block, - _content_parts_to_anthropic_blocks, _convert_assistant_message, _convert_content_part_to_anthropic, - _convert_content_to_anthropic, _convert_tool_message_to_result, _convert_user_message, - _ensure_leading_user_turn, _evict_old_screenshots, _extract_preserved_thinking_blocks, - _fix_blank_text_blocks_in_list, _image_source_from_openai_url, _is_bedrock_model_id, - _manage_thinking_signatures, _merge_consecutive_roles, _normalize_tool_input_schema, _safe_text, - _sanitize_replay_block, _sanitize_tool_id, _scrub_blank_text_blocks, _strip_orphaned_tool_blocks, - _to_plain_data, convert_messages_to_anthropic, convert_tools_to_anthropic, normalize_model_name + _EMPTY_TEXT_PLACEHOLDER, _convert_assistant_message, _convert_content_part_to_anthropic, + _convert_user_message, _ensure_leading_user_turn, _is_bedrock_model_id, _safe_text, + _sanitize_replay_block, _scrub_blank_text_blocks, _to_plain_data, convert_messages_to_anthropic, + convert_tools_to_anthropic, normalize_model_name, ) from agent.anthropic_credentials import ( # noqa: F401 - _OAUTH_CLIENT_ID, _OAUTH_REDIRECT_URI, _OAUTH_SCOPES, _OAUTH_TOKEN_URL, _OAUTH_TOKEN_URLS, - _OAUTH_TOKEN_USER_AGENT, CredentialPersistError, _generate_pkce, _get_hermes_oauth_file, _getenv, - _is_oauth_token, _prefer_refreshable_claude_code_token, _read_claude_code_credentials_from_file, + _OAUTH_TOKEN_USER_AGENT, CredentialPersistError, _get_hermes_oauth_file, _getenv, _is_oauth_token, _read_claude_code_credentials_from_keychain, _refresh_oauth_token, _resolve_anthropic_pool_token, _resolve_claude_code_token_from_credentials, _write_claude_code_credentials, _write_hermes_oauth_credentials, claude_code_credentials_path, is_claude_code_token_valid, is_rotation_consumed_uncommitted, mark_rotation_consumed_uncommitted, read_claude_code_credentials, read_hermes_oauth_credentials, refresh_anthropic_oauth_pure, resolve_anthropic_token, - run_hermes_oauth_login_pure, run_oauth_setup_token + run_hermes_oauth_login_pure, run_oauth_setup_token, ) try: @@ -61,9 +47,8 @@ except Exception: _HERMES_VERSION = "0.0.0" -# ``import anthropic`` is deliberately NOT at module top: the SDK costs ~220 ms -# of imports and every usage site is a cold user-triggered path. ``...`` is the -# "not yet tried" sentinel; None means tried and missing. +# ``import anthropic`` is deliberately NOT at module top: the SDK costs ~220 ms of imports and +# every usage site is a cold user-triggered path. ``...`` = not yet tried; None = tried, missing. _anthropic_sdk: Any = ... @@ -98,30 +83,21 @@ def _require_sdk(purpose: str, verb: str = "Install it with"): logger = logging.getLogger(__name__) THINKING_BUDGET = {"xhigh": 32000, "high": 16000, "medium": 8000, "low": 4000} -# Hermes effort -> Anthropic adaptive-thinking effort (output_config.effort). -# 4.7+ exposes low/medium/high/xhigh/max; Opus/Sonnet 4.6 have no xhigh, so -# callers downgrade xhigh->max there (see _supports_xhigh_effort). "minimal" is -# a legacy alias for low on every model. +# Hermes effort -> Anthropic adaptive-thinking effort (output_config.effort). 4.7+ exposes +# low/medium/high/xhigh/max; Opus/Sonnet 4.6 have no xhigh, so callers downgrade xhigh->max +# there (see _supports_xhigh_effort). "minimal" is a legacy alias for low on every model. ADAPTIVE_EFFORT_MAP = { - "ultra": "max", - "max": "max", - "xhigh": "xhigh", - "high": "high", - "medium": "medium", - "low": "low", + "ultra": "max", "max": "max", "xhigh": "xhigh", "high": "high", "medium": "medium", "low": "low", "minimal": "low", } -# ── Anthropic thinking-mode classification ──────────────────────────── -# Claude 4.6 replaced budget-based extended thinking with *adaptive* thinking; -# 4.7 additionally forbids the manual ``thinking`` block and drops -# temperature/top_p/top_k. Newer releases (4.8, named models like claude-fable-5) -# share no common version substring, so an allowlist of "modern" versions would -# go stale and silently route a new model down the legacy path. We therefore -# DEFAULT unknown Claude models to the modern contract and keep explicit -# *legacy* lists (mirroring _get_anthropic_max_output's default-to-newest). -# Non-Claude Anthropic-Messages models (minimax, qwen3, GLM, ...) are not Claude -# and fall through to the legacy manual-thinking path, which is what they need. +# Thinking-mode classification. Claude 4.6 replaced budget-based extended thinking with +# *adaptive* thinking; 4.7 additionally forbids the manual ``thinking`` block and drops +# temperature/top_p/top_k. Newer releases share no common version substring, so an allowlist of +# "modern" versions would go stale and silently route a new model down the legacy path: unknown +# Claude models DEFAULT to the modern contract and only explicit *legacy* lists are kept +# (mirroring _get_anthropic_max_output's default-to-newest). Non-Claude Anthropic-Messages models +# (minimax, qwen3, GLM, ...) fall through to the legacy manual-thinking path, which they need. # Older Claude families that need manual thinking (budget_tokens only). _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS = ( @@ -133,21 +109,13 @@ _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS = ( "claude-sonnet-4-5", "claude-sonnet-4.5", "claude-haiku-4-5", "claude-haiku-4.5", ) - -# Adaptive families that reject the "xhigh" effort (arrived with Opus 4.7) and -# still accept sampling params. -_NO_XHIGH_CLAUDE_SUBSTRINGS = ( - "claude-opus-4-6", "claude-opus-4.6", - "claude-sonnet-4-6", "claude-sonnet-4.6", -) - -# Adaptive families where thinking is mandatory: ``thinking: {"type": -# "disabled"}`` answers HTTP 400 (Portal flags them ``reasoning.mandatory``). -# The failure is asymmetric — a missing entry 400s the turn, a spurious one -# only leaves thinking on — so when in doubt, add the family. -_MANDATORY_THINKING_CLAUDE_SUBSTRINGS = ( - "claude-fable", -) +# Adaptive families that reject the "xhigh" effort (arrived with Opus 4.7) and still accept +# sampling params. +_NO_XHIGH_CLAUDE_SUBSTRINGS = ("claude-opus-4-6", "claude-opus-4.6", "claude-sonnet-4-6", "claude-sonnet-4.6") +# Adaptive families where thinking is mandatory: ``thinking: {"type": "disabled"}`` answers HTTP +# 400 (Portal flags them ``reasoning.mandatory``). The failure is asymmetric — a missing entry +# 400s the turn, a spurious one only leaves thinking on — so when in doubt, add the family. +_MANDATORY_THINKING_CLAUDE_SUBSTRINGS = ("claude-fable",) _FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-8", "opus-4.8", "opus-5") @@ -162,9 +130,8 @@ def _model_matches(model: str, substrings) -> bool: return any(v in m for v in substrings) -# ── Max output token limits per Anthropic model ─────────────────────── -# Anthropic requires max_tokens; a fixed 16384 starved thinking-enabled models -# (thinking tokens count toward the limit). Source: Anthropic docs + Cline catalog. +# Max output tokens per model. Anthropic requires max_tokens; a fixed 16384 starved +# thinking-enabled models (thinking tokens count toward the limit). Source: Anthropic docs + Cline. _ANTHROPIC_OUTPUT_LIMITS = { "claude-fable": 128_000, # Mythos-class named models — 1M context, reasoning "claude-sonnet-5": 128_000, @@ -186,31 +153,24 @@ _ANTHROPIC_OUTPUT_LIMITS = { "minimax": 131_072, # third-party Anthropic-compatible "qwen3": 65_536, # DashScope enforces max_tokens in [1, 65536] } - -# Unknown models get the highest current limit: future models are unlikely to -# have *less* output capacity. +# Unknown models get the highest current limit: future models are unlikely to have *less*. _ANTHROPIC_DEFAULT_OUTPUT_LIMIT = 128_000 def _get_anthropic_max_output(model: str) -> int: """Max output tokens for ``model`` via longest substring match against - ``_ANTHROPIC_OUTPUT_LIMITS`` (so date-stamped ids and ``:1m``/``:fast`` - suffixes resolve, and ``claude-3-5-sonnet`` beats ``claude-3-5``). Dots are - normalized to hyphens so ``claude-opus-4.6`` matches ``claude-opus-4-6``. - """ + ``_ANTHROPIC_OUTPUT_LIMITS`` (so date-stamped ids and ``:1m``/``:fast`` suffixes resolve, and + ``claude-3-5-sonnet`` beats ``claude-3-5``). Dots normalize to hyphens (``claude-opus-4.6``).""" m = model.lower().replace(".", "-") best_key = max((key for key in _ANTHROPIC_OUTPUT_LIMITS if key in m), key=len, default=None) return _ANTHROPIC_OUTPUT_LIMITS[best_key] if best_key else _ANTHROPIC_DEFAULT_OUTPUT_LIMIT def _resolve_positive_anthropic_max_tokens(value) -> Optional[int]: - """``value`` floored to a positive int, or None when it is not a finite - positive number. - - Anthropic 400s on max_tokens that are 0, negative, fractional or non-finite; - the ``max_tokens or fallback`` idiom catches 0 but lets ``-1``/``0.5`` - through. Booleans are excluded explicitly (they subclass int). - """ + """``value`` floored to a positive int, or None when it is not a finite positive number. + Anthropic 400s on max_tokens that are 0, negative, fractional or non-finite; the ``max_tokens + or fallback`` idiom catches 0 but lets ``-1``/``0.5`` through. Booleans are excluded (they + subclass int).""" if isinstance(value, bool) or not isinstance(value, (int, float)): return None try: @@ -222,15 +182,10 @@ def _resolve_positive_anthropic_max_tokens(value) -> Optional[int]: return floored if floored > 0 else None -def _resolve_anthropic_messages_max_tokens( - requested, - model: str, - context_length: Optional[int] = None, -) -> int: - """``requested`` when it is a positive finite number, else the model's output - ceiling. Raises ValueError if neither is positive. The context-window clamp - is the caller's job so the positive-value contract stays endpoint-agnostic. - """ +def _resolve_anthropic_messages_max_tokens(requested, model: str, context_length: Optional[int] = None) -> int: + """``requested`` when it is a positive finite number, else the model's output ceiling. Raises + ValueError if neither is positive. The context-window clamp is the caller's job so the + positive-value contract stays endpoint-agnostic.""" resolved = _resolve_positive_anthropic_max_tokens(requested) if resolved is not None: return resolved @@ -244,11 +199,9 @@ def _resolve_anthropic_messages_max_tokens( def _supports_adaptive_thinking(model: str) -> bool: - """True for Claude models using adaptive thinking (4.6+): unknown Claude - models default to adaptive, the explicit legacy list stays manual, and - non-Claude models return False — except Kimi/Moonshot, whose Anthropic- - compatible endpoints implement the adaptive contract (incl. xhigh/display). - """ + """True for Claude models using adaptive thinking (4.6+): unknown Claude models default to + adaptive, the explicit legacy list stays manual, and non-Claude models return False — except + Kimi/Moonshot, whose Anthropic-compatible endpoints implement the adaptive contract.""" if _model_name_is_kimi_family(model): return True if not _is_claude_model(model): @@ -257,20 +210,17 @@ def _supports_adaptive_thinking(model: str) -> bool: def _supports_xhigh_effort(model: str) -> bool: - """True for models accepting the 'xhigh' effort (Opus 4.7+). Opus/Sonnet 4.6 - 400 on it — callers downgrade xhigh->max when this returns False.""" + """True for models accepting the 'xhigh' effort (Opus 4.7+). Opus/Sonnet 4.6 400 on it — + callers downgrade xhigh->max when this returns False.""" return _supports_adaptive_thinking(model) and not _model_matches(model, _NO_XHIGH_CLAUDE_SUBSTRINGS) def _accepts_thinking_disable(model: str) -> bool: - """True when ``model`` accepts an explicit ``thinking: {"type": "disabled"}``. - - Adaptive Claude thinks by default, so "off" only works if the disable is - sent; mandatory-thinking families 400 on it and keep the omit behavior. - Legacy manual-thinking models are opt-in via budget_tokens, so omission is - already off. Scoped to Claude: Kimi's documented disable is omission, and - sending it a new parameter on the strength of Claude's contract is a guess. - """ + """True when ``model`` accepts an explicit ``thinking: {"type": "disabled"}``. Adaptive Claude + thinks by default, so "off" only works if the disable is sent; mandatory-thinking families + 400 on it and keep the omit behavior. Legacy manual-thinking models are opt-in via + budget_tokens, so omission is already off. Scoped to Claude: Kimi's documented disable is + omission, and sending it a new parameter on the strength of Claude's contract is a guess.""" return ( _is_claude_model(model) and _supports_adaptive_thinking(model) @@ -279,52 +229,42 @@ def _accepts_thinking_disable(model: str) -> bool: def _forbids_sampling_params(model: str) -> bool: - """True for models that 400 on any non-default temperature/top_p/top_k - (Opus 4.7 and later; unknown Claude defaults to forbidding). The 4.6 family - and the legacy manual-thinking families still accept them. Callers omit the - fields entirely — the API rejects anything non-null, even defaults.""" + """True for models that 400 on any non-default temperature/top_p/top_k (Opus 4.7 and later; + unknown Claude defaults to forbidding). The 4.6 family and the legacy manual-thinking families + still accept them. Callers omit the fields entirely — the API rejects anything non-null.""" return _is_claude_model(model) and not _model_matches( model, _NO_XHIGH_CLAUDE_SUBSTRINGS + _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS ) def _supports_fast_mode(model: str) -> bool: - """True for models accepting ``speed: "fast"`` (Opus 4.8 / Opus 5, Claude API only). - - Explicit allowlist, not a version floor: the matrix has flipped both ways. - Opus 4.6 had fast mode and lost it (requests silently run and bill at - standard speed, so listing it would show a toggle that does nothing); Opus - 4.7 hard-400s on the param. Dedicated ``...-fast`` ids select fast inference - via the model field and must NOT also receive the speed parameter. - """ + """True for models accepting ``speed: "fast"`` (Opus 4.8 / Opus 5, Claude API only). Explicit + allowlist, not a version floor: Opus 4.6 had fast mode and lost it (requests silently run and + bill at standard speed), Opus 4.7 hard-400s on the param. Dedicated ``...-fast`` ids select + fast inference via the model field and must NOT also receive the speed parameter.""" return "-fast" not in model and any(v in model for v in _FAST_MODE_SUPPORTED_SUBSTRINGS) -# Beta headers safe on ordinary/native Anthropic requests. GA on Claude 4.6+ -# (harmless no-op there) but older Claude and compatible endpoints still gate -# on them. Do NOT add ``context-1m-2025-08-07``: accounts without the -# long-context beta get HTTP 400 ("long context beta is not yet available for -# this subscription"), breaking short auxiliary calls. Bedrock/Azure still need -# it for 1M context and opt in on their own paths. +# Beta headers safe on ordinary/native Anthropic requests. GA on Claude 4.6+ (harmless no-op +# there) but older Claude and compatible endpoints still gate on them. Do NOT add +# ``context-1m-2025-08-07``: accounts without the long-context beta get HTTP 400, breaking short +# auxiliary calls. Bedrock/Azure still need it for 1M context and opt in on their own paths. _COMMON_BETAS = [ - "interleaved-thinking-2025-05-14", - "fine-grained-tool-streaming-2025-05-14", + "interleaved-thinking-2025-05-14", "fine-grained-tool-streaming-2025-05-14" ] -# MiniMax's Anthropic-compatible endpoints fail tool-use requests when this beta -# is present. +# MiniMax's Anthropic-compatible endpoints fail tool-use requests when this beta is present. _TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14" _CONTEXT_1M_BETA = "context-1m-2025-08-07" # Enables the ``speed: "fast"`` request parameter. _FAST_MODE_BETA = "fast-mode-2026-02-01" # Required for OAuth/subscription auth; matches Claude Code / pi-ai / OpenCode. _OAUTH_ONLY_BETAS = [ - "claude-code-20250219", - "oauth-2025-04-20", + "claude-code-20250219", "oauth-2025-04-20" ] -# Claude Code identity — OAuth requests without it intermittently 500. Anthropic -# rejects OAuth requests whose user-agent version is too far behind the actual -# release, so the installed version is detected and this fallback kept current. +# Claude Code identity — OAuth requests without it intermittently 500. Anthropic rejects OAuth +# requests whose user-agent version is too far behind the actual release, so the installed +# version is detected and this fallback kept current. _CLAUDE_CODE_VERSION_FALLBACK = "2.1.74" _claude_code_version_cache: Optional[str] = None @@ -357,30 +297,24 @@ def _get_claude_code_version() -> str: _CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude." _MCP_TOOL_PREFIX = "mcp__" -# Anthropic's OAuth billing classifier fingerprints certain Hermes tool -# schemas/prose as a third-party app and reroutes to the metered extra-usage -# lane (HTTP 400 "You're out of extra usage" on a valid subscription). Live A/B -# repros isolated two independent triggers — the ``session_search`` tool -# (schema/name/prose) and the ``memory`` tool (schema/name) — so both are +# Anthropic's OAuth billing classifier fingerprints certain Hermes tool schemas/prose as a +# third-party app and reroutes to the metered extra-usage lane (HTTP 400 "You're out of extra +# usage" on a valid subscription). Live A/B repros isolated two independent triggers — the +# ``session_search`` tool (schema/name/prose) and the ``memory`` tool (schema/name) — so both are # aliased on the OAuth wire only; normalize_response reverses the mapping. _OAUTH_TOOL_NAME_ALIASES = { - "session_search": "chat_history_lookup", - "memory": "context_notes", -} -_OAUTH_TOOL_NAME_REVERSE_ALIASES = { - wire_name: name for name, wire_name in _OAUTH_TOOL_NAME_ALIASES.items() + "session_search": "chat_history_lookup", "memory": "context_notes" } +_OAUTH_TOOL_NAME_REVERSE_ALIASES = {wire_name: name for name, wire_name in _OAUTH_TOOL_NAME_ALIASES.items()} -# Aliases ALSO safe to substitute in free-form prose (system prompt, tool -# descriptions). "memory" is ordinary English throughout the prompt and inside -# the memory tool's own parameter docs (an enum the model must emit verbatim), -# so rewriting it would corrupt guidance; a model that calls bare ``memory`` -# still dispatches, since normalize_response resolves it through the registry. +# Aliases ALSO safe to substitute in free-form prose (system prompt, tool descriptions). "memory" +# is ordinary English throughout the prompt and inside the memory tool's own parameter docs (an +# enum the model must emit verbatim), so rewriting it would corrupt guidance; a model that calls +# bare ``memory`` still dispatches, since normalize_response resolves it through the registry. _OAUTH_PROSE_ALIAS_NAMES = frozenset({"session_search"}) # Word-boundary matchers so a longer identifier containing the token (e.g. -# ``tools/session_search_tool.py`` in AGENTS.md) is left alone; ``\b`` treats -# ``_`` as a word char. +# ``tools/session_search_tool.py`` in AGENTS.md) is left alone; ``\b`` treats ``_`` as a word char. _OAUTH_PROSE_ALIAS_PATTERNS = tuple( (re.compile(rf"\b{re.escape(name)}\b"), _OAUTH_TOOL_NAME_ALIASES[name]) for name in sorted(_OAUTH_PROSE_ALIAS_NAMES) @@ -394,20 +328,13 @@ def _apply_oauth_prose_aliases(text: str) -> str: return text -def _common_betas_for_base_url( - base_url: str | None, - *, - drop_context_1m_beta: bool = False, -) -> list[str]: - """Beta headers safe for the configured endpoint. - - MiniMax (Bearer-auth) rejects both the fine-grained-tool-streaming beta - (every tool-use message errors) and the 1M-context beta. Azure AI Foundry - also uses Bearer auth but keeps both — it needs the 1M beta for 1M context, - which native Anthropic does not get by default (some subscriptions reject - it; Bedrock opts in via its own client helper). ``drop_context_1m_beta`` - strips the 1M beta after a subscription/endpoint rejected it. - """ +def _common_betas_for_base_url(base_url: str | None, *, drop_context_1m_beta: bool = False) -> list[str]: + """Beta headers safe for the configured endpoint. MiniMax (Bearer-auth) rejects both the + fine-grained-tool-streaming beta (every tool-use message errors) and the 1M-context beta. + Azure AI Foundry also uses Bearer auth but keeps both — it needs the 1M beta for 1M context, + which native Anthropic does not get by default (some subscriptions reject it; Bedrock opts in + via its own client helper). ``drop_context_1m_beta`` strips the 1M beta after a + subscription/endpoint rejected it.""" betas = list(_COMMON_BETAS) if _base_url_needs_context_1m_beta(base_url) and not drop_context_1m_beta: betas.append(_CONTEXT_1M_BETA) @@ -422,8 +349,7 @@ def _beta_header(betas: list) -> Dict[str, str]: _ATTRIBUTION_HEADERS = { - "HTTP-Referer": "https://hermes-agent.nousresearch.com", - "X-Title": "Hermes Agent", + "HTTP-Referer": "https://hermes-agent.nousresearch.com", "X-Title": "Hermes Agent" } @@ -443,12 +369,11 @@ def _client_timeout(timeout): def _base_client_kwargs(base_url, timeout) -> tuple[str, Dict[str, Any]]: """Shared SDK constructor kwargs; returns ``(normalized_base_url, kwargs)``. - Retry is delegated to hermes's outer loop (``max_retries=0``): the SDK - default of 2 uses its own backoff that ignores Retry-After and double- - retries inside our loop, burning request slots against a bucket that won't - refill for minutes. Any trailing ``/v1`` is stripped because the SDK appends - ``/v1/messages``. Azure endpoints need an ``api-version`` query param; it - goes through ``default_query`` so the base_url is not corrupted into + Retry is delegated to hermes's outer loop (``max_retries=0``): the SDK default of 2 uses its + own backoff that ignores Retry-After and double-retries inside our loop, burning request slots + against a bucket that won't refill for minutes. Any trailing ``/v1`` is stripped because the + SDK appends ``/v1/messages``. Azure endpoints need an ``api-version`` query param; it goes + through ``default_query`` so the base_url is not corrupted into ``/anthropic?api-version=.../v1/messages``. """ kwargs: Dict[str, Any] = {"timeout": _client_timeout(timeout), "max_retries": 0} @@ -462,21 +387,15 @@ def _base_client_kwargs(base_url, timeout) -> tuple[str, Dict[str, Any]]: def _build_anthropic_client_with_bearer_hook( - token_provider, - base_url: str = None, - timeout: float = None, - *, - drop_context_1m_beta: bool = False, + token_provider, base_url: str = None, timeout: float = None, *, drop_context_1m_beta: bool = False ): """Anthropic-on-Foundry Entra ID variant of :func:`build_anthropic_client`. - The SDK stores ``api_key``/``auth_token`` as static strings, so per-request - bearer refresh (Microsoft's documented Foundry pattern) is done with a custom - ``httpx.Client`` whose request hook mints a fresh JWT and rewrites - ``Authorization`` on every request; the SDK skips its own auth when - ``http_client`` is given. A placeholder ``auth_token`` is still required at - construction — the hook overrides it, and the sentinel makes any accidental - leak diagnosable in logs. + The SDK stores ``api_key``/``auth_token`` as static strings, so per-request bearer refresh + (Microsoft's documented Foundry pattern) is done with a custom ``httpx.Client`` whose request + hook mints a fresh JWT and rewrites ``Authorization`` on every request; the SDK skips its own + auth when ``http_client`` is given. A placeholder ``auth_token`` is still required at + construction — the hook overrides it, and the sentinel makes any accidental leak diagnosable. """ sdk = _require_sdk("Azure Foundry Anthropic-style endpoints with Entra ID auth", verb="Install with") normalize_proxy_env_vars() @@ -491,35 +410,49 @@ def _build_anthropic_client_with_bearer_hook( kwargs["default_headers"] = headers client = sdk.Anthropic(**kwargs) - # Same env-inference trap as build_anthropic_client: auth_token-only - # construction would otherwise also send ANTHROPIC_API_KEY as X-Api-Key. + # Same env-inference trap as build_anthropic_client: auth_token-only construction would + # otherwise also send ANTHROPIC_API_KEY as X-Api-Key. client.api_key = None return client -def build_anthropic_client( - api_key, - base_url: str = None, - timeout: float = None, - *, - drop_context_1m_beta: bool = False, -): +def _auth_style(api_key, base_url, normalized_base_url) -> str: + """Order-sensitive endpoint/key classification for :func:`build_anthropic_client`. + + ``kimi``: Kimi's /coding endpoint 403s without a User-Agent (the Kimi team asked for proper + attribution instead of the ``claude-code/0.1.0`` minimum). ``bearer``: MiniMax & co. want the + key in Authorization: Bearer — checked before the OAuth shape test because their secrets lack + the sk-ant-api prefix and would be misread as Anthropic OAuth/setup tokens. ``api_key``: + third-party proxies use their own x-api-key keys (skip OAuth detection). ``oauth``: OAuth / + setup-token -> Bearer auth + Claude Code identity (Anthropic routes OAuth by + user-agent/headers; without the fingerprint, 500s). + """ + if _is_kimi_coding_endpoint(base_url): + return "kimi" + if _requires_bearer_auth(normalized_base_url): + return "bearer" + if _is_third_party_anthropic_endpoint(base_url): + return "api_key" + if _is_oauth_token(api_key): + return "oauth" + return "api_key" + + +def build_anthropic_client(api_key, base_url: str = None, timeout: float = None, *, drop_context_1m_beta: bool = False): """Create an Anthropic client, auto-detecting setup-tokens vs API keys. - ``api_key`` is a static ``str`` (all key-based and OAuth flows) or a - ``Callable[[], str]`` Entra ID bearer provider, which is routed through - :func:`_build_anthropic_client_with_bearer_hook`. ``timeout`` overrides the - 900s read timeout (connect stays 10s) from the per-provider/per-model - ``request_timeout_seconds`` config. ``drop_context_1m_beta`` strips - ``context-1m-2025-08-07`` from the client-level beta header — used by the - reactive OAuth retry in run_agent when a subscription rejects it; fresh - clients keep the default so 1M-capable subscriptions keep the capability. + ``api_key`` is a static ``str`` (all key-based and OAuth flows) or a ``Callable[[], str]`` + Entra ID bearer provider, routed through :func:`_build_anthropic_client_with_bearer_hook`. + ``timeout`` overrides the 900s read timeout (connect stays 10s) from the per-provider / + per-model ``request_timeout_seconds`` config. ``drop_context_1m_beta`` strips + ``context-1m-2025-08-07`` from the client-level beta header — used by the reactive OAuth retry + in run_agent when a subscription rejects it; fresh clients keep the default so 1M-capable + subscriptions keep the capability. """ sdk = _require_sdk("the Anthropic provider") if callable(api_key) and not isinstance(api_key, str): return _build_anthropic_client_with_bearer_hook( - api_key, base_url, timeout, - drop_context_1m_beta=drop_context_1m_beta, + api_key, base_url, timeout, drop_context_1m_beta=drop_context_1m_beta ) normalize_proxy_env_vars() @@ -528,62 +461,37 @@ def build_anthropic_client( kwargs["base_url"] = normalized_base_url.rstrip("/") common_betas = _common_betas_for_base_url(normalized_base_url, drop_context_1m_beta=drop_context_1m_beta) - if _is_kimi_coding_endpoint(base_url): - # Kimi's /coding endpoint 403s without a User-Agent; the Kimi team asked - # for proper attribution instead of the ``claude-code/0.1.0`` minimum. - kwargs["api_key"] = api_key - headers = {**_attribution_headers(), **_beta_header(common_betas)} - elif _requires_bearer_auth(normalized_base_url): - # MiniMax & co. want the key in Authorization: Bearer. Checked before the - # OAuth shape test: their secrets lack the sk-ant-api prefix and would - # otherwise be misread as Anthropic OAuth/setup tokens. - kwargs["auth_token"] = api_key - headers = _beta_header(common_betas) - elif _is_third_party_anthropic_endpoint(base_url): - # Third-party proxies use their own x-api-key keys; skip OAuth detection - # (their keys don't follow the sk-ant-* convention). - kwargs["api_key"] = api_key - headers = _beta_header(common_betas) - elif _is_oauth_token(api_key): - # OAuth/setup-token -> Bearer auth + Claude Code identity. Anthropic - # routes OAuth by user-agent/headers; without the fingerprint, 500s. - kwargs["auth_token"] = api_key - headers = { - **_beta_header(common_betas + _OAUTH_ONLY_BETAS), - "user-agent": f"claude-code/{_get_claude_code_version()} (external, cli)", - "x-app": "cli", - } - else: - kwargs["api_key"] = api_key - headers = _beta_header(common_betas) + style = _auth_style(api_key, base_url, normalized_base_url) + kwargs["auth_token" if style in ("bearer", "oauth") else "api_key"] = api_key + headers = _beta_header(common_betas + _OAUTH_ONLY_BETAS if style == "oauth" else common_betas) + if style == "kimi": + headers = {**_attribution_headers(), **headers} + elif style == "oauth": + headers["user-agent"] = f"claude-code/{_get_claude_code_version()} (external, cli)" + headers["x-app"] = "cli" if _is_opencode_endpoint(base_url): - # OpenCode identifies clients by request headers (like OpenRouter). The - # OpenAI-wire paths get these from profile.default_headers, but this - # route builds its client here and never sees the profile. + # OpenCode identifies clients by request headers (like OpenRouter). The OpenAI-wire paths + # get these from profile.default_headers, but this route never sees the profile. for k, v in _attribution_headers().items(): headers.setdefault(k, v) if headers: kwargs["default_headers"] = headers client = sdk.Anthropic(**kwargs) - # Bearer-only construction leaves ``api_key`` unset, so the SDK fills it from - # ANTHROPIC_API_KEY (loaded from ~/.hermes/.env) and sends dual auth — - # X-Api-Key *and* Authorization: Bearer — on every Portal/MiniMax/OAuth - # request. Clear it whenever we intentionally authenticated via auth_token. + # Bearer-only construction leaves ``api_key`` unset, so the SDK fills it from ANTHROPIC_API_KEY + # (loaded from ~/.hermes/.env) and sends dual auth — X-Api-Key *and* Authorization: Bearer — + # on every Portal/MiniMax/OAuth request. Clear it whenever we authenticated via auth_token. if "auth_token" in kwargs and "api_key" not in kwargs: client.api_key = None return client def build_anthropic_bedrock_client(region: str): - """AnthropicBedrock client for Bedrock Claude models (boto3 default credential chain). - - The SDK's native Bedrock adapter gives full Claude feature parity (prompt - caching, thinking budgets, adaptive thinking, fast mode) that Converse - lacks. The common betas plus ``context-1m-2025-08-07`` are attached: without - the latter Bedrock caps Opus 4.6/4.7 at 200K instead of 1M. - """ + """AnthropicBedrock client for Bedrock Claude models (boto3 default credential chain). The + SDK's native Bedrock adapter gives full Claude feature parity (prompt caching, thinking + budgets, adaptive thinking, fast mode) that Converse lacks. The common betas plus + ``context-1m-2025-08-07`` are attached: without the latter Bedrock caps Opus 4.6/4.7 at 200K.""" sdk = _require_sdk("the Bedrock provider") if not hasattr(sdk, "AnthropicBedrock"): raise ImportError( @@ -599,15 +507,11 @@ def build_anthropic_bedrock_client(region: str): def _normalize_to_mcp_wire(name: str) -> str: - """OAuth wire form of a tool name (no aliasing): ``mcp__<...>``. - - Anthropic's OAuth billing classifier treats a single-underscore ``mcp_`` - tool name as a third-party-app fingerprint (HTTP 400 "Third-party apps now - draw from extra usage"); ``mcp__foo`` is accepted. Both bare Hermes tools - (``read_file``) and native MCP tools registered as ``mcp__`` - must land on the double-underscore form — the latter was the gap a bare - prefix swap left open. normalize_response reverses both via registry lookup. - """ + """OAuth wire form of a tool name (no aliasing): ``mcp__<...>``. Anthropic's OAuth billing + classifier treats a single-underscore ``mcp_`` tool name as a third-party-app fingerprint + (HTTP 400 "Third-party apps now draw from extra usage"); ``mcp__foo`` is accepted. Both bare + Hermes tools (``read_file``) and native MCP tools registered as ``mcp__`` must + land on the double-underscore form. normalize_response reverses both via registry lookup.""" if name.startswith("mcp__"): return name # already correct, don't double-prefix if name.startswith("mcp_"): @@ -616,13 +520,10 @@ def _normalize_to_mcp_wire(name: str) -> str: def _oauth_wire_namer(anthropic_tools: List[Dict[str, Any]]): - """Return ``name -> OAuth wire name`` for this request's tool set. - - An alias must never collide with a wire name owned by a non-alias tool: two - identical tool names in one request is a hard 400, strictly worse than the - bug being fixed. Mirrors normalize_response's "registered tool wins" so - outbound and inbound agree on who owns a contested name. - """ + """Return ``name -> OAuth wire name`` for this request's tool set. An alias must never collide + with a wire name owned by a non-alias tool: two identical tool names in one request is a hard + 400, strictly worse than the bug being fixed. Mirrors normalize_response's "registered tool + wins" so outbound and inbound agree on who owns a contested name.""" claimed = { _normalize_to_mcp_wire(tool["name"]) for tool in (anthropic_tools or []) @@ -640,19 +541,16 @@ def _oauth_wire_namer(anthropic_tools: List[Dict[str, Any]]): _OAUTH_SYSTEM_REPLACEMENTS = ( - ("Hermes Agent", "Claude Code"), - ("Hermes agent", "Claude Code"), - ("hermes-agent", "claude-code"), - ("Nous Research", "Anthropic"), + ("Hermes Agent", "Claude Code"), ("Hermes agent", "Claude Code"), + ("hermes-agent", "claude-code"), ("Nous Research", "Anthropic"), ) def _apply_claude_code_identity(system, anthropic_tools, anthropic_messages, to_wire): - """OAuth transforms: Claude Code system prefix, product-name sanitizing (avoids - server-side content filters), tool/description aliasing, and the same tool - renames on replayed tool_use blocks so history matches ``tools[]``. Returns - the new ``system``; tools and messages are mutated in place. - """ + """OAuth transforms: Claude Code system prefix, product-name sanitizing (avoids server-side + content filters), tool/description aliasing, and the same tool renames on replayed tool_use + blocks so history matches ``tools[]``. Returns the new ``system``; tools and messages are + mutated in place.""" cc_block = {"type": "text", "text": _CLAUDE_CODE_SYSTEM_PREFIX} if isinstance(system, list): system = [cc_block] + system @@ -684,21 +582,15 @@ def _apply_claude_code_identity(system, anthropic_tools, anthropic_messages, to_ def _thinking_kwargs(reasoning_config: Dict[str, Any], model: str, effective_max_tokens: int) -> Dict[str, Any]: - """Map ``reasoning_config`` to Anthropic thinking kwargs. - - Adaptive models (Claude 4.6+, Kimi/Moonshot — the replay-validation 400s - that once motivated dropping the param for Kimi no longer occur) get - ``thinking.type=adaptive`` + ``output_config.effort``; older models and - manual-only compat endpoints (MiniMax) get budget_tokens. Haiku has no - extended thinking. On 4.7+ ``thinking.display`` defaults to "omitted", - hiding the reasoning Hermes shows in its CLI, so "summarized" is requested - to keep the activity feed populated. - """ + """Map ``reasoning_config`` to Anthropic thinking kwargs. Adaptive models (Claude 4.6+, + Kimi/Moonshot) get ``thinking.type=adaptive`` + ``output_config.effort``; older models and + manual-only compat endpoints (MiniMax) get budget_tokens. Haiku has no extended thinking. On + 4.7+ ``thinking.display`` defaults to "omitted", hiding the reasoning Hermes shows in its CLI, + so "summarized" is requested to keep the activity feed populated.""" if reasoning_config.get("enabled") is False: - # Adaptive models think by DEFAULT, so omitting the parameter is not a - # disable — the user silently keeps paying. Mandatory-thinking models - # 400 on the disable, so they keep the omission: a silently-ignored - # disable beats a dead turn. + # Adaptive models think by DEFAULT, so omitting the parameter is not a disable — the user + # silently keeps paying. Mandatory-thinking models 400 on the disable, so they keep the + # omission: a silently-ignored disable beats a dead turn. return {"thinking": {"type": "disabled"}} if _accepts_thinking_disable(model) else {} if "haiku" in model.lower(): return {} @@ -720,48 +612,33 @@ def _thinking_kwargs(reasoning_config: Dict[str, Any], model: str, effective_max def build_anthropic_kwargs( - model: str, - messages: List[Dict], - tools: Optional[List[Dict]], - max_tokens: Optional[int], - reasoning_config: Optional[Dict[str, Any]], - tool_choice: Optional[str] = None, - is_oauth: bool = False, - preserve_dots: bool = False, - context_length: Optional[int] = None, - base_url: str | None = None, - fast_mode: bool = False, - drop_context_1m_beta: bool = False, + model: str, messages: List[Dict], tools: Optional[List[Dict]], max_tokens: Optional[int], + reasoning_config: Optional[Dict[str, Any]], tool_choice: Optional[str] = None, + is_oauth: bool = False, preserve_dots: bool = False, context_length: Optional[int] = None, + base_url: str | None = None, fast_mode: bool = False, drop_context_1m_beta: bool = False, ) -> Dict[str, Any]: """Build kwargs for anthropic.messages.create(). - Two easily confused concepts: ``max_tokens`` is the OUTPUT cap for one - response (Anthropic's name for it; their native SDK says max_output_tokens); - ``context_length`` is the TOTAL window (input + output), enforced as - ``input_tokens + max_tokens <= context_length``. ``max_tokens=None`` uses the - model's native output ceiling; if that exceeds ``context_length`` (small - local endpoints) it is clamped to ``context_length - 1``. The clamp ignores - prompt size — callers must catch "max_tokens too large given prompt" and - retry smaller (parse_available_output_tokens_from_error). + Two easily confused concepts: ``max_tokens`` is the OUTPUT cap for one response; + ``context_length`` is the TOTAL window (input + output), enforced as ``input_tokens + + max_tokens <= context_length``. ``max_tokens=None`` uses the model's native output ceiling; if + that exceeds ``context_length`` (small local endpoints) it is clamped to ``context_length - + 1``. The clamp ignores prompt size — callers must catch "max_tokens too large given prompt" + and retry smaller (parse_available_output_tokens_from_error). - ``is_oauth`` applies Claude Code compatibility transforms; ``preserve_dots`` - keeps model-name dots (DashScope: qwen3.5-plus); a third-party ``base_url`` - strips thinking signatures; ``fast_mode`` adds ``extra_body.speed="fast"`` - plus the fast-mode beta on native Anthropic only. + ``is_oauth`` applies Claude Code compatibility transforms; ``preserve_dots`` keeps model-name + dots (DashScope: qwen3.5-plus); a third-party ``base_url`` strips thinking signatures; + ``fast_mode`` adds ``extra_body.speed="fast"`` plus the fast-mode beta on native Anthropic only. """ - system, anthropic_messages = convert_messages_to_anthropic( - messages, base_url=base_url, model=model - ) + system, anthropic_messages = convert_messages_to_anthropic(messages, base_url=base_url, model=model) anthropic_tools = convert_tools_to_anthropic(tools) if tools else [] - # Nous Portal routes on its own catalog ids (``anthropic/claude-opus-4.8``); - # normalizing would make the model unresolvable there (prefix AND dots kept). + # Nous Portal routes on its own catalog ids (``anthropic/claude-opus-4.8``); normalizing would + # make the model unresolvable there (prefix AND dots kept). if not _is_nous_portal_endpoint(base_url): model = normalize_model_name(model, preserve_dots=preserve_dots) # Non-positive/non-finite values fail locally instead of 400-ing upstream. - effective_max_tokens = _resolve_anthropic_messages_max_tokens( - max_tokens, model, context_length=context_length - ) + effective_max_tokens = _resolve_anthropic_messages_max_tokens(max_tokens, model, context_length=context_length) if context_length and effective_max_tokens > context_length: effective_max_tokens = max(context_length - 1, 1) @@ -769,11 +646,7 @@ def build_anthropic_kwargs( if to_wire: system = _apply_claude_code_identity(system, anthropic_tools, anthropic_messages, to_wire) - kwargs: Dict[str, Any] = { - "model": model, - "messages": anthropic_messages, - "max_tokens": effective_max_tokens, - } + kwargs: Dict[str, Any] = {"model": model, "messages": anthropic_messages, "max_tokens": effective_max_tokens} if system: kwargs["system"] = system @@ -786,24 +659,23 @@ def build_anthropic_kwargs( elif tool_choice == "none": kwargs.pop("tools", None) # no Anthropic "none" — omit tools to prevent use elif isinstance(tool_choice, str): - # Under OAuth every tools[] entry is mcp__-prefixed/aliased; the forced - # name must go through the same normalizer or it (a) leaks the literal - # trigger string and (b) names a tool that no longer exists -> 400. + # Under OAuth every tools[] entry is mcp__-prefixed/aliased; the forced name must go + # through the same normalizer or it (a) leaks the literal trigger string and (b) names + # a tool that no longer exists -> 400. kwargs["tool_choice"] = {"type": "tool", "name": to_wire(tool_choice) if to_wire else tool_choice} if reasoning_config and isinstance(reasoning_config, dict): kwargs.update(_thinking_kwargs(reasoning_config, model, effective_max_tokens)) - # Safety net so upstream 4.6 -> 4.7 migrations don't need coordinated edits - # everywhere callers (auxiliary_client, ...) set sampling params. + # Safety net so upstream 4.6 -> 4.7 migrations don't need coordinated edits everywhere callers + # (auxiliary_client, ...) set sampling params. if _forbids_sampling_params(model): for _sampling_key in ("temperature", "top_p", "top_k"): kwargs.pop(_sampling_key, None) - # Fast mode: native Anthropic only — third-party providers reject the - # unknown beta/param and Anthropic scopes it to the Claude API (not - # Bedrock/Vertex/Foundry). Per-request extra_headers OVERRIDE the - # client-level anthropic-beta header, so rebuild the full beta list. + # Fast mode: native Anthropic only — third-party providers reject the unknown beta/param and + # Anthropic scopes it to the Claude API (not Bedrock/Vertex/Foundry). Per-request extra_headers + # OVERRIDE the client-level anthropic-beta header, so rebuild the full beta list. if fast_mode and not _is_third_party_anthropic_endpoint(base_url) and _supports_fast_mode(model): kwargs.setdefault("extra_body", {})["speed"] = "fast" betas = list(_common_betas_for_base_url(base_url, drop_context_1m_beta=drop_context_1m_beta)) @@ -815,22 +687,17 @@ def build_anthropic_kwargs( return kwargs -# Keys exclusive to the OpenAI Responses / Codex shape; the Messages SDK raises -# ``TypeError: ... unexpected keyword argument`` on any of them. -_RESPONSES_ONLY_KWARGS = frozenset( - {"instructions", "input", "store", "parallel_tool_calls"} -) +# Keys exclusive to the OpenAI Responses / Codex shape; the Messages SDK raises ``TypeError: ... +# unexpected keyword argument`` on any of them. +_RESPONSES_ONLY_KWARGS = frozenset({"instructions", "input", "store", "parallel_tool_calls"}) def sanitize_anthropic_kwargs(api_kwargs: Any, *, log_prefix: str = "") -> Any: - """Drop Responses-API-only keys before an Anthropic Messages SDK call. - - Boundary guard for api_mode-flip races (a concurrent auxiliary call mutating - a shared agent between kwargs build and dispatch): a Responses-shaped payload - reaching ``messages.stream()`` dies with a non-retryable TypeError that takes - the whole turn and fallback chain with it. Mutates and returns - ``api_kwargs``; logs a WARNING so the race stays visible. - """ + """Drop Responses-API-only keys before an Anthropic Messages SDK call. Boundary guard for + api_mode-flip races (a concurrent auxiliary call mutating a shared agent between kwargs build + and dispatch): a Responses-shaped payload reaching ``messages.stream()`` dies with a + non-retryable TypeError that takes the whole turn and fallback chain with it. Mutates and + returns ``api_kwargs``; logs a WARNING so the race stays visible.""" if not isinstance(api_kwargs, dict): return api_kwargs leaked = _RESPONSES_ONLY_KWARGS.intersection(api_kwargs) @@ -860,67 +727,60 @@ def _is_stream_unavailable_error(exc: Exception) -> bool: return False +def _stream_final_message(stream_fn, api_kwargs, log_prefix, on_stream_event, on_response): + """``messages.stream()`` -> final Message, ticking the best-effort callbacks.""" + stream_kwargs = dict(api_kwargs) + stream_kwargs.pop("stream", None) + with stream_fn(**stream_kwargs) as stream: + if callable(on_response): + try: + on_response(getattr(stream, "response", None)) + except Exception: + logger.debug("%son_response callback failed", log_prefix, exc_info=True) + if callable(on_stream_event): + # Consume manually so each event ticks the progress callback; get_final_message then + # returns the accumulated snapshot. + for _event in stream: + try: + on_stream_event(_event) + except TimeoutError: + # The callback is the caller's deadline seam: the host has given up, so abandon + # the stream (``with`` closes it) instead of streaming an answer nobody reads. + raise + except Exception: + logger.debug("%son_stream_event callback failed", log_prefix, exc_info=True) + return stream.get_final_message() + + def create_anthropic_message( - client: Any, - api_kwargs: dict, - *, - log_prefix: str = "", - prefer_stream: bool = True, - on_stream_event=None, - on_response=None, + client: Any, api_kwargs: dict, *, log_prefix: str = "", prefer_stream: bool = True, + on_stream_event=None, on_response=None, ) -> Any: """Create an Anthropic message, aggregating via stream when available. Some Anthropic-compatible gateways are SSE-only and answer ``create()`` with - ``text/event-stream``, which the SDK surfaces as raw text (callers then - crash on ``.content``). So prefer ``messages.stream().get_final_message()`` - like the main turn path, falling back to ``create()`` only for providers - that explicitly don't support streaming (restricted Bedrock roles). - - Both callbacks are best-effort (exceptions swallowed) and fire only on the - streaming path. ``on_stream_event(event)`` lets liveness watchdogs see - forward progress (e.g. a slow compression summary is not "hung"); - ``on_response(httpx_response)`` exposes headers the parsed Message drops - (Nous Portal's ``x-nous-credits-*`` balance family). + ``text/event-stream``, which the SDK surfaces as raw text (callers then crash on + ``.content``). So prefer ``messages.stream().get_final_message()`` like the main turn path, + falling back to ``create()`` only for providers that explicitly don't support streaming + (restricted Bedrock roles). Both callbacks are best-effort (exceptions swallowed) and fire + only on the streaming path: ``on_stream_event(event)`` lets liveness watchdogs see forward + progress; ``on_response(httpx_response)`` exposes headers the parsed Message drops (Nous + Portal's ``x-nous-credits-*`` balance family). """ sanitize_anthropic_kwargs(api_kwargs, log_prefix=log_prefix) messages_api = getattr(client, "messages", None) stream_fn = getattr(messages_api, "stream", None) if prefer_stream and callable(stream_fn): - stream_kwargs = dict(api_kwargs) - stream_kwargs.pop("stream", None) try: - with stream_fn(**stream_kwargs) as stream: - if callable(on_response): - try: - on_response(getattr(stream, "response", None)) - except Exception: - logger.debug("%son_response callback failed", log_prefix, exc_info=True) - if callable(on_stream_event): - # Consume manually so each event ticks the progress callback; - # get_final_message then returns the accumulated snapshot. - for _event in stream: - try: - on_stream_event(_event) - except TimeoutError: - # The callback is the caller's deadline seam: the host - # has given up, so abandon the stream (``with`` closes - # it) instead of streaming an answer nobody will read. - raise - except Exception: - logger.debug("%son_stream_event callback failed", log_prefix, exc_info=True) - return stream.get_final_message() + return _stream_final_message(stream_fn, api_kwargs, log_prefix, on_stream_event, on_response) except TimeoutError: raise except Exception as exc: if not _is_stream_unavailable_error(exc): raise logger.debug( - "%sAnthropic Messages stream unavailable; falling back to " - "messages.create(): %s", - log_prefix, - exc, + "%sAnthropic Messages stream unavailable; falling back to messages.create(): %s", log_prefix, exc ) create_kwargs = dict(api_kwargs) From f106dd6374f645fbada9636d6334e749a9ca133f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:12:32 -0700 Subject: [PATCH 04/12] refactor(agent/background_review,anthropic_adapter): drop dead is_background_review_enabled (tests repointed to load_background_review_settings), share _new_sdk_client, table call-detail defaults --- agent/anthropic_adapter.py | 34 +++++++---------- agent/background_review.py | 38 +++++-------------- tests/agent/test_background_review_usage.py | 2 +- .../test_background_review_cost_controls.py | 4 +- 4 files changed, 25 insertions(+), 53 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index fb264505e4..b7f6b36816 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -39,12 +39,7 @@ from agent.anthropic_credentials import ( # noqa: F401 run_hermes_oauth_login_pure, run_oauth_setup_token, ) -try: - import hermes_cli as _hermes_cli - - _HERMES_VERSION = str(_hermes_cli.__version__) -except Exception: - _HERMES_VERSION = "0.0.0" +from hermes_cli import __version__ as _HERMES_VERSION # ``import anthropic`` is deliberately NOT at module top: the SDK costs ~220 ms of imports and @@ -287,7 +282,7 @@ def _detect_claude_code_version() -> str: def _get_claude_code_version() -> str: - """Lazily detect the installed Claude Code version when OAuth headers need it.""" + """Detect lazily (only OAuth headers need it) and cache for the process.""" global _claude_code_version_cache if _claude_code_version_cache is None: _claude_code_version_cache = _detect_claude_code_version() @@ -406,13 +401,19 @@ def _build_anthropic_client_with_bearer_hook( kwargs["http_client"] = build_bearer_http_client(token_provider, timeout=kwargs["timeout"]) kwargs["auth_token"] = "entra-id-bearer-via-http-hook" headers = _beta_header(_common_betas_for_base_url(normalized_base_url, drop_context_1m_beta=drop_context_1m_beta)) + return _new_sdk_client(sdk, kwargs, headers) + + +def _new_sdk_client(sdk, kwargs: Dict[str, Any], headers: Dict[str, str]): + """``sdk.Anthropic(**kwargs)`` with ``headers`` attached. Bearer-only construction leaves + ``api_key`` unset, so the SDK fills it from ANTHROPIC_API_KEY (loaded from ~/.hermes/.env) and + sends dual auth — X-Api-Key *and* Authorization: Bearer — on every Portal/MiniMax/OAuth/Entra + request; clear it whenever we intentionally authenticated via auth_token.""" if headers: kwargs["default_headers"] = headers - client = sdk.Anthropic(**kwargs) - # Same env-inference trap as build_anthropic_client: auth_token-only construction would - # otherwise also send ANTHROPIC_API_KEY as X-Api-Key. - client.api_key = None + if "auth_token" in kwargs and "api_key" not in kwargs: + client.api_key = None return client @@ -475,16 +476,7 @@ def build_anthropic_client(api_key, base_url: str = None, timeout: float = None, # get these from profile.default_headers, but this route never sees the profile. for k, v in _attribution_headers().items(): headers.setdefault(k, v) - if headers: - kwargs["default_headers"] = headers - - client = sdk.Anthropic(**kwargs) - # Bearer-only construction leaves ``api_key`` unset, so the SDK fills it from ANTHROPIC_API_KEY - # (loaded from ~/.hermes/.env) and sends dual auth — X-Api-Key *and* Authorization: Bearer — - # on every Portal/MiniMax/OAuth request. Clear it whenever we authenticated via auth_token. - if "auth_token" in kwargs and "api_key" not in kwargs: - client.api_key = None - return client + return _new_sdk_client(sdk, kwargs, headers) def build_anthropic_bedrock_client(region: str): diff --git a/agent/background_review.py b/agent/background_review.py index 85373db03a..5665e96f4f 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -213,25 +213,6 @@ def load_background_review_settings() -> tuple[bool, Dict[str, Any]]: return True, {} -def is_background_review_enabled(task_cfg: Optional[Dict[str, Any]] = None) -> bool: - """Whether automatic post-turn review may spawn (``enabled``, default true). Explicit - ``/refine`` (``focus`` set) bypasses this gate; prefer :func:`load_background_review_settings` - at the spawn site so the block is not re-read on the same turn.""" - if task_cfg is None: - return load_background_review_settings()[0] - try: - from utils import is_truthy_value - - return is_truthy_value(task_cfg.get("enabled"), default=True) - except Exception: - logger.warning( - "Failed to interpret background_review.enabled; leaving " - "automatic review enabled (fail-open)", - exc_info=True, - ) - return True - - def _resolve_review_runtime(agent: Any, task_cfg: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Resolve provider/model/credentials for the review fork. @@ -666,6 +647,13 @@ def _verbose_memory_lines(label: str, detail: Dict) -> List[str]: return [line or f"{label} updated"] +# Tool-call argument fields surfaced in action summaries, with their defaults. +_CALL_DETAIL_DEFAULTS = ( + ("action", "?"), ("target", "memory"), ("content", ""), ("old_text", ""), ("name", ""), + ("old_string", ""), ("new_string", ""), +) + + def _collect_review_call_details(review_messages: List[Dict]) -> Tuple[set, dict]: """Map review-agent tool_call ids -> parsed call arguments for notify tools. @@ -695,15 +683,8 @@ def _collect_review_call_details(review_messages: List[Dict]) -> Tuple[set, dict args = {} if tcid: call_details[tcid] = { - "tool": fn_name, - "action": args.get("action", "?"), - "target": args.get("target", "memory"), - "content": args.get("content", ""), - "old_text": args.get("old_text", ""), - "operations": args.get("operations") or [], - "name": args.get("name", ""), - "old_string": args.get("old_string", ""), - "new_string": args.get("new_string", ""), + "tool": fn_name, "operations": args.get("operations") or [], + **{k: args.get(k, default) for k, default in _CALL_DETAIL_DEFAULTS}, } return all_tool_call_ids, call_details @@ -1332,7 +1313,6 @@ __all__ = [ "_MEMORY_REVIEW_PROMPT", "_SKILL_REVIEW_PROMPT", "_COMBINED_REVIEW_PROMPT", - "is_background_review_enabled", "load_background_review_settings", "spawn_background_review_thread", "summarize_background_review_actions", diff --git a/tests/agent/test_background_review_usage.py b/tests/agent/test_background_review_usage.py index db59e4ac8c..2825314053 100644 --- a/tests/agent/test_background_review_usage.py +++ b/tests/agent/test_background_review_usage.py @@ -160,7 +160,7 @@ def test_enabled_config_failure_logs_warning(caplog): "hermes_cli.config.load_config_readonly", side_effect=RuntimeError("boom"), ), caplog.at_level(logging.WARNING, logger="agent.background_review"): - assert background_review.is_background_review_enabled() is True + assert background_review.load_background_review_settings()[0] is True assert any( "fail-open" in r.message.lower() or "leaving automatic" in r.message.lower() for r in caplog.records diff --git a/tests/run_agent/test_background_review_cost_controls.py b/tests/run_agent/test_background_review_cost_controls.py index a6fc9f5839..a02377d012 100644 --- a/tests/run_agent/test_background_review_cost_controls.py +++ b/tests/run_agent/test_background_review_cost_controls.py @@ -166,10 +166,10 @@ def test_digest_records_tool_names_in_arc(): def test_enabled_defaults_true(): with patch("hermes_cli.config.load_config_readonly", return_value={}): - assert br.is_background_review_enabled() is True + assert br.load_background_review_settings()[0] is True def test_enabled_false_disables_automatic_review(): cfg = {"auxiliary": {"background_review": {"enabled": False}}} with patch("hermes_cli.config.load_config_readonly", return_value=cfg): - assert br.is_background_review_enabled() is False + assert br.load_background_review_settings()[0] is False From bfd8f12c1300c74285ffa33f0b46ef4919f81520 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:26:25 -0700 Subject: [PATCH 05/12] refactor(agent/anthropic_*,background_review): tool_choice table, shared block helpers (_split_blank_text_blocks, _carry_cache_control, _block_ids), predicate collapse in endpoints, suppress() for swallow-all guards --- agent/anthropic_adapter.py | 26 +++--- agent/anthropic_endpoints.py | 33 +++----- agent/anthropic_message_convert.py | 123 ++++++++++++++--------------- agent/background_review.py | 109 ++++++++----------------- 4 files changed, 114 insertions(+), 177 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index b7f6b36816..f8e58872ac 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -12,6 +12,7 @@ import logging import math import re import subprocess +from contextlib import suppress from pathlib import Path # noqa: F401 (tests patch ``anthropic_adapter.Path.home``) from typing import Any, Dict, List, Optional @@ -51,11 +52,9 @@ def _get_anthropic_sdk(): """Return the ``anthropic`` SDK module, importing lazily. None if not installed.""" global _anthropic_sdk if _anthropic_sdk is ...: - try: + with suppress(Exception): # ImportError or FeatureUnavailable — fall through to the import below from tools.lazy_deps import ensure as _lazy_ensure _lazy_ensure("provider.anthropic", prompt=False) - except Exception: # ImportError or FeatureUnavailable — fall through to the import below - pass try: import anthropic as _sdk _anthropic_sdk = _sdk @@ -603,6 +602,10 @@ def _thinking_kwargs(reasoning_config: Dict[str, Any], model: str, effective_max } +# OpenAI tool_choice -> Anthropic; any other string is a forced tool name. +_TOOL_CHOICE_MAP = {None: {"type": "auto"}, "auto": {"type": "auto"}, "required": {"type": "any"}} + + def build_anthropic_kwargs( model: str, messages: List[Dict], tools: Optional[List[Dict]], max_tokens: Optional[int], reasoning_config: Optional[Dict[str, Any]], tool_choice: Optional[str] = None, @@ -644,17 +647,14 @@ def build_anthropic_kwargs( if anthropic_tools: kwargs["tools"] = anthropic_tools - if tool_choice == "auto" or tool_choice is None: - kwargs["tool_choice"] = {"type": "auto"} - elif tool_choice == "required": - kwargs["tool_choice"] = {"type": "any"} - elif tool_choice == "none": + if tool_choice == "none": kwargs.pop("tools", None) # no Anthropic "none" — omit tools to prevent use - elif isinstance(tool_choice, str): - # Under OAuth every tools[] entry is mcp__-prefixed/aliased; the forced name must go - # through the same normalizer or it (a) leaks the literal trigger string and (b) names - # a tool that no longer exists -> 400. - kwargs["tool_choice"] = {"type": "tool", "name": to_wire(tool_choice) if to_wire else tool_choice} + elif tool_choice is None or isinstance(tool_choice, str): + # A forced tool name goes through the OAuth normalizer too: every tools[] entry is + # mcp__-prefixed/aliased there, so the literal would leak and name a nonexistent tool. + kwargs["tool_choice"] = _TOOL_CHOICE_MAP.get(tool_choice) or { + "type": "tool", "name": to_wire(tool_choice) if to_wire else tool_choice + } if reasoning_config and isinstance(reasoning_config, dict): kwargs.update(_thinking_kwargs(reasoning_config, model, effective_max_tokens)) diff --git a/agent/anthropic_endpoints.py b/agent/anthropic_endpoints.py index fbd871dde0..0372bfd5d6 100644 --- a/agent/anthropic_endpoints.py +++ b/agent/anthropic_endpoints.py @@ -56,12 +56,8 @@ _KIMI_FAMILY_EXACT_SLUGS = frozenset({"k3"}) def _model_name_is_kimi_family(model: str | None) -> bool: if not isinstance(model, str): return False - m = model.strip().lower() - if not m: - return False - if "/" in m: # ``moonshotai/kimi-k2.5`` -> ``kimi-k2.5`` - m = m.rsplit("/", 1)[-1] - return m in _KIMI_FAMILY_EXACT_SLUGS or m.startswith(_KIMI_FAMILY_MODEL_PREFIXES) + m = model.strip().lower().rsplit("/", 1)[-1] # ``moonshotai/kimi-k2.5`` -> ``kimi-k2.5`` + return bool(m) and (m in _KIMI_FAMILY_EXACT_SLUGS or m.startswith(_KIMI_FAMILY_MODEL_PREFIXES)) def _is_kimi_family_endpoint(base_url: str | None, model: str | None = None) -> bool: @@ -69,11 +65,11 @@ def _is_kimi_family_endpoint(base_url: str | None, model: str | None = None) -> moonshot.ai / moonshot.cn host, or any endpoint (e.g. a private gateway) whose *model* is in the Kimi family — the upstream enforces Kimi's thinking semantics regardless of hostname. Decides whether unsigned reasoning_content-derived thinking blocks are preserved on replay.""" - if _is_kimi_coding_endpoint(base_url): - return True - if any(base_url_host_matches(base_url or "", d) for d in ("api.kimi.com", "moonshot.ai", "moonshot.cn")): - return True - return _model_name_is_kimi_family(model) + return ( + _is_kimi_coding_endpoint(base_url) + or any(base_url_host_matches(base_url or "", d) for d in ("api.kimi.com", "moonshot.ai", "moonshot.cn")) + or _model_name_is_kimi_family(model) + ) def _is_deepseek_anthropic_endpoint(base_url: str | None) -> bool: @@ -81,9 +77,7 @@ def _is_deepseek_anthropic_endpoint(base_url: str | None) -> bool: blocks to round-trip while the generic third-party path strips them; its blocks are unsigned, so it gets the same strip-signed / keep-unsigned policy as Kimi. Pinned to the ``/anthropic`` path so the OpenAI-compatible base URL is not misclassified.""" - if not base_url_host_matches(base_url or "", "api.deepseek.com"): - return False - return "/anthropic" in _normalized_lower(base_url) + return base_url_host_matches(base_url or "", "api.deepseek.com") and "/anthropic" in _normalized_lower(base_url) def _is_nous_portal_endpoint(base_url: str | None) -> bool: @@ -99,9 +93,7 @@ def _is_nous_portal_endpoint(base_url: str | None) -> bool: override = _nous_inference_env_override() except Exception: return False - if not override: - return False - override_host = base_url_hostname(override) + override_host = base_url_hostname(override) if override else "" return bool(override_host) and base_url_hostname(base_url or "") == override_host @@ -109,13 +101,10 @@ def _requires_bearer_auth(base_url: str | None) -> bool: """Providers needing ``Authorization: Bearer`` instead of ``x-api-key``: MiniMax, Azure AI Foundry, Palantir Foundry's LLM proxy, CommandCode, Nous Portal. Palantir/CommandCode use hostname matching (not substring) so ``evil.com/palantirfoundry`` paths don't trigger it.""" - if _is_nous_portal_endpoint(base_url): - return True normalized = _normalized_lower(base_url) - if not normalized: - return False return ( - normalized.startswith(_MINIMAX_ANTHROPIC_PREFIXES) + _is_nous_portal_endpoint(base_url) + or normalized.startswith(_MINIMAX_ANTHROPIC_PREFIXES) or "azure.com" in normalized or base_url_host_matches(normalized, "palantirfoundry.com") or base_url_host_matches(normalized, "api.commandcode.ai") diff --git a/agent/anthropic_message_convert.py b/agent/anthropic_message_convert.py index c053be4311..f84e78d917 100644 --- a/agent/anthropic_message_convert.py +++ b/agent/anthropic_message_convert.py @@ -29,8 +29,6 @@ _BEDROCK_REGION_PREFIXES = ( ) -# ----- small shared predicates ----- - def _block_type(b: Any) -> Any: """``type`` of a dict block, None for non-dicts.""" @@ -59,6 +57,14 @@ def _text_block(text: str) -> Dict[str, str]: return {"type": "text", "text": text} +def _text_block_with_citations(text: Any, cits: Any) -> Dict[str, Any]: + """Text block carrying ``citations`` only when it is a non-empty list (the only input-valid shape).""" + block: Dict[str, Any] = _text_block(text) + if isinstance(cits, list) and cits: + block["citations"] = cits + return block + + def _parse_tool_args(raw: Any) -> Any: """JSON-decode a tool_call ``arguments`` string; non-strings pass through, bad JSON -> {}.""" try: @@ -71,7 +77,32 @@ def _strip_thinking(blocks: List[Any]) -> List[Any]: return [b for b in blocks if _block_type(b) not in _THINKING_TYPES] -# ----- model / tool conversion ----- +def _block_ids(blocks: List[Any], btype: str, key: str) -> set: + return {b.get(key) for b in blocks if _block_type(b) == btype} + + +def _carry_cache_control(out: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]: + """Copy a dict-valued ``cache_control`` marker from ``b`` onto ``out`` (returned).""" + if _cache_control_of(b) is not None: + out["cache_control"] = b["cache_control"] + return out + + +def _split_blank_text_blocks(blocks: List[Any]) -> Tuple[List[Any], Any, List[int]]: + """``(kept, relocated_cache_control, dropped_indexes)``: drop blank text blocks, remembering + the cache_control of the last one dropped so the caller can relocate the breakpoint.""" + kept: List[Any] = [] + relocated_cc = None + dropped: List[int] = [] + for i, blk in enumerate(blocks): + if _is_blank_text_block(blk): + if _cache_control_of(blk) is not None: + relocated_cc = blk["cache_control"] + dropped.append(i) + else: + kept.append(blk) + return kept, relocated_cc, dropped + def _is_bedrock_model_id(model: str) -> bool: @@ -99,6 +130,10 @@ def _sanitize_tool_id(tool_id: str) -> str: return re.sub(r"[^a-zA-Z0-9_-]", "_", tool_id) or "tool_0" +def _tool_use_block(tool_id: Any, name: Any, tool_input: Any) -> Dict[str, Any]: + return {"type": "tool_use", "id": _sanitize_tool_id(tool_id), "name": name, "input": tool_input} + + def _normalize_tool_input_schema(schema: Any) -> Dict[str, Any]: """Normalize a tool schema for Anthropic's validator: collapse nullable unions (``anyOf: [{type: string}, {type: null}]`` from Pydantic/MCP optional fields) to the non-null branch — @@ -149,8 +184,6 @@ def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]: return result -# ----- content-part conversion ----- - def _image_source_from_openai_url(url: str) -> Dict[str, str]: """OpenAI image URL / data URL -> Anthropic image ``source``.""" @@ -176,10 +209,7 @@ def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]: if ptype in ("input_text", "text"): # Rebuild from whitelisted fields only: stored SDK text blocks carry output-only siblings # (parsed_output, citations=None) that the INPUT schema rejects with 400. - block: Dict[str, Any] = _text_block(part.get("text", "")) - cits = part.get("citations") - if ptype == "text" and isinstance(cits, list) and cits: - block["citations"] = cits + block = _text_block_with_citations(part.get("text", ""), part.get("citations") if ptype == "text" else None) elif ptype in {"image_url", "input_image"}: image_value = part.get("image_url", {}) url = image_value.get("url", "") if isinstance(image_value, dict) else str(image_value or "") @@ -269,15 +299,10 @@ def _safe_text(text: Any) -> str: """``text`` if non-whitespace, else the placeholder. A blank text block stored in history (e.g. by compression) is replayed on every turn and wedges the session with HTTP 400; the placeholder is self-healing. Mirrors ``bedrock_adapter._safe_text`` (kept separate on purpose).""" - if text is None: - return _EMPTY_TEXT_PLACEHOLDER - if not isinstance(text, str): - text = str(text) + text = "" if text is None else str(text) return text if text.strip() else _EMPTY_TEXT_PLACEHOLDER -# ----- replay-block sanitizing (per-type whitelist) ----- - def _replay_text(b: Dict[str, Any]) -> Optional[Dict[str, Any]]: # Drop blank blocks rather than coerce in place: the caller relocates any cache_control and @@ -285,13 +310,7 @@ def _replay_text(b: Dict[str, Any]) -> Optional[Dict[str, Any]]: # model-visible noise next to real blocks. if _is_blank_text_block(b): return None - out: Dict[str, Any] = _text_block(b["text"]) - cits = b.get("citations") # input-valid ONLY as a non-empty list - if isinstance(cits, list) and cits: - out["citations"] = cits - if _cache_control_of(b) is not None: - out["cache_control"] = b["cache_control"] - return out + return _carry_cache_control(_text_block_with_citations(b["text"], b.get("citations")), b) def _replay_thinking(b: Dict[str, Any]) -> Dict[str, Any]: @@ -306,13 +325,7 @@ def _replay_redacted_thinking(b: Dict[str, Any]) -> Optional[Dict[str, Any]]: def _replay_tool_use(b: Dict[str, Any]) -> Dict[str, Any]: - out = { - "type": "tool_use", "id": _sanitize_tool_id(b.get("id", "")), "name": b.get("name", ""), - "input": b.get("input", {}), - } - if _cache_control_of(b) is not None: - out["cache_control"] = b["cache_control"] - return out + return _carry_cache_control(_tool_use_block(b.get("id", ""), b.get("name", ""), b.get("input", {})), b) def _replay_image(b: Dict[str, Any]) -> Optional[Dict[str, Any]]: @@ -347,8 +360,6 @@ def _apply_assistant_cache_control_to_last_cacheable_block(blocks: List[Dict[str break -# ----- per-message conversion ----- - def _replay_ordered_blocks(m: Dict[str, Any], ordered_blocks: List[Any]) -> Optional[List[Dict[str, Any]]]: """Interleaved-thinking replay: rebuild the assistant turn from the verbatim block list @@ -411,22 +422,15 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: # exactly the blank block). relocated_cc = None if isinstance(content, list): - for blk in _convert_content_to_anthropic(content): - if _is_blank_text_block(blk): - if _cache_control_of(blk) is not None: - relocated_cc = blk["cache_control"] - continue - blocks.append(blk) + kept, relocated_cc, _ = _split_blank_text_blocks(_convert_content_to_anthropic(content)) + blocks.extend(kept) elif content and str(content).strip(): blocks.append(_text_block(str(content))) for tc in m.get("tool_calls", []): if not tc or not isinstance(tc, dict): continue fn = tc.get("function", {}) - blocks.append({ - "type": "tool_use", "id": _sanitize_tool_id(tc.get("id", "")), "name": fn.get("name", ""), - "input": _parse_tool_args(fn.get("arguments", "{}")), - }) + blocks.append(_tool_use_block(tc.get("id", ""), fn.get("name", ""), _parse_tool_args(fn.get("arguments", "{}")))) # Kimi's /coding endpoint requires reasoning_content on replayed tool-call turns — even "" # (injected as a fallback upstream). Prepend, since thinking must precede text/tool_use. Skip # when reasoning_details already supplied (signed) thinking blocks: a duplicate unsigned one @@ -499,8 +503,6 @@ def _convert_user_message(content: Any) -> Dict[str, Any]: return {"role": "user", "content": content} -# ----- whole-list passes ----- - def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None: """Strip tool_use blocks with no matching tool_result, and vice versa. Compression/truncation @@ -511,14 +513,14 @@ def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None: for i, m in enumerate(result): if m.get("role") != "assistant" or not isinstance(m.get("content"), list): continue - tool_use_ids_in_turn = {b.get("id") for b in m["content"] if _block_type(b) == "tool_use"} + tool_use_ids_in_turn = _block_ids(m["content"], "tool_use", "id") if not tool_use_ids_in_turn: continue adjacent_result_ids: set = set() if i + 1 < len(result): nxt = result[i + 1] if nxt.get("role") == "user" and isinstance(nxt.get("content"), list): - adjacent_result_ids = {b.get("tool_use_id") for b in nxt["content"] if _block_type(b) == "tool_result"} + adjacent_result_ids = _block_ids(nxt["content"], "tool_result", "tool_use_id") orphaned = tool_use_ids_in_turn - adjacent_result_ids if not orphaned: continue @@ -531,13 +533,10 @@ def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None: m["content"] = kept if kept else [_text_block("(tool call removed)")] # Pass 2: tool_result whose tool_use no longer exists anywhere. - surviving_tool_use_ids = { - b.get("id") - for m in result - if m.get("role") == "assistant" and isinstance(m.get("content"), list) - for b in m["content"] - if _block_type(b) == "tool_use" - } + surviving_tool_use_ids: set = set() + for m in result: + if m.get("role") == "assistant" and isinstance(m.get("content"), list): + surviving_tool_use_ids |= _block_ids(m["content"], "tool_use", "id") for m in result: if m.get("role") != "user" or not isinstance(m.get("content"), list): continue @@ -674,19 +673,13 @@ def _fix_blank_text_blocks_in_list( """Drop blank text blocks; relocate any cache_control they carried onto the last surviving cacheable block; if nothing survives, substitute one placeholder block (carrying the relocated marker). Non-text blocks and order are untouched. Returns a new list; logs structure only.""" - kept: List[Any] = [] - relocated_cache_control = None - for block_index, blk in enumerate(blocks): - if _is_blank_text_block(blk): - if _cache_control_of(blk) is not None: - relocated_cache_control = blk["cache_control"] - logger.warning( - "Pre-call sanitizer: dropped blank text content block " - "(message_index=%d role=%s location=%s block_index=%d block_type=text)", - msg_index, role, location, block_index, - ) - continue - kept.append(blk) + kept, relocated_cache_control, dropped = _split_blank_text_blocks(blocks) + for block_index in dropped: + logger.warning( + "Pre-call sanitizer: dropped blank text content block " + "(message_index=%d role=%s location=%s block_index=%d block_type=text)", + msg_index, role, location, block_index, + ) if not kept: kept.append(_text_block(placeholder_text)) _apply_assistant_cache_control_to_last_cacheable_block(kept, relocated_cache_control) diff --git a/agent/background_review.py b/agent/background_review.py index 5665e96f4f..52ed842926 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -15,7 +15,7 @@ import json import logging import os import threading -from contextlib import contextmanager +from contextlib import contextmanager, suppress from dataclasses import dataclass, field from typing import Any, Dict, Iterator, List, Optional, Tuple @@ -112,8 +112,7 @@ def _interrupt_background_review(review_agent: Any) -> None: from agent.interrupt_compat import request_hard_interrupt request_hard_interrupt( - review_agent, "superseded by a new live turn", - tool_reason="background review superseded", + review_agent, "superseded by a new live turn", tool_reason="background review superseded" ) except Exception: logger.debug("Failed to cancel in-flight background review for a new turn", exc_info=True) @@ -255,13 +254,9 @@ def _resolve_review_runtime(agent: Any, task_cfg: Optional[Dict[str, Any]] = Non return { "provider": rp.get("provider") or task_provider, "model": rp.get("model") or task_model, - "api_key": rp.get("api_key"), - "base_url": rp.get("base_url"), - "api_mode": rp.get("api_mode"), - "credential_pool": rp.get("credential_pool"), + **{key: rp.get(key) for key in ("api_key", "base_url", "api_mode", "credential_pool", "command")}, "request_overrides": dict(rp.get("request_overrides") or {}), "max_tokens": rp.get("max_output_tokens"), - "command": rp.get("command"), "args": list(rp.get("args") or []), "routed": True, } @@ -296,14 +291,13 @@ def _digest_history(messages_snapshot: List[Dict], tail: int = 24) -> List[Dict] messages verbatim (extended so the kept run never starts on a tool result) and collapse older turns into one synthetic user-role digest, preserving role alternation.""" msgs = list(messages_snapshot or []) - if len(msgs) <= tail: - return msgs - keep = msgs[-tail:] - while keep and isinstance(keep[0], dict) and keep[0].get("role") == "tool": - tail += 1 - if len(msgs) <= tail: - return msgs + while len(msgs) > tail: keep = msgs[-tail:] + if not (isinstance(keep[0], dict) and keep[0].get("role") == "tool"): + break + tail += 1 + else: + return msgs lines: List[str] = [] for m in msgs[:-len(keep)]: if not isinstance(m, dict): @@ -626,13 +620,11 @@ def _verbose_skill_line(data: Dict, detail: Dict, message: str) -> str: new_string = change.get("new", "") or detail.get("new_string", "") description = change.get("description", "") if action == "patch" and (old_string or new_string): - old_preview = _preview(old_string, 80).replace("\n", " ") - new_preview = _preview(new_string, 80).replace("\n", " ") + old_preview, new_preview = (_preview(t, 80).replace("\n", " ") for t in (old_string, new_string)) return f"📝 Skill '{skill_name}' patched: \"{old_preview}\" → \"{new_preview}\"" - if action == "create" and description: - return f"📝 Skill '{skill_name}' created: {description}" - if action == "edit" and description: - return f"📝 Skill '{skill_name}' rewritten: {description}" + verb = {"create": "created", "edit": "rewritten"}.get(action) + if verb and description: + return f"📝 Skill '{skill_name}' {verb}: {description}" return f"📝 {message}" if message else f"Skill {action}" @@ -837,16 +829,9 @@ def _classify_review_result(actions: List[str]) -> str: ``📝 Skill …``, ``Memory …``, ``User profile …``), so a free-text line like ``Skipped: no skill worth saving`` stays ``none``. """ - has_skill = has_memory = False - for action in actions or []: - text = str(action).lstrip() - if text.startswith("📝"): - text = text[1:].lstrip() - lower = text.lower() - if lower.startswith("skill"): - has_skill = True - elif lower.startswith("memory") or lower.startswith("user profile"): - has_memory = True + lowers = [str(action).lstrip().removeprefix("📝").lstrip().lower() for action in actions or []] + has_skill = any(t.startswith("skill") for t in lowers) + has_memory = any(t.startswith(("memory", "user profile")) for t in lowers) return "+".join(kind for kind, hit in (("skill", has_skill), ("memory", has_memory)) if hit) or "none" @@ -986,15 +971,13 @@ def build_cache_parity_fork( _rt = _resolve_review_runtime(agent, task_cfg) _routed = bool(_rt.get("routed")) review_agent = AIAgent(**_fork_init_kwargs(agent, _rt, _routed, max_iterations)) - review_agent._memory_write_origin = write_origin - review_agent._memory_write_context = write_origin + review_agent._memory_write_origin = review_agent._memory_write_context = write_origin # The between-turns MCP refresh would add late-connecting MCP tools and break tools[] parity. review_agent._skip_mcp_refresh = True review_agent._memory_store = agent._memory_store review_agent._memory_enabled = agent._memory_enabled review_agent._user_profile_enabled = agent._user_profile_enabled - review_agent._memory_nudge_interval = 0 - review_agent._skill_nudge_interval = 0 + review_agent._memory_nudge_interval = review_agent._skill_nudge_interval = 0 # PERSISTENCE ISOLATION (curator-takeover root cause): sharing the parent's session_id, the # fork would otherwise write its harness turn into the REAL session, which the next live turn # re-reads as a standing instruction. @@ -1030,10 +1013,8 @@ def _bg_review_auto_deny(command, description, **kwargs): def _set_thread_approval_callback(callback: Any) -> None: from tools.terminal_tool import set_approval_callback - try: + with suppress(Exception): set_approval_callback(callback) - except Exception: - pass def _track_review_fork(agent: Any, review_agent: Any, *, register: bool) -> None: @@ -1084,10 +1065,9 @@ def _review_tool_whitelist(review_agent: Any, task_cfg: Optional[Dict[str, Any]] _extra_raw = _background_review_task_config(task_cfg).get("extra_tools", []) if isinstance(_extra_raw, list): configured_extra_tools = {name.strip() for name in _extra_raw if isinstance(name, str) and name.strip()} - whitelist |= configured_extra_tools except Exception: logger.debug("background_review extra_tools parse failed", exc_info=True) - return whitelist, configured_extra_tools + return whitelist | configured_extra_tools, configured_extra_tools @dataclass @@ -1102,10 +1082,8 @@ class _ReviewForkState: def _release_fork_clients(review_agent: Any) -> None: """The fork shares the foreground session ID: close() / shutdown_memory_provider() are session-bound (close() kills that session's terminal processes), so release only clients.""" - try: + with suppress(Exception): review_agent.release_clients() - except Exception: - pass def _finish_request_phase(agent: Any, review_agent: Any, review_run: Optional[_BackgroundReviewRun]) -> None: @@ -1128,6 +1106,8 @@ def _run_review_fork( review_whitelist, configured_extra_tools = _review_tool_whitelist(st.review_agent, task_cfg) extra_list = ", ".join(sorted(configured_extra_tools)) + deny_extra = f" Configured extra tools also allowed: {extra_list}." if configured_extra_tools else "" + prompt_extra = f" Exception — these configured tools are also allowed: {extra_list}." if configured_extra_tools else "" set_thread_tool_whitelist( review_whitelist, deny_msg_fmt=( @@ -1135,37 +1115,22 @@ def _run_review_fork( "{tool_name}. Allowed here: skill_view/skills_list/" "read_file/search_files to read, " "skill_manage(action='patch'|...) to change skills, and " - "memory for notes." - + ( - " Configured extra tools also allowed: " + extra_list + "." - if configured_extra_tools - else "" - ) - + " Do not retry {tool_name}." + "memory for notes." + deny_extra + " Do not retry {tool_name}." ), ) - try: + with suppress(Exception): from tools.skill_manager_tool import _reset_background_review_read_marks _reset_background_review_read_marks() - except Exception: - pass try: if review_run is None or review_run.begin_request(st.review_agent): # Routed -> digest (cache cold anyway); same model -> full snapshot (warm cache reads). st.review_agent.run_conversation( user_message=( - prompt - + "\n\nYou can only call memory and skill " + prompt + "\n\nYou can only call memory and skill " "management tools. Other tools will be denied " - "at runtime — do not attempt them." - + ( - " Exception — these configured tools are " - "also allowed: " + extra_list + "." - if configured_extra_tools - else "" - ) + "at runtime — do not attempt them." + prompt_extra ), conversation_history=_digest_history(messages_snapshot) if _routed else messages_snapshot, ) @@ -1191,10 +1156,8 @@ def _publish_review_summary(agent: Any, actions: List[str]) -> None: agent._safe_print(f" 💾 Self-improvement review: {summary}") _bg_cb = agent.background_review_callback if _bg_cb: - try: + with suppress(Exception): _bg_cb(f"💾 Self-improvement review: {summary}") - except Exception: - pass def _run_review_in_thread( @@ -1263,11 +1226,8 @@ def _run_review_in_thread( # cleanup output stays quiet without blanking other threads. _finish_request_phase(agent, st.review_agent, review_run) if st.review_agent is not None: - try: - with thread_scoped_silence(): - _release_fork_clients(st.review_agent) - except Exception: - pass + with suppress(Exception), thread_scoped_silence(): + _release_fork_clients(st.review_agent) # Clear the approval callback so a recycled thread-id doesn't inherit it. _set_thread_approval_callback(None) @@ -1310,11 +1270,6 @@ def spawn_background_review_thread( __all__ = [ - "_MEMORY_REVIEW_PROMPT", - "_SKILL_REVIEW_PROMPT", - "_COMBINED_REVIEW_PROMPT", - "load_background_review_settings", - "spawn_background_review_thread", - "summarize_background_review_actions", - "build_memory_write_metadata", + "_MEMORY_REVIEW_PROMPT", "_SKILL_REVIEW_PROMPT", "_COMBINED_REVIEW_PROMPT", "load_background_review_settings", + "spawn_background_review_thread", "summarize_background_review_actions", "build_memory_write_metadata", ] From 09eb9c01eb183b221cab54cdc4d57edc5ba6307a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:35:20 -0700 Subject: [PATCH 06/12] refactor(agent/background_review): repack review prompt literals at sentence/paragraph boundaries (bytes verified identical) --- agent/background_review.py | 409 ++++++++++++++++--------------------- 1 file changed, 175 insertions(+), 234 deletions(-) diff --git a/agent/background_review.py b/agent/background_review.py index 52ed842926..ad37ab580b 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -330,263 +330,204 @@ def _digest_history(messages_snapshot: List[Dict], tail: int = 24) -> List[Dict] _MEMORY_REVIEW_PROMPT = ( "Review the conversation above and consider saving to memory if appropriate.\n\n" "Focus on:\n" - "1. Has the user revealed things about themselves — their persona, desires, " - "preferences, or personal details worth remembering?\n" - "2. Has the user expressed expectations about how you should behave, their work " - "style, or ways they want you to operate?\n\n" - "If something stands out, save it using the memory tool. " - "If nothing is worth saving, just say 'Nothing to save.' and stop." + "1. Has the user revealed things about themselves — their persona, desires, preferences, or " + "personal details worth remembering?\n" + "2. Has the user expressed expectations about how you should behave, their work style, or ways " + "they want you to operate?\n\n" + "If something stands out, save it using the memory tool. If nothing is worth saving, just say " + "'Nothing to save.' and stop." ) _SKILL_REVIEW_PROMPT = ( - "Review the conversation above and update the skill library. Be " - "ACTIVE — most sessions produce at least one skill update, even if " - "small. A pass that does nothing is a missed learning opportunity, " - "not a neutral outcome.\n\n" - "Target shape of the library: CLASS-LEVEL skills, each with a rich " - "SKILL.md and a `references/` directory for session-specific detail. " - "Not a long flat list of narrow one-session-one-skill entries. This " - "shapes HOW you update, not WHETHER you update.\n\n" + "Review the conversation above and update the skill library. Be ACTIVE — most sessions produce " + "at least one skill update, even if small. A pass that does nothing is a missed learning " + "opportunity, not a neutral outcome.\n\n" + "Target shape of the library: CLASS-LEVEL skills, each with a rich SKILL.md and a " + "`references/` directory for session-specific detail. Not a long flat list of narrow " + "one-session-one-skill entries. This shapes HOW you update, not WHETHER you update.\n\n" "Signals to look for (any one of these warrants action):\n" - " • User corrected your style, tone, format, legibility, or " - "verbosity. Frustration signals like 'stop doing X', 'this is too " - "verbose', 'don't format like this', 'why are you explaining', " - "'just give me the answer', 'you always do Y and I hate it', or an " - "explicit 'remember this' are FIRST-CLASS skill signals, not just " - "memory signals. Update the relevant skill(s) to embed the " - "preference so the next session starts already knowing.\n" - " • User corrected your workflow, approach, or sequence of steps. " - "Encode the correction as a pitfall or explicit step in the skill " - "that governs that class of task.\n" - " • Non-trivial technique, fix, workaround, debugging path, or " - "tool-usage pattern emerged that a future session would benefit " - "from. Capture it.\n" - " • A skill that got loaded or consulted this session turned out " - "to be wrong, missing a step, or outdated. Patch it NOW.\n\n" - "Preference order — prefer the earliest action that fits, but do " - "pick one when a signal above fired:\n" - " 1. UPDATE A CURRENTLY-LOADED SKILL. Look back through the " - "conversation for skills the user loaded via /skill-name or you " - "read via skill_view. If any of them covers the territory of the " - "new learning, PATCH that one first (re-load it with skill_view " - "during this review — see Read-before-write below). It is the " - "skill that was in play, so it's the right one to extend — but " - "only if it is " - "curator-managed. Bundled, hub, pinned, and user-owned skills are " - "off-limits to you no matter how relevant (see Protected skills " - "below); for those, fall through to the next option.\n" - " 2. UPDATE AN EXISTING UMBRELLA (via skills_list + skill_view). " - "If no loaded skill fits but an existing class-level skill does, " - "patch it. Add a subsection, a pitfall, or broaden a trigger.\n" - " 3. ADD A SUPPORT FILE under an existing umbrella. Skills can be " - "packaged with three kinds of support files — use the right " - "directory per kind:\n" - " • `references/.md` — session-specific detail (error " - "transcripts, reproduction recipes, provider quirks) AND " - "condensed knowledge banks: quoted research, API docs, external " - "authoritative excerpts, or domain notes you found while working " - "on the problem. Write it concise and for the value of the task, " - "not as a full mirror of upstream docs.\n" - " • `templates/.` — starter files meant to be " - "copied and modified (boilerplate configs, scaffolding, a " - "known-good example the agent can `reproduce with modifications`).\n" - " • `scripts/.` — statically re-runnable actions " - "the skill can invoke directly (verification scripts, fixture " - "generators, deterministic probes, anything the agent should run " - "rather than hand-type each time).\n" - " Add support files via skill_manage action=write_file with " - "file_path starting 'references/', 'templates/', or 'scripts/'. " - "The umbrella's SKILL.md should gain a one-line pointer to any " - "new support file so future agents know it exists.\n" - " 4. CREATE A NEW CLASS-LEVEL UMBRELLA SKILL when no existing " - "skill covers the class. The name MUST be at the class level. " - "The name MUST NOT be a specific PR number, error string, feature " - "codename, library-alone name, or 'fix-X / debug-Y / audit-Z-today' " - "session artifact. If the proposed name only makes sense for " - "today's task, it's wrong — fall back to (1), (2), or (3).\n\n" - "Read-before-write (ENFORCED — skill_manage refuses otherwise): " - "before you patch or edit an existing skill's SKILL.md, call " - "skill_view(name) for that skill during this review. Before you " - "overwrite or remove an EXISTING supporting file, call " - "skill_view(name, file_path=...) for that exact file. Content " - "quoted earlier in the conversation transcript does NOT count — " - "the guard requires a fresh load within this review, and your " - "write must be based on what skill_view just returned. Creating " - "a brand-new skill or adding a NEW supporting file needs no " - "prior read. If a write is refused with a read-before-write " - "error, call skill_view for the named target once and retry the " - "write once; do not loop.\n\n" - "User-preference embedding (important): when the user expressed a " - "style/format/workflow preference, the update belongs in the " - "SKILL.md body, not just in memory. Memory captures 'who the user " - "is and what the current situation and state of your operations " - "are'; skills capture 'how to do this class of task for this " - "user'. When they complain about how you handled a task, the " - "skill that governs that task needs to carry the lesson.\n\n" - "If you notice two existing skills that overlap, note it in your " - "reply — the background curator handles consolidation at scale.\n\n" + " • User corrected your style, tone, format, legibility, or verbosity. Frustration signals " + "like 'stop doing X', 'this is too verbose', 'don't format like this', 'why are you " + "explaining', 'just give me the answer', 'you always do Y and I hate it', or an explicit " + "'remember this' are FIRST-CLASS skill signals, not just memory signals. Update the relevant " + "skill(s) to embed the preference so the next session starts already knowing.\n" + " • User corrected your workflow, approach, or sequence of steps. Encode the correction as a " + "pitfall or explicit step in the skill that governs that class of task.\n" + " • Non-trivial technique, fix, workaround, debugging path, or tool-usage pattern emerged " + "that a future session would benefit from. Capture it.\n" + " • A skill that got loaded or consulted this session turned out to be wrong, missing a step, " + "or outdated. Patch it NOW.\n\n" + "Preference order — prefer the earliest action that fits, but do pick one when a signal above " + "fired:\n" + " 1. UPDATE A CURRENTLY-LOADED SKILL. Look back through the conversation for skills the user " + "loaded via /skill-name or you read via skill_view. If any of them covers the territory of the " + "new learning, PATCH that one first (re-load it with skill_view during this review — see " + "Read-before-write below). It is the skill that was in play, so it's the right one to extend — " + "but only if it is curator-managed. Bundled, hub, pinned, and user-owned skills are off-limits " + "to you no matter how relevant (see Protected skills below); for those, fall through to the " + "next option.\n" + " 2. UPDATE AN EXISTING UMBRELLA (via skills_list + skill_view). If no loaded skill fits but " + "an existing class-level skill does, patch it. Add a subsection, a pitfall, or broaden a " + "trigger.\n" + " 3. ADD A SUPPORT FILE under an existing umbrella. Skills can be packaged with three kinds " + "of support files — use the right directory per kind:\n" + " • `references/.md` — session-specific detail (error transcripts, reproduction " + "recipes, provider quirks) AND condensed knowledge banks: quoted research, API docs, external " + "authoritative excerpts, or domain notes you found while working on the problem. Write it " + "concise and for the value of the task, not as a full mirror of upstream docs.\n" + " • `templates/.` — starter files meant to be copied and modified (boilerplate " + "configs, scaffolding, a known-good example the agent can `reproduce with modifications`).\n" + " • `scripts/.` — statically re-runnable actions the skill can invoke directly " + "(verification scripts, fixture generators, deterministic probes, anything the agent should " + "run rather than hand-type each time).\n" + " Add support files via skill_manage action=write_file with file_path starting " + "'references/', 'templates/', or 'scripts/'. The umbrella's SKILL.md should gain a one-line " + "pointer to any new support file so future agents know it exists.\n" + " 4. CREATE A NEW CLASS-LEVEL UMBRELLA SKILL when no existing skill covers the class. The " + "name MUST be at the class level. The name MUST NOT be a specific PR number, error string, " + "feature codename, library-alone name, or 'fix-X / debug-Y / audit-Z-today' session artifact. " + "If the proposed name only makes sense for today's task, it's wrong — fall back to (1), (2), " + "or (3).\n\n" + "Read-before-write (ENFORCED — skill_manage refuses otherwise): before you patch or edit an " + "existing skill's SKILL.md, call skill_view(name) for that skill during this review. Before " + "you overwrite or remove an EXISTING supporting file, call skill_view(name, file_path=...) for " + "that exact file. Content quoted earlier in the conversation transcript does NOT count — the " + "guard requires a fresh load within this review, and your write must be based on what " + "skill_view just returned. Creating a brand-new skill or adding a NEW supporting file needs no " + "prior read. If a write is refused with a read-before-write error, call skill_view for the " + "named target once and retry the write once; do not loop.\n\n" + "User-preference embedding (important): when the user expressed a style/format/workflow " + "preference, the update belongs in the SKILL.md body, not just in memory. Memory captures 'who " + "the user is and what the current situation and state of your operations are'; skills capture " + "'how to do this class of task for this user'. When they complain about how you handled a " + "task, the skill that governs that task needs to carry the lesson.\n\n" + "If you notice two existing skills that overlap, note it in your reply — the background " + "curator handles consolidation at scale.\n\n" "Protected skills (DO NOT edit these):\n" " • Bundled skills (shipped with Hermes, e.g. 'hermes-agent').\n" " • Hub-installed skills (installed via 'hermes skills install').\n" " • Skills in skills.external_dirs (externally owned).\n" - " • PINNED skills (marked via 'hermes curator pin'). You are an " - "autonomous no-user-present actor, so pin blocks your writes too — " - "content updates included. Only the user, in a foreground session, " - "can change a pinned skill.\n" - " • USER-OWNED skills — anything not curator-managed. A skill the " - "user hand-wrote, installed by URL, or asked a foreground agent to " - "create is theirs, not yours; your writes to it WILL be refused. " - "This includes skills that were loaded or consulted this session: " - "being in play does not make one yours to edit. If such a skill is " - "wrong or outdated, say so in your reply and recommend " - "'hermes curator adopt ' — do not try to patch it.\n" + " • PINNED skills (marked via 'hermes curator pin'). You are an autonomous no-user-present " + "actor, so pin blocks your writes too — content updates included. Only the user, in a " + "foreground session, can change a pinned skill.\n" + " • USER-OWNED skills — anything not curator-managed. A skill the user hand-wrote, installed " + "by URL, or asked a foreground agent to create is theirs, not yours; your writes to it WILL be " + "refused. This includes skills that were loaded or consulted this session: being in play does " + "not make one yours to edit. If such a skill is wrong or outdated, say so in your reply and " + "recommend 'hermes curator adopt ' — do not try to patch it.\n" "If the only skills that need updating are protected, say\n" "'Nothing to save.' and stop.\n\n" - "Do NOT capture (these become persistent self-imposed constraints " - "that bite you later when the environment changes):\n" - " • Environment-dependent failures: missing binaries, fresh-install " - "errors, post-migration path mismatches, 'command not found', " - "unconfigured credentials, uninstalled packages. The user can fix " - "these — they are not durable rules.\n" - " • Negative claims about tools or features ('browser tools do not " - "work', 'X tool is broken', 'cannot use Y from execute_code'). These " - "harden into refusals the agent cites against itself for months " - "after the actual problem was fixed.\n" - " • Session-specific transient errors that resolved before the " - "conversation ended. If retrying worked, the lesson is the retry " - "pattern, not the original failure.\n" - " • One-off task narratives. A user asking 'summarize today's " - "market' or 'analyze this PR' is not a class of work that warrants " - "a skill.\n\n" - " • Unresolved failures: if the session ended WITHOUT actually " - "finding a working method — you tried several things, none worked, " - "and told the user to check manually — do NOT write those attempts " - "up as a 'reliable workflow' or 'recommended approach'. That presents " - "an untested sequence of failures as validated guidance a future " - "session will trust and repeat. Either say 'Nothing to save', or, " - "only if you are independently confident of a real working alternative " - "(not something you are merely guessing might work), capture ONLY that " - "alternative — never the dead ends, and never dressed up as best practice.\n\n" - "If a tool failed because of setup state, capture the FIX (install " - "command, config step, env var to set) under an existing setup or " - "troubleshooting skill — never 'this tool does not work' as a " - "standalone constraint.\n\n" - "'Nothing to save.' is a real option but should NOT be the " - "default. If the session ran smoothly with no corrections and " - "produced no new technique, just say 'Nothing to save.' and stop. " - "Otherwise, act." + "Do NOT capture (these become persistent self-imposed constraints that bite you later when the " + "environment changes):\n" + " • Environment-dependent failures: missing binaries, fresh-install errors, post-migration " + "path mismatches, 'command not found', unconfigured credentials, uninstalled packages. The " + "user can fix these — they are not durable rules.\n" + " • Negative claims about tools or features ('browser tools do not work', 'X tool is broken', " + "'cannot use Y from execute_code'). These harden into refusals the agent cites against itself " + "for months after the actual problem was fixed.\n" + " • Session-specific transient errors that resolved before the conversation ended. If " + "retrying worked, the lesson is the retry pattern, not the original failure.\n" + " • One-off task narratives. A user asking 'summarize today's market' or 'analyze this PR' is " + "not a class of work that warrants a skill.\n\n" + " • Unresolved failures: if the session ended WITHOUT actually finding a working method — you " + "tried several things, none worked, and told the user to check manually — do NOT write those " + "attempts up as a 'reliable workflow' or 'recommended approach'. That presents an untested " + "sequence of failures as validated guidance a future session will trust and repeat. Either say " + "'Nothing to save', or, only if you are independently confident of a real working alternative " + "(not something you are merely guessing might work), capture ONLY that alternative — never the " + "dead ends, and never dressed up as best practice.\n\n" + "If a tool failed because of setup state, capture the FIX (install command, config step, env " + "var to set) under an existing setup or troubleshooting skill — never 'this tool does not " + "work' as a standalone constraint.\n\n" + "'Nothing to save.' is a real option but should NOT be the default. If the session ran " + "smoothly with no corrections and produced no new technique, just say 'Nothing to save.' and " + "stop. Otherwise, act." ) _COMBINED_REVIEW_PROMPT = ( "Review the conversation above and update two things:\n\n" - "**Memory**: who the user is. Did the user reveal persona, " - "desires, preferences, personal details, or expectations about " - "how you should behave? Save facts about the user and durable " + "**Memory**: who the user is. Did the user reveal persona, desires, preferences, personal " + "details, or expectations about how you should behave? Save facts about the user and durable " "preferences with the memory tool.\n\n" - "**Skills**: how to do this class of task. Be ACTIVE — most " - "sessions produce at least one skill update. A pass that does " - "nothing is a missed learning opportunity, not a neutral outcome.\n\n" - "Target shape of the skill library: CLASS-LEVEL skills with a rich " - "SKILL.md and a `references/` directory for session-specific detail. " - "Not a long flat list of narrow one-session-one-skill entries.\n\n" + "**Skills**: how to do this class of task. Be ACTIVE — most sessions produce at least one " + "skill update. A pass that does nothing is a missed learning opportunity, not a neutral " + "outcome.\n\n" + "Target shape of the skill library: CLASS-LEVEL skills with a rich SKILL.md and a " + "`references/` directory for session-specific detail. Not a long flat list of narrow " + "one-session-one-skill entries.\n\n" "Signals that warrant a skill update (any one is enough):\n" - " • User corrected your style, tone, format, legibility, " - "verbosity, or approach. Frustration is a FIRST-CLASS skill " - "signal, not just a memory signal. 'stop doing X', 'don't format " - "like this', 'I hate when you Y' — embed the lesson in the skill " - "that governs that task so the next session starts fixed.\n" - " • Non-trivial technique, fix, workaround, or debugging path " - "emerged.\n" - " • A skill that was loaded or consulted turned out wrong, " - "missing, or outdated — patch it now.\n\n" + " • User corrected your style, tone, format, legibility, verbosity, or approach. Frustration " + "is a FIRST-CLASS skill signal, not just a memory signal. 'stop doing X', 'don't format like " + "this', 'I hate when you Y' — embed the lesson in the skill that governs that task so the next " + "session starts fixed.\n" + " • Non-trivial technique, fix, workaround, or debugging path emerged.\n" + " • A skill that was loaded or consulted turned out wrong, missing, or outdated — patch it " + "now.\n\n" "Preference order for skills — pick the earliest that fits:\n" - " 1. UPDATE A CURRENTLY-LOADED SKILL. Check what skills were " - "loaded via /skill-name or skill_view in the conversation. If one " - "of them covers the learning, PATCH it first (re-load it with " - "skill_view during this review — see Read-before-write below). " - "It was in play; " - "it's the right place — provided it is curator-managed. Protected " - "and user-owned skills are off-limits however relevant; fall " - "through when one of those is the best fit.\n" - " 2. UPDATE AN EXISTING UMBRELLA (skills_list + skill_view to " - "find the right one). Patch it.\n" - " 3. ADD A SUPPORT FILE under an existing umbrella via " - "skill_manage action=write_file. Three kinds: " - "`references/.md` for session-specific detail OR condensed " - "knowledge banks (quoted research, API docs excerpts, domain " - "notes) written concise and task-focused; `templates/.` " - "for starter files meant to be copied and modified; " - "`scripts/.` for statically re-runnable actions " - "(verification, fixture generators, probes). Add a one-line " - "pointer in SKILL.md so future agents find them.\n" - " 4. CREATE A NEW CLASS-LEVEL UMBRELLA when nothing exists. " - "Name at the class level — NOT a PR number, error string, " - "codename, library-alone name, or 'fix-X / debug-Y' session " - "artifact. If the name only fits today's task, fall back to (1), " - "(2), or (3).\n\n" - "Read-before-write (ENFORCED — skill_manage refuses otherwise): " - "before patching or editing an existing skill's SKILL.md, call " - "skill_view(name) during this review; before overwriting or " - "removing an EXISTING supporting file, call skill_view(name, " - "file_path=...) for that exact file. Content quoted earlier in " - "the transcript does NOT count — base the write on what " - "skill_view just returned. New skills and NEW supporting files " - "need no prior read. On a read-before-write refusal: view the " - "named target once, retry the write once, do not loop.\n\n" - "User-preference embedding: when the user complains about how " - "you handled a task, update the skill that governs that task — " - "memory alone isn't enough. Memory says 'who the user is and " - "what the current situation and state of your operations are'; " - "skills say 'how to do this class of task for this user'. Both " - "should carry user-preference lessons when relevant.\n\n" - "If you notice overlapping existing skills, mention it — the " - "background curator handles consolidation.\n\n" + " 1. UPDATE A CURRENTLY-LOADED SKILL. Check what skills were loaded via /skill-name or " + "skill_view in the conversation. If one of them covers the learning, PATCH it first (re-load " + "it with skill_view during this review — see Read-before-write below). It was in play; it's " + "the right place — provided it is curator-managed. Protected and user-owned skills are " + "off-limits however relevant; fall through when one of those is the best fit.\n" + " 2. UPDATE AN EXISTING UMBRELLA (skills_list + skill_view to find the right one). Patch it.\n" + " 3. ADD A SUPPORT FILE under an existing umbrella via skill_manage action=write_file. Three " + "kinds: `references/.md` for session-specific detail OR condensed knowledge banks " + "(quoted research, API docs excerpts, domain notes) written concise and task-focused; " + "`templates/.` for starter files meant to be copied and modified; " + "`scripts/.` for statically re-runnable actions (verification, fixture generators, " + "probes). Add a one-line pointer in SKILL.md so future agents find them.\n" + " 4. CREATE A NEW CLASS-LEVEL UMBRELLA when nothing exists. Name at the class level — NOT a " + "PR number, error string, codename, library-alone name, or 'fix-X / debug-Y' session artifact. " + "If the name only fits today's task, fall back to (1), (2), or (3).\n\n" + "Read-before-write (ENFORCED — skill_manage refuses otherwise): before patching or editing an " + "existing skill's SKILL.md, call skill_view(name) during this review; before overwriting or " + "removing an EXISTING supporting file, call skill_view(name, file_path=...) for that exact " + "file. Content quoted earlier in the transcript does NOT count — base the write on what " + "skill_view just returned. New skills and NEW supporting files need no prior read. On a " + "read-before-write refusal: view the named target once, retry the write once, do not loop.\n\n" + "User-preference embedding: when the user complains about how you handled a task, update the " + "skill that governs that task — memory alone isn't enough. Memory says 'who the user is and " + "what the current situation and state of your operations are'; skills say 'how to do this " + "class of task for this user'. Both should carry user-preference lessons when relevant.\n\n" + "If you notice overlapping existing skills, mention it — the background curator handles " + "consolidation.\n\n" "Protected skills (DO NOT edit these):\n" " • Bundled skills (shipped with Hermes, e.g. 'hermes-agent').\n" " • Hub-installed skills (installed via 'hermes skills install').\n" " • Skills in skills.external_dirs (externally owned).\n" - " • PINNED skills (marked via 'hermes curator pin'). Pin blocks " - "autonomous writes entirely — content updates included — because no " - "user is present to consent. Only a foreground session can change one.\n" - " • USER-OWNED skills — anything not curator-managed (hand-written, " - "URL-installed, or created by a foreground agent at the user's " - "request). Your writes to these WILL be refused, including to skills " - "loaded or consulted this session. If one is wrong, say so in your " - "reply and recommend 'hermes curator adopt ' instead.\n" + " • PINNED skills (marked via 'hermes curator pin'). Pin blocks autonomous writes entirely — " + "content updates included — because no user is present to consent. Only a foreground session " + "can change one.\n" + " • USER-OWNED skills — anything not curator-managed (hand-written, URL-installed, or created " + "by a foreground agent at the user's request). Your writes to these WILL be refused, including " + "to skills loaded or consulted this session. If one is wrong, say so in your reply and " + "recommend 'hermes curator adopt ' instead.\n" "If the only skills that need updating are protected, say\n" "'Nothing to save.' and stop.\n\n" - "Do NOT capture as skills (these become persistent self-imposed " - "constraints that bite you later when the environment changes):\n" - " • Environment-dependent failures: missing binaries, fresh-install " - "errors, post-migration path mismatches, 'command not found', " - "unconfigured credentials, uninstalled packages. The user can fix " - "these — they are not durable rules.\n" - " • Negative claims about tools or features ('browser tools do not " - "work', 'X tool is broken', 'cannot use Y from execute_code'). These " - "harden into refusals the agent cites against itself for months " - "after the actual problem was fixed.\n" - " • Session-specific transient errors that resolved before the " - "conversation ended. If retrying worked, the lesson is the retry " - "pattern, not the original failure.\n" - " • One-off task narratives. A user asking 'summarize today's " - "market' or 'analyze this PR' is not a class of work that warrants " - "a skill.\n\n" - " • Unresolved failures: if the session ended WITHOUT actually " - "finding a working method — you tried several things, none worked, " - "and told the user to check manually — do NOT write those attempts " - "up as a 'reliable workflow' or 'recommended approach'. That presents " - "an untested sequence of failures as validated guidance a future " - "session will trust and repeat. Either say 'Nothing to save', or, " - "only if you are independently confident of a real working alternative " - "(not something you are merely guessing might work), capture ONLY that " - "alternative — never the dead ends, and never dressed up as best practice.\n\n" - "If a tool failed because of setup state, capture the FIX (install " - "command, config step, env var to set) under an existing setup or " - "troubleshooting skill — never 'this tool does not work' as a " - "standalone constraint.\n\n" - "Act on whichever of the two dimensions has real signal. If " - "genuinely nothing stands out on either, say 'Nothing to save.' " - "and stop — but don't reach for that conclusion as a default." + "Do NOT capture as skills (these become persistent self-imposed constraints that bite you " + "later when the environment changes):\n" + " • Environment-dependent failures: missing binaries, fresh-install errors, post-migration " + "path mismatches, 'command not found', unconfigured credentials, uninstalled packages. The " + "user can fix these — they are not durable rules.\n" + " • Negative claims about tools or features ('browser tools do not work', 'X tool is broken', " + "'cannot use Y from execute_code'). These harden into refusals the agent cites against itself " + "for months after the actual problem was fixed.\n" + " • Session-specific transient errors that resolved before the conversation ended. If " + "retrying worked, the lesson is the retry pattern, not the original failure.\n" + " • One-off task narratives. A user asking 'summarize today's market' or 'analyze this PR' is " + "not a class of work that warrants a skill.\n\n" + " • Unresolved failures: if the session ended WITHOUT actually finding a working method — you " + "tried several things, none worked, and told the user to check manually — do NOT write those " + "attempts up as a 'reliable workflow' or 'recommended approach'. That presents an untested " + "sequence of failures as validated guidance a future session will trust and repeat. Either say " + "'Nothing to save', or, only if you are independently confident of a real working alternative " + "(not something you are merely guessing might work), capture ONLY that alternative — never the " + "dead ends, and never dressed up as best practice.\n\n" + "If a tool failed because of setup state, capture the FIX (install command, config step, env " + "var to set) under an existing setup or troubleshooting skill — never 'this tool does not " + "work' as a standalone constraint.\n\n" + "Act on whichever of the two dimensions has real signal. If genuinely nothing stands out on " + "either, say 'Nothing to save.' and stop — but don't reach for that conclusion as a default." ) From 429b7895e60da12a1d1f506da55cf344865a9010 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:45:48 -0700 Subject: [PATCH 07/12] refactor(agent/background_review): factor shared do-not-capture prompt tail into _DO_NOT_CAPTURE_BLOCK (assembled bytes verified identical), compact fork attribute setup --- agent/background_review.py | 106 ++++++++++++++----------------------- 1 file changed, 41 insertions(+), 65 deletions(-) diff --git a/agent/background_review.py b/agent/background_review.py index ad37ab580b..bfc65d97e2 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -338,6 +338,32 @@ _MEMORY_REVIEW_PROMPT = ( "'Nothing to save.' and stop." ) +# Shared tail of the skill and combined prompts: what NOT to persist as a skill. +_DO_NOT_CAPTURE_BLOCK = ( + " (these become persistent self-imposed constraints that bite you later when the environment " + "changes):\n" + " • Environment-dependent failures: missing binaries, fresh-install errors, post-migration " + "path mismatches, 'command not found', unconfigured credentials, uninstalled packages. The " + "user can fix these — they are not durable rules.\n" + " • Negative claims about tools or features ('browser tools do not work', 'X tool is broken', " + "'cannot use Y from execute_code'). These harden into refusals the agent cites against itself " + "for months after the actual problem was fixed.\n" + " • Session-specific transient errors that resolved before the conversation ended. If " + "retrying worked, the lesson is the retry pattern, not the original failure.\n" + " • One-off task narratives. A user asking 'summarize today's market' or 'analyze this PR' is " + "not a class of work that warrants a skill.\n\n" + " • Unresolved failures: if the session ended WITHOUT actually finding a working method — you " + "tried several things, none worked, and told the user to check manually — do NOT write those " + "attempts up as a 'reliable workflow' or 'recommended approach'. That presents an untested " + "sequence of failures as validated guidance a future session will trust and repeat. Either say " + "'Nothing to save', or, only if you are independently confident of a real working alternative " + "(not something you are merely guessing might work), capture ONLY that alternative — never the " + "dead ends, and never dressed up as best practice.\n\n" + "If a tool failed because of setup state, capture the FIX (install command, config step, env " + "var to set) under an existing setup or troubleshooting skill — never 'this tool does not " + "work' as a standalone constraint.\n\n" +) + _SKILL_REVIEW_PROMPT = ( "Review the conversation above and update the skill library. Be ACTIVE — most sessions produce " "at least one skill update, even if small. A pass that does nothing is a missed learning " @@ -417,28 +443,7 @@ _SKILL_REVIEW_PROMPT = ( "recommend 'hermes curator adopt ' — do not try to patch it.\n" "If the only skills that need updating are protected, say\n" "'Nothing to save.' and stop.\n\n" - "Do NOT capture (these become persistent self-imposed constraints that bite you later when the " - "environment changes):\n" - " • Environment-dependent failures: missing binaries, fresh-install errors, post-migration " - "path mismatches, 'command not found', unconfigured credentials, uninstalled packages. The " - "user can fix these — they are not durable rules.\n" - " • Negative claims about tools or features ('browser tools do not work', 'X tool is broken', " - "'cannot use Y from execute_code'). These harden into refusals the agent cites against itself " - "for months after the actual problem was fixed.\n" - " • Session-specific transient errors that resolved before the conversation ended. If " - "retrying worked, the lesson is the retry pattern, not the original failure.\n" - " • One-off task narratives. A user asking 'summarize today's market' or 'analyze this PR' is " - "not a class of work that warrants a skill.\n\n" - " • Unresolved failures: if the session ended WITHOUT actually finding a working method — you " - "tried several things, none worked, and told the user to check manually — do NOT write those " - "attempts up as a 'reliable workflow' or 'recommended approach'. That presents an untested " - "sequence of failures as validated guidance a future session will trust and repeat. Either say " - "'Nothing to save', or, only if you are independently confident of a real working alternative " - "(not something you are merely guessing might work), capture ONLY that alternative — never the " - "dead ends, and never dressed up as best practice.\n\n" - "If a tool failed because of setup state, capture the FIX (install command, config step, env " - "var to set) under an existing setup or troubleshooting skill — never 'this tool does not " - "work' as a standalone constraint.\n\n" + "Do NOT capture" + _DO_NOT_CAPTURE_BLOCK + "'Nothing to save.' is a real option but should NOT be the default. If the session ran " "smoothly with no corrections and produced no new technique, just say 'Nothing to save.' and " "stop. Otherwise, act." @@ -504,28 +509,7 @@ _COMBINED_REVIEW_PROMPT = ( "recommend 'hermes curator adopt ' instead.\n" "If the only skills that need updating are protected, say\n" "'Nothing to save.' and stop.\n\n" - "Do NOT capture as skills (these become persistent self-imposed constraints that bite you " - "later when the environment changes):\n" - " • Environment-dependent failures: missing binaries, fresh-install errors, post-migration " - "path mismatches, 'command not found', unconfigured credentials, uninstalled packages. The " - "user can fix these — they are not durable rules.\n" - " • Negative claims about tools or features ('browser tools do not work', 'X tool is broken', " - "'cannot use Y from execute_code'). These harden into refusals the agent cites against itself " - "for months after the actual problem was fixed.\n" - " • Session-specific transient errors that resolved before the conversation ended. If " - "retrying worked, the lesson is the retry pattern, not the original failure.\n" - " • One-off task narratives. A user asking 'summarize today's market' or 'analyze this PR' is " - "not a class of work that warrants a skill.\n\n" - " • Unresolved failures: if the session ended WITHOUT actually finding a working method — you " - "tried several things, none worked, and told the user to check manually — do NOT write those " - "attempts up as a 'reliable workflow' or 'recommended approach'. That presents an untested " - "sequence of failures as validated guidance a future session will trust and repeat. Either say " - "'Nothing to save', or, only if you are independently confident of a real working alternative " - "(not something you are merely guessing might work), capture ONLY that alternative — never the " - "dead ends, and never dressed up as best practice.\n\n" - "If a tool failed because of setup state, capture the FIX (install command, config step, env " - "var to set) under an existing setup or troubleshooting skill — never 'this tool does not " - "work' as a standalone constraint.\n\n" + "Do NOT capture as skills" + _DO_NOT_CAPTURE_BLOCK + "Act on whichever of the two dimensions has real signal. If genuinely nothing stands out on " "either, say 'Nothing to save.' and stop — but don't reach for that conclusion as a default." ) @@ -913,30 +897,26 @@ def build_cache_parity_fork( _routed = bool(_rt.get("routed")) review_agent = AIAgent(**_fork_init_kwargs(agent, _rt, _routed, max_iterations)) review_agent._memory_write_origin = review_agent._memory_write_context = write_origin - # The between-turns MCP refresh would add late-connecting MCP tools and break tools[] parity. - review_agent._skip_mcp_refresh = True review_agent._memory_store = agent._memory_store review_agent._memory_enabled = agent._memory_enabled review_agent._user_profile_enabled = agent._user_profile_enabled review_agent._memory_nudge_interval = review_agent._skill_nudge_interval = 0 - # PERSISTENCE ISOLATION (curator-takeover root cause): sharing the parent's session_id, the - # fork would otherwise write its harness turn into the REAL session, which the next live turn - # re-reads as a standing instruction. - review_agent._persist_disabled = True + # _skip_mcp_refresh: the between-turns MCP refresh would add late-connecting MCP tools and + # break tools[] parity. PERSISTENCE ISOLATION (curator-takeover root cause): sharing the + # parent's session_id, the fork would otherwise write its harness turn into the REAL session, + # which the next live turn re-reads as a standing instruction; close() must likewise not + # finalize the parent's still-active session row. suppress_status_output: fork status/warning + # emits go via _print_fn/status_callback, which bypass the stdout redirect. + review_agent._skip_mcp_refresh = review_agent._persist_disabled = review_agent.suppress_status_output = True + review_agent._session_json_enabled = review_agent._end_session_on_close = False review_agent._session_db = None - review_agent._session_json_enabled = False - # Fork status/warning emits go via _print_fn/status_callback, which bypass the stdout redirect. - review_agent.suppress_status_output = True + review_agent.session_id = agent.session_id # Same model only: share the warm cached system prompt (~26% cost cut; a rebuilt prompt misses # the byte-exact prefix key) and pin session_start so any re-render (compression, plugin # hooks) stays byte-identical. if not _routed: review_agent._cached_system_prompt = agent._cached_system_prompt review_agent.session_start = agent.session_start - review_agent.session_id = agent.session_id - # Single-lifecycle fork sharing the live session_id: close() must not finalize the parent's - # still-active session row. - review_agent._end_session_on_close = False _detach_fork_compression(review_agent) # Compaction bounds a single request; this bounds the WHOLE review (checked in # conversation_loop via _review_input_budget_exhausted). @@ -1053,8 +1033,7 @@ def _run_review_fork( review_whitelist, deny_msg_fmt=( "Background review denied non-whitelisted tool: " - "{tool_name}. Allowed here: skill_view/skills_list/" - "read_file/search_files to read, " + "{tool_name}. Allowed here: skill_view/skills_list/read_file/search_files to read, " "skill_manage(action='patch'|...) to change skills, and " "memory for notes." + deny_extra + " Do not retry {tool_name}." ), @@ -1122,8 +1101,7 @@ def _run_review_in_thread( logger.warning( "Background review skipped: provider %r cannot emit Hermes tool calls, " "so the review fork could not write memories or skills. Set " - "auxiliary.background_review.{provider,model} to route the review to " - "a normal model.", + "auxiliary.background_review.{provider,model} to route the review to a normal model.", getattr(agent, "provider", "?"), ) _set_thread_approval_callback(None) @@ -1198,10 +1176,8 @@ def spawn_background_review_thread( focus = (focus or "").strip() if focus: prompt = ( - f"{prompt}\n\n" - f"The user explicitly requested this review with the following " - f"focus — prioritize it over the general instructions above:\n" - f"{focus}" + f"{prompt}\n\nThe user explicitly requested this review with the following " + f"focus — prioritize it over the general instructions above:\n{focus}" ) def _target() -> None: # resolves _run_review_in_thread at call time (tests patch it) From 5dc9c223e1626c2b4d4477ee659fb869e445af9e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:01:36 -0700 Subject: [PATCH 08/12] refactor(agent/anthropic_adapter,message_convert): compact tables and docstrings, _assistant_block_lists iterator, _carry_cache_control copy mode, collapse predicate ladders --- agent/anthropic_adapter.py | 238 +++++++++++------------------ agent/anthropic_message_convert.py | 78 ++++------ 2 files changed, 118 insertions(+), 198 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index f8e58872ac..5e373c75d4 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -1,12 +1,8 @@ -"""Anthropic Messages API adapter for Hermes Agent. - -Client construction and the Messages API call for Hermes's OpenAI-style internals. Auth: -regular API keys (``sk-ant-api*``) -> x-api-key; OAuth setup-tokens (``sk-ant-oat*``) -> Bearer + -beta header; Claude Code credentials (``~/.claude.json`` / ``~/.claude/.credentials.json``) -> -Bearer. Endpoint predicates, payload conversion and credential handling live in -``agent/anthropic_endpoints.py``, ``agent/anthropic_message_convert.py`` and -``agent/anthropic_credentials.py`` and are re-exported here for long-standing imports. -""" +"""Anthropic Messages API adapter: client construction + the Messages call for Hermes's +OpenAI-style internals. Auth: API keys (``sk-ant-api*``) -> x-api-key; OAuth setup-tokens +(``sk-ant-oat*``) and Claude Code credentials -> Bearer + beta header. Endpoint predicates, +payload conversion and credentials live in ``agent/anthropic_{endpoints,message_convert, +credentials}.py`` and are re-exported here for long-standing imports.""" import logging import math @@ -85,23 +81,20 @@ ADAPTIVE_EFFORT_MAP = { "minimal": "low", } -# Thinking-mode classification. Claude 4.6 replaced budget-based extended thinking with -# *adaptive* thinking; 4.7 additionally forbids the manual ``thinking`` block and drops -# temperature/top_p/top_k. Newer releases share no common version substring, so an allowlist of -# "modern" versions would go stale and silently route a new model down the legacy path: unknown -# Claude models DEFAULT to the modern contract and only explicit *legacy* lists are kept -# (mirroring _get_anthropic_max_output's default-to-newest). Non-Claude Anthropic-Messages models -# (minimax, qwen3, GLM, ...) fall through to the legacy manual-thinking path, which they need. - -# Older Claude families that need manual thinking (budget_tokens only). +# Thinking-mode classification. Claude 4.6 replaced budget-based extended thinking with *adaptive* +# thinking; 4.7 additionally forbids the manual ``thinking`` block and drops temperature/top_p/ +# top_k. Newer releases share no common version substring, so an allowlist of "modern" versions +# would go stale and silently route a new model down the legacy path: unknown Claude models +# DEFAULT to the modern contract and only explicit *legacy* lists are kept (mirroring +# _get_anthropic_max_output's default-to-newest). Non-Claude Anthropic-Messages models (minimax, +# qwen3, GLM, ...) fall through to the legacy manual-thinking path, which they need. +# Older Claude families that need manual thinking (budget_tokens only); ``claude-3`` covers +# 3/3.5/3.7 and the ``-2025`` entries are date-stamped 4.0 ids. _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS = ( - "claude-3", # 3, 3.5, 3.7 - "claude-opus-4-0", "claude-opus-4.0", "claude-opus-4-1", "claude-opus-4.1", - "claude-sonnet-4-0", "claude-sonnet-4.0", - "claude-opus-4-2025", "claude-sonnet-4-2025", # date-stamped 4.0 IDs - "claude-opus-4-5", "claude-opus-4.5", - "claude-sonnet-4-5", "claude-sonnet-4.5", - "claude-haiku-4-5", "claude-haiku-4.5", + "claude-3", "claude-opus-4-0", "claude-opus-4.0", "claude-opus-4-1", "claude-opus-4.1", + "claude-sonnet-4-0", "claude-sonnet-4.0", "claude-opus-4-2025", "claude-sonnet-4-2025", + "claude-opus-4-5", "claude-opus-4.5", "claude-sonnet-4-5", "claude-sonnet-4.5", "claude-haiku-4-5", + "claude-haiku-4.5", ) # Adaptive families that reject the "xhigh" effort (arrived with Opus 4.7) and still accept # sampling params. @@ -110,7 +103,6 @@ _NO_XHIGH_CLAUDE_SUBSTRINGS = ("claude-opus-4-6", "claude-opus-4.6", "claude-son # 400 (Portal flags them ``reasoning.mandatory``). The failure is asymmetric — a missing entry # 400s the turn, a spurious one only leaves thinking on — so when in doubt, add the family. _MANDATORY_THINKING_CLAUDE_SUBSTRINGS = ("claude-fable",) - _FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-8", "opus-4.8", "opus-5") @@ -124,28 +116,17 @@ def _model_matches(model: str, substrings) -> bool: return any(v in m for v in substrings) -# Max output tokens per model. Anthropic requires max_tokens; a fixed 16384 starved -# thinking-enabled models (thinking tokens count toward the limit). Source: Anthropic docs + Cline. +# Max output tokens per model (Anthropic docs + Cline catalog). Anthropic requires max_tokens; a +# fixed 16384 starved thinking-enabled models (thinking tokens count toward the limit). +# ``claude-fable`` = Mythos-class named models (1M context); ``minimax`` is a third-party +# Anthropic-compatible endpoint; DashScope enforces ``qwen3`` max_tokens in [1, 65536]. _ANTHROPIC_OUTPUT_LIMITS = { - "claude-fable": 128_000, # Mythos-class named models — 1M context, reasoning - "claude-sonnet-5": 128_000, - "claude-opus-4-8": 128_000, - "claude-opus-4-7": 128_000, - "claude-opus-4-6": 128_000, - "claude-sonnet-4-6": 64_000, - "claude-opus-4-5": 64_000, - "claude-sonnet-4-5": 64_000, - "claude-haiku-4-5": 64_000, - "claude-opus-4": 32_000, - "claude-sonnet-4": 64_000, - "claude-3-7-sonnet": 128_000, - "claude-3-5-sonnet": 8_192, - "claude-3-5-haiku": 8_192, - "claude-3-opus": 4_096, - "claude-3-sonnet": 4_096, - "claude-3-haiku": 4_096, - "minimax": 131_072, # third-party Anthropic-compatible - "qwen3": 65_536, # DashScope enforces max_tokens in [1, 65536] + "claude-fable": 128_000, "claude-sonnet-5": 128_000, "claude-opus-4-8": 128_000, + "claude-opus-4-7": 128_000, "claude-opus-4-6": 128_000, "claude-sonnet-4-6": 64_000, + "claude-opus-4-5": 64_000, "claude-sonnet-4-5": 64_000, "claude-haiku-4-5": 64_000, + "claude-opus-4": 32_000, "claude-sonnet-4": 64_000, "claude-3-7-sonnet": 128_000, + "claude-3-5-sonnet": 8_192, "claude-3-5-haiku": 8_192, "claude-3-opus": 4_096, + "claude-3-sonnet": 4_096, "claude-3-haiku": 4_096, "minimax": 131_072, "qwen3": 65_536, } # Unknown models get the highest current limit: future models are unlikely to have *less*. _ANTHROPIC_DEFAULT_OUTPUT_LIMIT = 128_000 @@ -172,20 +153,16 @@ def _resolve_positive_anthropic_max_tokens(value) -> Optional[int]: return None except Exception: # e.g. OverflowError for ints too large for float return None - floored = int(value) # truncates toward zero for floats - return floored if floored > 0 else None + return int(value) if int(value) > 0 else None # int() truncates toward zero for floats def _resolve_anthropic_messages_max_tokens(requested, model: str, context_length: Optional[int] = None) -> int: """``requested`` when it is a positive finite number, else the model's output ceiling. Raises ValueError if neither is positive. The context-window clamp is the caller's job so the positive-value contract stays endpoint-agnostic.""" - resolved = _resolve_positive_anthropic_max_tokens(requested) - if resolved is not None: + resolved = _resolve_positive_anthropic_max_tokens(requested) or _get_anthropic_max_output(model) + if resolved > 0: return resolved - fallback = _get_anthropic_max_output(model) - if fallback > 0: - return fallback raise ValueError( f"Anthropic Messages adapter requires a positive max_tokens value for " f"model {model!r}; got {requested!r} and no model default resolved." @@ -196,11 +173,9 @@ def _supports_adaptive_thinking(model: str) -> bool: """True for Claude models using adaptive thinking (4.6+): unknown Claude models default to adaptive, the explicit legacy list stays manual, and non-Claude models return False — except Kimi/Moonshot, whose Anthropic-compatible endpoints implement the adaptive contract.""" - if _model_name_is_kimi_family(model): - return True - if not _is_claude_model(model): - return False - return not _model_matches(model, _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS) + return _model_name_is_kimi_family(model) or ( + _is_claude_model(model) and not _model_matches(model, _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS) + ) def _supports_xhigh_effort(model: str) -> bool: @@ -243,18 +218,14 @@ def _supports_fast_mode(model: str) -> bool: # there) but older Claude and compatible endpoints still gate on them. Do NOT add # ``context-1m-2025-08-07``: accounts without the long-context beta get HTTP 400, breaking short # auxiliary calls. Bedrock/Azure still need it for 1M context and opt in on their own paths. -_COMMON_BETAS = [ - "interleaved-thinking-2025-05-14", "fine-grained-tool-streaming-2025-05-14" -] -# MiniMax's Anthropic-compatible endpoints fail tool-use requests when this beta is present. +# MiniMax's Anthropic-compatible endpoints fail tool-use requests when the tool-streaming beta is +# present. ``_FAST_MODE_BETA`` enables the ``speed: "fast"`` request parameter. _TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14" +_COMMON_BETAS = ["interleaved-thinking-2025-05-14", _TOOL_STREAMING_BETA] _CONTEXT_1M_BETA = "context-1m-2025-08-07" -# Enables the ``speed: "fast"`` request parameter. _FAST_MODE_BETA = "fast-mode-2026-02-01" # Required for OAuth/subscription auth; matches Claude Code / pi-ai / OpenCode. -_OAUTH_ONLY_BETAS = [ - "claude-code-20250219", "oauth-2025-04-20" -] +_OAUTH_ONLY_BETAS = ["claude-code-20250219", "oauth-2025-04-20"] # Claude Code identity — OAuth requests without it intermittently 500. Anthropic rejects OAuth # requests whose user-agent version is too far behind the actual release, so the installed @@ -296,9 +267,7 @@ _MCP_TOOL_PREFIX = "mcp__" # usage" on a valid subscription). Live A/B repros isolated two independent triggers — the # ``session_search`` tool (schema/name/prose) and the ``memory`` tool (schema/name) — so both are # aliased on the OAuth wire only; normalize_response reverses the mapping. -_OAUTH_TOOL_NAME_ALIASES = { - "session_search": "chat_history_lookup", "memory": "context_notes" -} +_OAUTH_TOOL_NAME_ALIASES = {"session_search": "chat_history_lookup", "memory": "context_notes"} _OAUTH_TOOL_NAME_REVERSE_ALIASES = {wire_name: name for name, wire_name in _OAUTH_TOOL_NAME_ALIASES.items()} # Aliases ALSO safe to substitute in free-form prose (system prompt, tool descriptions). "memory" @@ -342,14 +311,12 @@ def _beta_header(betas: list) -> Dict[str, str]: return {"anthropic-beta": ",".join(betas)} if betas else {} -_ATTRIBUTION_HEADERS = { - "HTTP-Referer": "https://hermes-agent.nousresearch.com", "X-Title": "Hermes Agent" -} - - def _attribution_headers() -> Dict[str, str]: """Same client-attribution set sent to OpenRouter / Vercel AI Gateway / Fireworks.""" - return {**_ATTRIBUTION_HEADERS, "User-Agent": f"HermesAgent/{_HERMES_VERSION}"} + return { + "HTTP-Referer": "https://hermes-agent.nousresearch.com", "X-Title": "Hermes Agent", + "User-Agent": f"HermesAgent/{_HERMES_VERSION}", + } def _client_timeout(timeout): @@ -361,15 +328,11 @@ def _client_timeout(timeout): def _base_client_kwargs(base_url, timeout) -> tuple[str, Dict[str, Any]]: - """Shared SDK constructor kwargs; returns ``(normalized_base_url, kwargs)``. - - Retry is delegated to hermes's outer loop (``max_retries=0``): the SDK default of 2 uses its - own backoff that ignores Retry-After and double-retries inside our loop, burning request slots - against a bucket that won't refill for minutes. Any trailing ``/v1`` is stripped because the - SDK appends ``/v1/messages``. Azure endpoints need an ``api-version`` query param; it goes - through ``default_query`` so the base_url is not corrupted into - ``/anthropic?api-version=.../v1/messages``. - """ + """Shared SDK constructor kwargs -> ``(normalized_base_url, kwargs)``. Retry is delegated to + hermes's outer loop (``max_retries=0``): the SDK default of 2 uses its own backoff that ignores + Retry-After and double-retries inside our loop. Any trailing ``/v1`` is stripped because the + SDK appends ``/v1/messages``. Azure's ``api-version`` goes through ``default_query`` so the + base_url is not corrupted into ``/anthropic?api-version=.../v1/messages``.""" kwargs: Dict[str, Any] = {"timeout": _client_timeout(timeout), "max_retries": 0} normalized = _normalize_base_url_text(base_url) if normalized: @@ -383,14 +346,11 @@ def _base_client_kwargs(base_url, timeout) -> tuple[str, Dict[str, Any]]: def _build_anthropic_client_with_bearer_hook( token_provider, base_url: str = None, timeout: float = None, *, drop_context_1m_beta: bool = False ): - """Anthropic-on-Foundry Entra ID variant of :func:`build_anthropic_client`. - - The SDK stores ``api_key``/``auth_token`` as static strings, so per-request bearer refresh - (Microsoft's documented Foundry pattern) is done with a custom ``httpx.Client`` whose request - hook mints a fresh JWT and rewrites ``Authorization`` on every request; the SDK skips its own - auth when ``http_client`` is given. A placeholder ``auth_token`` is still required at - construction — the hook overrides it, and the sentinel makes any accidental leak diagnosable. - """ + """Anthropic-on-Foundry Entra ID variant of :func:`build_anthropic_client`. The SDK stores + ``api_key``/``auth_token`` as static strings, so per-request bearer refresh (Microsoft's + documented Foundry pattern) uses a custom ``httpx.Client`` whose request hook mints a fresh JWT + and rewrites ``Authorization``; the SDK skips its own auth when ``http_client`` is given. The + placeholder ``auth_token`` is still required at construction and makes any leak diagnosable.""" sdk = _require_sdk("Azure Foundry Anthropic-style endpoints with Entra ID auth", verb="Install with") normalize_proxy_env_vars() @@ -417,16 +377,12 @@ def _new_sdk_client(sdk, kwargs: Dict[str, Any], headers: Dict[str, str]): def _auth_style(api_key, base_url, normalized_base_url) -> str: - """Order-sensitive endpoint/key classification for :func:`build_anthropic_client`. - - ``kimi``: Kimi's /coding endpoint 403s without a User-Agent (the Kimi team asked for proper - attribution instead of the ``claude-code/0.1.0`` minimum). ``bearer``: MiniMax & co. want the - key in Authorization: Bearer — checked before the OAuth shape test because their secrets lack - the sk-ant-api prefix and would be misread as Anthropic OAuth/setup tokens. ``api_key``: - third-party proxies use their own x-api-key keys (skip OAuth detection). ``oauth``: OAuth / - setup-token -> Bearer auth + Claude Code identity (Anthropic routes OAuth by - user-agent/headers; without the fingerprint, 500s). - """ + """Order-sensitive endpoint/key classification for :func:`build_anthropic_client`. ``kimi``: + Kimi's /coding endpoint 403s without a User-Agent (the Kimi team asked for proper attribution). + ``bearer``: MiniMax & co. want Authorization: Bearer — checked before the OAuth shape test + because their secrets lack the sk-ant-api prefix and would be misread as OAuth/setup tokens. + ``api_key``: third-party proxies use their own x-api-key keys (skip OAuth detection). ``oauth``: + Bearer auth + Claude Code identity (Anthropic routes OAuth by user-agent; without it, 500s).""" if _is_kimi_coding_endpoint(base_url): return "kimi" if _requires_bearer_auth(normalized_base_url): @@ -439,16 +395,12 @@ def _auth_style(api_key, base_url, normalized_base_url) -> str: def build_anthropic_client(api_key, base_url: str = None, timeout: float = None, *, drop_context_1m_beta: bool = False): - """Create an Anthropic client, auto-detecting setup-tokens vs API keys. - - ``api_key`` is a static ``str`` (all key-based and OAuth flows) or a ``Callable[[], str]`` - Entra ID bearer provider, routed through :func:`_build_anthropic_client_with_bearer_hook`. - ``timeout`` overrides the 900s read timeout (connect stays 10s) from the per-provider / - per-model ``request_timeout_seconds`` config. ``drop_context_1m_beta`` strips - ``context-1m-2025-08-07`` from the client-level beta header — used by the reactive OAuth retry - in run_agent when a subscription rejects it; fresh clients keep the default so 1M-capable - subscriptions keep the capability. - """ + """Create an Anthropic client, auto-detecting setup-tokens vs API keys. ``api_key`` is a static + ``str`` or a ``Callable[[], str]`` Entra ID bearer provider (routed through + :func:`_build_anthropic_client_with_bearer_hook`). ``timeout`` overrides the 900s read timeout + (connect stays 10s). ``drop_context_1m_beta`` strips ``context-1m-2025-08-07`` from the + client-level beta header — the reactive OAuth retry in run_agent uses it after a subscription + rejects it; fresh clients keep the default so 1M-capable subscriptions keep the capability.""" sdk = _require_sdk("the Anthropic provider") if callable(api_key) and not isinstance(api_key, str): return _build_anthropic_client_with_bearer_hook( @@ -522,10 +474,9 @@ def _oauth_wire_namer(anthropic_tools: List[Dict[str, Any]]): } def to_wire(name: str) -> str: - if name in _OAUTH_TOOL_NAME_ALIASES: - aliased = _OAUTH_TOOL_NAME_ALIASES[name] - if _MCP_TOOL_PREFIX + aliased not in claimed: - name = aliased + aliased = _OAUTH_TOOL_NAME_ALIASES.get(name) + if aliased and _MCP_TOOL_PREFIX + aliased not in claimed: + name = aliased return _normalize_to_mcp_wire(name) return to_wire @@ -543,12 +494,9 @@ def _apply_claude_code_identity(system, anthropic_tools, anthropic_messages, to_ blocks so history matches ``tools[]``. Returns the new ``system``; tools and messages are mutated in place.""" cc_block = {"type": "text", "text": _CLAUDE_CODE_SYSTEM_PREFIX} - if isinstance(system, list): - system = [cc_block] + system - elif isinstance(system, str) and system: - system = [cc_block, {"type": "text", "text": system}] - else: - system = [cc_block] + if isinstance(system, str) and system: + system = [{"type": "text", "text": system}] + system = [cc_block] + (system if isinstance(system, list) else []) for block in system: if isinstance(block, dict) and block.get("type") == "text": text = block.get("text", "") @@ -612,19 +560,14 @@ def build_anthropic_kwargs( is_oauth: bool = False, preserve_dots: bool = False, context_length: Optional[int] = None, base_url: str | None = None, fast_mode: bool = False, drop_context_1m_beta: bool = False, ) -> Dict[str, Any]: - """Build kwargs for anthropic.messages.create(). - - Two easily confused concepts: ``max_tokens`` is the OUTPUT cap for one response; - ``context_length`` is the TOTAL window (input + output), enforced as ``input_tokens + - max_tokens <= context_length``. ``max_tokens=None`` uses the model's native output ceiling; if - that exceeds ``context_length`` (small local endpoints) it is clamped to ``context_length - - 1``. The clamp ignores prompt size — callers must catch "max_tokens too large given prompt" - and retry smaller (parse_available_output_tokens_from_error). - + """Build kwargs for anthropic.messages.create(). ``max_tokens`` is the OUTPUT cap for one + response; ``context_length`` is the TOTAL window (input + output). ``max_tokens=None`` uses the + model's native output ceiling; if that exceeds ``context_length`` (small local endpoints) it is + clamped to ``context_length - 1``. The clamp ignores prompt size — callers must catch + "max_tokens too large given prompt" and retry smaller (parse_available_output_tokens_from_error). ``is_oauth`` applies Claude Code compatibility transforms; ``preserve_dots`` keeps model-name dots (DashScope: qwen3.5-plus); a third-party ``base_url`` strips thinking signatures; - ``fast_mode`` adds ``extra_body.speed="fast"`` plus the fast-mode beta on native Anthropic only. - """ + ``fast_mode`` adds ``extra_body.speed="fast"`` plus the fast-mode beta on native Anthropic only.""" system, anthropic_messages = convert_messages_to_anthropic(messages, base_url=base_url, model=model) anthropic_tools = convert_tools_to_anthropic(tools) if tools else [] @@ -662,8 +605,8 @@ def build_anthropic_kwargs( # Safety net so upstream 4.6 -> 4.7 migrations don't need coordinated edits everywhere callers # (auxiliary_client, ...) set sampling params. if _forbids_sampling_params(model): - for _sampling_key in ("temperature", "top_p", "top_k"): - kwargs.pop(_sampling_key, None) + for key in ("temperature", "top_p", "top_k"): + kwargs.pop(key, None) # Fast mode: native Anthropic only — third-party providers reject the unknown beta/param and # Anthropic scopes it to the Claude API (not Bedrock/Vertex/Foundry). Per-request extra_headers @@ -694,8 +637,8 @@ def sanitize_anthropic_kwargs(api_kwargs: Any, *, log_prefix: str = "") -> Any: return api_kwargs leaked = _RESPONSES_ONLY_KWARGS.intersection(api_kwargs) if leaked: - for _key in leaked: - del api_kwargs[_key] + for key in leaked: + del api_kwargs[key] logger.warning( "%sStripped Responses-only kwarg(s) %s from an Anthropic Messages " "call (api_mode flip race — see #31673). The call will proceed; " @@ -748,17 +691,14 @@ def create_anthropic_message( client: Any, api_kwargs: dict, *, log_prefix: str = "", prefer_stream: bool = True, on_stream_event=None, on_response=None, ) -> Any: - """Create an Anthropic message, aggregating via stream when available. - - Some Anthropic-compatible gateways are SSE-only and answer ``create()`` with - ``text/event-stream``, which the SDK surfaces as raw text (callers then crash on - ``.content``). So prefer ``messages.stream().get_final_message()`` like the main turn path, - falling back to ``create()`` only for providers that explicitly don't support streaming - (restricted Bedrock roles). Both callbacks are best-effort (exceptions swallowed) and fire - only on the streaming path: ``on_stream_event(event)`` lets liveness watchdogs see forward - progress; ``on_response(httpx_response)`` exposes headers the parsed Message drops (Nous - Portal's ``x-nous-credits-*`` balance family). - """ + """Create an Anthropic message, aggregating via stream when available. Some Anthropic-compatible + gateways are SSE-only and answer ``create()`` with ``text/event-stream``, which the SDK surfaces + as raw text (callers then crash on ``.content``), so prefer ``messages.stream()`` like the main + turn path and fall back to ``create()`` only for providers that explicitly don't support + streaming (restricted Bedrock roles). Both callbacks are best-effort and fire only on the + streaming path: ``on_stream_event(event)`` lets liveness watchdogs see forward progress; + ``on_response(httpx_response)`` exposes headers the parsed Message drops (Nous Portal's + ``x-nous-credits-*`` balance family).""" sanitize_anthropic_kwargs(api_kwargs, log_prefix=log_prefix) messages_api = getattr(client, "messages", None) diff --git a/agent/anthropic_message_convert.py b/agent/anthropic_message_convert.py index f84e78d917..f0c76238e8 100644 --- a/agent/anthropic_message_convert.py +++ b/agent/anthropic_message_convert.py @@ -29,7 +29,6 @@ _BEDROCK_REGION_PREFIXES = ( ) - def _block_type(b: Any) -> Any: """``type`` of a dict block, None for non-dicts.""" return b.get("type") if isinstance(b, dict) else None @@ -81,10 +80,17 @@ def _block_ids(blocks: List[Any], btype: str, key: str) -> set: return {b.get(key) for b in blocks if _block_type(b) == btype} -def _carry_cache_control(out: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]: - """Copy a dict-valued ``cache_control`` marker from ``b`` onto ``out`` (returned).""" - if _cache_control_of(b) is not None: - out["cache_control"] = b["cache_control"] +def _assistant_block_lists(result: List[Dict[str, Any]]): + """``(index, message)`` for every assistant message whose content is a block list.""" + return ((i, m) for i, m in enumerate(result) if m.get("role") == "assistant" and isinstance(m.get("content"), list)) + + +def _carry_cache_control(out: Dict[str, Any], b: Any, *, copy: bool = False) -> Dict[str, Any]: + """Carry a dict-valued ``cache_control`` marker from ``b`` onto ``out`` (returned); ``copy`` + shallow-copies it so the caller's dict is never shared with the wire payload.""" + cc = _cache_control_of(b) + if cc is not None: + out["cache_control"] = dict(cc) if copy else cc return out @@ -104,7 +110,6 @@ def _split_blank_text_blocks(blocks: List[Any]) -> Tuple[List[Any], Any, List[in return kept, relocated_cc, dropped - def _is_bedrock_model_id(model: str) -> bool: """Bedrock ids (``anthropic.claude-opus-4-7``, ``us.anthropic.claude-*``) use dots as namespace separators that must be preserved verbatim.""" @@ -177,14 +182,10 @@ def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]: "description": fn.get("description", ""), "input_schema": _normalize_tool_input_schema(fn.get("parameters") or {}), } - cache_control = _cache_control_of(t) - if cache_control is not None: - anthropic_tool["cache_control"] = dict(cache_control) - result.append(anthropic_tool) + result.append(_carry_cache_control(anthropic_tool, t, copy=True)) return result - def _image_source_from_openai_url(url: str) -> Dict[str, str]: """OpenAI image URL / data URL -> Anthropic image ``source``.""" url = str(url or "").strip() @@ -303,7 +304,6 @@ def _safe_text(text: Any) -> str: return text if text.strip() else _EMPTY_TEXT_PLACEHOLDER - def _replay_text(b: Dict[str, Any]) -> Optional[Dict[str, Any]]: # Drop blank blocks rather than coerce in place: the caller relocates any cache_control and # falls back to a placeholder only when nothing survives, so "(empty)" never sits as @@ -360,7 +360,6 @@ def _apply_assistant_cache_control_to_last_cacheable_block(blocks: List[Dict[str break - def _replay_ordered_blocks(m: Dict[str, Any], ordered_blocks: List[Any]) -> Optional[List[Dict[str, Any]]]: """Interleaved-thinking replay: rebuild the assistant turn from the verbatim block list normalize_response stored (only for turns interleaving SIGNED thinking with tool_use). @@ -479,9 +478,7 @@ def _convert_tool_message_to_result(result: List[Dict[str, Any]], m: Dict[str, A "type": "tool_result", "tool_use_id": _sanitize_tool_id(m.get("tool_call_id", "")), "content": _tool_result_content(m), } - cache_control = _cache_control_of(m) - if cache_control is not None: - tool_result["cache_control"] = dict(cache_control) + _carry_cache_control(tool_result, m, copy=True) last = result[-1] if result else {} if last.get("role") == "user" and isinstance(last.get("content"), list) and last["content"] \ and last["content"][0].get("type") == "tool_result": @@ -493,26 +490,22 @@ def _convert_tool_message_to_result(result: List[Dict[str, Any]], m: Dict[str, A def _convert_user_message(content: Any) -> Dict[str, Any]: """Validate and convert a user message to Anthropic format.""" if isinstance(content, list): - kept_blocks = _fix_blank_text_blocks_in_list( + content = _fix_blank_text_blocks_in_list( _convert_content_to_anthropic(content), placeholder_text="(empty message)", msg_index=-1, role="user", location="_convert_user_message", ) - return {"role": "user", "content": kept_blocks} - if not content or (isinstance(content, str) and not content.strip()): + elif not content or (isinstance(content, str) and not content.strip()): content = "(empty message)" return {"role": "user", "content": content} - def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None: """Strip tool_use blocks with no matching tool_result, and vice versa. Compression/truncation can remove either side of a pair or insert messages between them. Anthropic requires the tool_result in the IMMEDIATELY FOLLOWING user message — a global id match is not enough. Mutates ``result`` in place.""" # Pass 1: tool_use without an adjacent result. - for i, m in enumerate(result): - if m.get("role") != "assistant" or not isinstance(m.get("content"), list): - continue + for i, m in _assistant_block_lists(result): tool_use_ids_in_turn = _block_ids(m["content"], "tool_use", "id") if not tool_use_ids_in_turn: continue @@ -534,9 +527,8 @@ def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None: # Pass 2: tool_result whose tool_use no longer exists anywhere. surviving_tool_use_ids: set = set() - for m in result: - if m.get("role") == "assistant" and isinstance(m.get("content"), list): - surviving_tool_use_ids |= _block_ids(m["content"], "tool_use", "id") + for _, m in _assistant_block_lists(result): + surviving_tool_use_ids |= _block_ids(m["content"], "tool_use", "id") for m in result: if m.get("role") != "user" or not isinstance(m.get("content"), list): continue @@ -612,9 +604,7 @@ def _manage_thinking_signatures(result: List[Dict[str, Any]], base_url: str | No is_deepseek = _is_deepseek_anthropic_endpoint(base_url) last_assistant_idx = next((i for i in range(len(result) - 1, -1, -1) if result[i].get("role") == "assistant"), None) - for idx, m in enumerate(result): - if m.get("role") != "assistant" or not isinstance(m.get("content"), list): - continue + for idx, m in _assistant_block_lists(result): if is_kimi: pass # shared cleanup below still strips cache markers + the flag elif is_deepseek: @@ -643,12 +633,8 @@ def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None: image_count = 0 for msg in reversed(result): content = msg.get("content") - if not isinstance(content, list): - continue - for block in content: - if _block_type(block) != "tool_result": - continue - inner = block.get("content") + for block in content if isinstance(content, list) else []: + inner = block.get("content") if _block_type(block) == "tool_result" else None if not isinstance(inner, list) or not _has_block_type(inner, {"image"}): continue image_count += 1 @@ -697,19 +683,16 @@ def _scrub_blank_text_blocks(result: List[Dict[str, Any]]) -> None: content = msg.get("content") if not isinstance(content, list) or not content: continue - new_content = _fix_blank_text_blocks_in_list( + msg["content"] = _fix_blank_text_blocks_in_list( content, placeholder_text=_EMPTY_TEXT_PLACEHOLDER if role == "assistant" else "(empty message)", msg_index=msg_index, role=role, location="content", ) - for blk in new_content: - if _block_type(blk) != "tool_result": - continue - inner = blk.get("content") + for blk in msg["content"]: + inner = blk.get("content") if _block_type(blk) == "tool_result" else None if isinstance(inner, list) and inner: blk["content"] = _fix_blank_text_blocks_in_list( inner, placeholder_text="(no output)", msg_index=msg_index, role=role, location="tool_result", ) - msg["content"] = new_content def _convert_system_content(content: Any) -> Any: @@ -721,14 +704,11 @@ def _convert_system_content(content: Any) -> Any: return content if not any(p.get("cache_control") for p in content if isinstance(p, dict)): return "\n".join(p["text"] for p in content if p.get("type") == "text") - system = [] - for p in content: - if not isinstance(p, dict): - continue - if p.get("type") == "text" and isinstance(p.get("text"), str) and not p["text"].strip(): - p = {**p, "text": _EMPTY_TEXT_PLACEHOLDER} - system.append(p) - return system + return [ + {**p, "text": _EMPTY_TEXT_PLACEHOLDER} if _is_blank_text_block(p) and isinstance(p.get("text"), str) else p + for p in content + if isinstance(p, dict) + ] def convert_messages_to_anthropic( From 45e070d23e24d8c54a4bcd93a914ca54ed1ea581 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:11:43 -0700 Subject: [PATCH 09/12] refactor(agent/background_review): prompt-scope dispatch table, _tool_messages iterator, fold metadata/usage builders, compact docstrings --- agent/background_review.py | 320 ++++++++++++++----------------------- 1 file changed, 121 insertions(+), 199 deletions(-) diff --git a/agent/background_review.py b/agent/background_review.py index bfc65d97e2..7894bf121a 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -1,12 +1,9 @@ -"""Background memory/skill review — fork the agent to evaluate the turn. - -After every turn ``AIAgent.run_conversation`` may spawn a daemon thread that replays the -conversation snapshot in a forked :class:`AIAgent` and asks "should any skill/memory be saved -or updated?". Writes go straight to the memory + skill stores; the main conversation and prompt -cache are never touched. The fork inherits the parent's live runtime (provider, model, -credentials, cached system prompt) so it hits the same prefix cache, and runs under a -dispatch-side tool whitelist limited to memory/skill tools. -""" +"""Background memory/skill review — fork the agent to evaluate the turn. After every turn +``AIAgent.run_conversation`` may spawn a daemon thread that replays the conversation snapshot in a +forked :class:`AIAgent` and asks "should any skill/memory be saved or updated?". Writes go +straight to the memory + skill stores; the main conversation and prompt cache are never touched. +The fork inherits the parent's live runtime (provider, model, credentials, cached system prompt) +so it hits the same prefix cache, and runs under a dispatch-side tool whitelist.""" from __future__ import annotations @@ -23,7 +20,6 @@ from agent.thread_scoped_output import thread_scoped_silence logger = logging.getLogger(__name__) - _BACKGROUND_REVIEW_CANCEL_TIMEOUT_SECONDS = 2.0 @@ -124,24 +120,17 @@ def _interrupt_background_review(review_agent: Any) -> None: def cancel_background_review_for_live_turn(agent: Any) -> None: - """Cancel the current review and await its request-phase acknowledgement. - - Foreground priority: past the bounded deadline, warn and let the live turn proceed — - self-improvement work must never block a user-facing turn. - """ + """Cancel the current review and await its request-phase acknowledgement. Foreground priority: + past the bounded deadline, warn and let the live turn proceed — self-improvement work must + never block a user-facing turn.""" with _optional_lock(agent, "_background_review_lock"): run = getattr(agent, "_background_review_run", None) legacy_agent = getattr(agent, "_background_review_agent", None) - - if run is None: - if legacy_agent is not None: - _interrupt_background_review(legacy_agent) - return - - review_agent = run.cancel() + review_agent = legacy_agent if run is None else run.cancel() if review_agent is not None: _interrupt_background_review(review_agent) - + if run is None: + return if not run.request_done.wait(timeout=_BACKGROUND_REVIEW_CANCEL_TIMEOUT_SECONDS): logger.warning( "Background review did not acknowledge cancellation within %.1fs; " @@ -153,9 +142,7 @@ def cancel_background_review_for_live_turn(agent: Any) -> None: # Aux-model routing: by default ("auto") the fork runs on the MAIN model and replays the full # conversation as warm cache reads. When auxiliary.background_review.{provider,model} routes it # to a DIFFERENT model the cache is cold anyway, so the fork replays a compact digest instead. - _REVIEW_MAX_ITERATIONS = 16 - # Aggregate INPUT-token budget for one review fork (checked in conversation_loop's # ``_review_input_budget_exhausted``). Request #1 replays the full snapshot as a warm cache read # (both compression gates deferred until the first response); compaction then bounds each @@ -213,38 +200,29 @@ def load_background_review_settings() -> tuple[bool, Dict[str, Any]]: def _resolve_review_runtime(agent: Any, task_cfg: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - """Resolve provider/model/credentials for the review fork. - - Default (auto / unset / same as parent): the parent's live runtime with ``routed=False`` - (codex_app_server -> codex_responses downgrade applied). When - ``auxiliary.background_review.{provider,model}`` names a different concrete model, resolve - that runtime and set ``routed=True``. - """ + """Resolve provider/model/credentials for the review fork. Default (auto / unset / same as + parent): the parent's live runtime with ``routed=False`` (codex_app_server -> codex_responses + downgrade applied). When ``auxiliary.background_review.{provider,model}`` names a different + concrete model, resolve that runtime and set ``routed=True``.""" parent_runtime = agent._current_main_runtime() parent_api_mode = parent_runtime.get("api_mode") or None - if parent_api_mode == "codex_app_server": - parent_api_mode = "codex_responses" parent = { - "provider": agent.provider, - "model": agent.model, - "api_key": parent_runtime.get("api_key") or None, - "base_url": parent_runtime.get("base_url") or None, - "api_mode": parent_api_mode, + "provider": agent.provider, "model": agent.model, + "api_key": parent_runtime.get("api_key") or None, "base_url": parent_runtime.get("base_url") or None, + "api_mode": "codex_responses" if parent_api_mode == "codex_app_server" else parent_api_mode, "credential_pool": getattr(agent, "_credential_pool", None), "request_overrides": dict(getattr(agent, "request_overrides", {}) or {}), - "max_tokens": getattr(agent, "max_tokens", None), - "command": getattr(agent, "acp_command", None), - "args": list(getattr(agent, "acp_args", []) or []), - "routed": False, + "max_tokens": getattr(agent, "max_tokens", None), "command": getattr(agent, "acp_command", None), + "args": list(getattr(agent, "acp_args", []) or []), "routed": False, } task = _background_review_task_config(task_cfg) task_provider, task_model, task_base_url, task_api_key = ( str(task.get(key, "")).strip() or None for key in ("provider", "model", "base_url", "api_key") ) - if not (task_provider and task_provider != "auto" and task_model): + if not (task_provider and task_provider != "auto" and task_model) or ( + task_provider == (agent.provider or "") and task_model == (agent.model or "") # same as parent + ): return parent - if task_provider == (agent.provider or "") and task_model == (agent.model or ""): - return parent # same model/provider as parent -> not routed try: from hermes_cli.runtime_provider import resolve_runtime_provider rp = resolve_runtime_provider( @@ -252,13 +230,10 @@ def _resolve_review_runtime(agent: Any, task_cfg: Optional[Dict[str, Any]] = Non explicit_api_key=task_api_key, explicit_base_url=task_base_url, ) return { - "provider": rp.get("provider") or task_provider, - "model": rp.get("model") or task_model, + "provider": rp.get("provider") or task_provider, "model": rp.get("model") or task_model, **{key: rp.get(key) for key in ("api_key", "base_url", "api_mode", "credential_pool", "command")}, "request_overrides": dict(rp.get("request_overrides") or {}), - "max_tokens": rp.get("max_output_tokens"), - "args": list(rp.get("args") or []), - "routed": True, + "max_tokens": rp.get("max_output_tokens"), "args": list(rp.get("args") or []), "routed": True, } except Exception as e: logger.debug("background-review aux routing failed (%s); using main model", e) @@ -308,21 +283,16 @@ def _digest_history(messages_snapshot: List[Dict], tail: int = 24) -> List[Dict] lines.append(f"USER: {text[:300]}") elif role == "assistant": if m.get("tool_calls"): - names = [ - (tc.get("function") or {}).get("name", "?") for tc in m["tool_calls"] if isinstance(tc, dict) - ] + names = [(tc.get("function") or {}).get("name", "?") for tc in m["tool_calls"] if isinstance(tc, dict)] lines.append(f"ASSISTANT[tools: {', '.join(names)}]") if text: lines.append(f"ASSISTANT: {text[:200]}") - digest = { - "role": "user", - "content": ( - "[Earlier conversation digest — older turns summarised to bound the " - "review's cold-write cost on the routed aux model. Recent turns " - "follow verbatim below.]\n" + "\n".join(lines) - ), - } - return [digest] + keep + digest = ( + "[Earlier conversation digest — older turns summarised to bound the " + "review's cold-write cost on the routed aux model. Recent turns " + "follow verbatim below.]\n" + "\n".join(lines) + ) + return [{"role": "user", "content": digest}] + keep # Review prompts. AIAgent exposes them as class attributes @@ -527,11 +497,8 @@ _MEMORY_OP_FORMATS: Dict[str, Tuple[str, str, int]] = { def _memory_op_line(label: str, action: str, fields: Dict[str, str]) -> Optional[str]: """Verbose line for one memory add/replace/remove, or None when no preview text.""" - fmt = _MEMORY_OP_FORMATS.get(action) - if fmt is None: - return None - glyph, field_name, limit = fmt - text = fields.get(field_name) or "" + glyph, field_name, limit = _MEMORY_OP_FORMATS.get(action) or (None, "", 0) + text = fields.get(field_name) or "" if glyph else "" return f"{label} {glyph} {_preview(text, limit)}" if text else None @@ -556,12 +523,10 @@ def _verbose_skill_line(data: Dict, detail: Dict, message: str) -> str: def _verbose_memory_lines(label: str, detail: Dict) -> List[str]: # ``operations`` may be any JSON value; only a list of dicts is usable. ops_raw = detail.get("operations") - operations: list = ops_raw if isinstance(ops_raw, list) else [] - if operations: - lines = [_memory_op_line(label, op.get("action", ""), op) for op in operations if isinstance(op, dict)] + if isinstance(ops_raw, list) and ops_raw: + lines = [_memory_op_line(label, op.get("action", ""), op) for op in ops_raw if isinstance(op, dict)] return [line for line in lines if line] - line = _memory_op_line(label, detail.get("action", ""), detail) - return [line or f"{label} updated"] + return [_memory_op_line(label, detail.get("action", ""), detail) or f"{label} updated"] # Tool-call argument fields surfaced in action summaries, with their defaults. @@ -572,12 +537,9 @@ _CALL_DETAIL_DEFAULTS = ( def _collect_review_call_details(review_messages: List[Dict]) -> Tuple[set, dict]: - """Map review-agent tool_call ids -> parsed call arguments for notify tools. - - Result JSON only says "Entry added"; the call arguments carry action, target and content - previews. Restricting to notify tools keeps helper tools from surfacing as memory work just - because they succeeded. - """ + """Map review-agent tool_call ids -> parsed call arguments for notify tools. Result JSON only + says "Entry added"; the call arguments carry action, target and content previews. Restricting + to notify tools keeps helper tools from surfacing as memory work just because they succeeded.""" notify_tools = {"memory", "skill_manage"} all_tool_call_ids: set = set() call_details: dict = {} @@ -592,32 +554,28 @@ def _collect_review_call_details(review_messages: List[Dict]) -> Tuple[set, dict tcid = tc.get("id") if tcid: all_tool_call_ids.add(tcid) - if fn_name not in notify_tools: + if fn_name not in notify_tools or not tcid: continue try: args = json.loads(fn.get("arguments", "{}")) except (json.JSONDecodeError, TypeError): args = {} - if tcid: - call_details[tcid] = { - "tool": fn_name, "operations": args.get("operations") or [], - **{k: args.get(k, default) for k, default in _CALL_DETAIL_DEFAULTS}, - } + call_details[tcid] = { + "tool": fn_name, "operations": args.get("operations") or [], + **{k: args.get(k, default) for k, default in _CALL_DETAIL_DEFAULTS}, + } return all_tool_call_ids, call_details +def _tool_messages(messages: List[Dict]) -> Iterator[Dict]: + return (m for m in messages or [] if isinstance(m, dict) and m.get("role") == "tool") + + def _prior_tool_keys(prior_snapshot: List[Dict]) -> Tuple[set, set]: """``(tool_call_ids, contents)`` of tool messages already in the parent snapshot.""" - ids: set = set() - contents: set = set() - for prior in prior_snapshot or []: - if not isinstance(prior, dict) or prior.get("role") != "tool": - continue - tcid = prior.get("tool_call_id") - if tcid: - ids.add(tcid) - elif isinstance(prior.get("content"), str): - contents.add(prior["content"]) + priors = list(_tool_messages(prior_snapshot)) + ids = {m["tool_call_id"] for m in priors if m.get("tool_call_id")} + contents = {m["content"] for m in priors if not m.get("tool_call_id") and isinstance(m.get("content"), str)} return ids, contents @@ -649,13 +607,11 @@ def _action_lines(data: Dict, detail: Dict, verbose: bool) -> List[str]: def summarize_background_review_actions( review_messages: List[Dict], prior_snapshot: List[Dict], notification_mode: str = "on" ) -> List[str]: - """Build the human-facing action summary for a background review pass. - - Collects successful memory / skill-management tool results from the review agent's messages, - skipping tool messages already present in ``prior_snapshot`` so inherited results are not - re-surfaced as fresh work. ``notification_mode``: ``off`` -> no actions; ``on`` -> generic - "Memory updated"/tool messages; ``verbose`` -> content previews from the tool-call arguments. - """ + """Human-facing action summary for a background review pass: successful memory / + skill-management tool results from the review agent's messages, skipping tool messages already + present in ``prior_snapshot`` so inherited results are not re-surfaced as fresh work. + ``notification_mode``: ``off`` -> no actions; ``on`` -> generic "Memory updated"/tool messages; + ``verbose`` -> content previews from the tool-call arguments.""" mode = str(notification_mode or "on").lower() if mode == "off": return [] @@ -664,17 +620,12 @@ def summarize_background_review_actions( all_tool_call_ids, call_details = _collect_review_call_details(review_messages) actions: List[str] = [] - for msg in review_messages or []: - if not isinstance(msg, dict) or msg.get("role") != "tool": - continue + for msg in _tool_messages(review_messages): tcid = msg.get("tool_call_id") - if tcid and tcid in existing_tool_call_ids: - continue - if not tcid: - content_str = msg.get("content") - if isinstance(content_str, str) and content_str in existing_tool_contents: + if tcid: + if tcid in existing_tool_call_ids or (all_tool_call_ids and tcid not in call_details): continue - if tcid and all_tool_call_ids and tcid not in call_details: + elif isinstance(msg.get("content"), str) and msg["content"] in existing_tool_contents: continue try: data = json.loads(msg.get("content", "{}")) @@ -700,11 +651,9 @@ def build_memory_write_metadata( "parent_session_id": agent._parent_session_id or "", "platform": agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"), "tool_name": "memory", + "task_id": task_id or None, + "tool_call_id": tool_call_id or None, } - if task_id: - metadata["task_id"] = task_id - if tool_call_id: - metadata["tool_call_id"] = tool_call_id return {k: v for k, v in metadata.items() if v not in {None, ""}} @@ -715,20 +664,18 @@ _USAGE_COUNTERS = ( def _snapshot_review_usage(review_agent: Any) -> Dict[str, Any]: """Snapshot in-memory usage counters from a review fork (pre-close).""" - usage: Dict[str, Any] = {key: getattr(review_agent, key, None) for key in ("model", "provider", "base_url")} - for key in _USAGE_COUNTERS: - usage[key] = int(getattr(review_agent, f"session_{key}", 0) or 0) - usage["estimated_cost_usd"] = getattr(review_agent, "session_estimated_cost_usd", None) - return usage + return { + **{key: getattr(review_agent, key, None) for key in ("model", "provider", "base_url")}, + **{key: int(getattr(review_agent, f"session_{key}", 0) or 0) for key in _USAGE_COUNTERS}, + "estimated_cost_usd": getattr(review_agent, "session_estimated_cost_usd", None), + } def _record_review_usage_to_parent(parent_agent: Any, usage: Dict[str, Any]) -> None: - """Record a fork's usage against the parent session (best-effort, never raises). - - The fork has ``_session_db = None`` so conversation_loop's DB-gated accounting never sees its - calls; route them through the aux-accounting chokepoint, which writes only - ``session_model_usage`` — never the transcript or ``sessions`` row. - """ + """Record a fork's usage against the parent session (best-effort, never raises). The fork has + ``_session_db = None`` so conversation_loop's DB-gated accounting never sees its calls; route + them through the aux-accounting chokepoint, which writes only ``session_model_usage`` — never + the transcript or ``sessions`` row.""" try: session_db = getattr(parent_agent, "_session_db", None) session_id = getattr(parent_agent, "session_id", None) @@ -749,11 +696,9 @@ def _record_review_usage_to_parent(parent_agent: Any, usage: Dict[str, Any]) -> def _classify_review_result(actions: List[str]) -> str: """Map a review action summary to ``none`` / ``skill`` / ``memory`` / ``skill+memory``. - Prefix-based on the formats :func:`summarize_background_review_actions` emits (``Skill …``, ``📝 Skill …``, ``Memory …``, ``User profile …``), so a free-text line like ``Skipped: no - skill worth saving`` stays ``none``. - """ + skill worth saving`` stays ``none``.""" lowers = [str(action).lstrip().removeprefix("📝").lstrip().lower() for action in actions or []] has_skill = any(t.startswith("skill") for t in lowers) has_memory = any(t.startswith(("memory", "user profile")) for t in lowers) @@ -765,10 +710,7 @@ def _log_review_completion(usage: Dict[str, Any], result: str) -> None: logger.info( "Background review complete: thread=bg-review calls=%d in=%d out=%d " "cache_read=%d result=%s", - int(usage.get("api_calls") or 0), - int(usage.get("input_tokens") or 0), - int(usage.get("output_tokens") or 0), - int(usage.get("cache_read_tokens") or 0), + *(int(usage.get(k) or 0) for k in ("api_calls", "input_tokens", "output_tokens", "cache_read_tokens")), result, ) @@ -783,17 +725,14 @@ _PROVIDER_PIN_ATTRS = ( def _same_model_parity_kwargs(agent: Any) -> Dict[str, Any]: - """AIAgent kwargs that keep a SAME-model fork's request bytes identical to the parent's. - - Only for the un-routed path: on a different model the cache is cold anyway, and the parent's + """AIAgent kwargs that keep a SAME-model fork's request bytes identical to the parent's. Only + for the un-routed path: on a different model the cache is cold anyway, and the parent's reasoning-effort vocabulary may be invalid for the routed provider (OpenRouter forwards - ``reasoning.effort`` unclamped; codex_responses passes ``max``/``ultra`` through unmapped). - """ + ``reasoning.effort`` unclamped; codex_responses passes ``max``/``ultra`` through unmapped).""" kwargs: Dict[str, Any] = { - # Anthropic's cache key is namespaced by ``thinking`` presence. + # Anthropic's cache key is namespaced by ``thinking`` presence; the gateway session context + # is appended to the cached system prompt at API-call time (without it the prompt diverges). "reasoning_config": getattr(agent, "reasoning_config", None), - # Gateway session context appended to the cached system prompt at API-call time; - # without it the effective system prompt diverges. "ephemeral_system_prompt": getattr(agent, "ephemeral_system_prompt", None), } # Prefill sits right after the system message, so a parent with prefill would diverge at @@ -810,15 +749,13 @@ def _same_model_parity_kwargs(agent: Any) -> Dict[str, Any]: def _detach_fork_compression(review_agent: Any) -> None: - """Detached in-memory compaction for a fork sharing the parent's session_id. - - Disabling compression (the old guard against compacting the parent's live session) removed - the only bound on the review's snapshot. Persistence is already off, so compaction can only - rewrite the fork's transcript — but the compressor's own SessionDB/session_id binding must be - severed too, or cooldown/streak counters land on the parent's row. Force in-place mode and - re-enable compression ONLY after the rebind succeeded (fail-closed); gates stay deferred - until the first response so request #1 is a warm cache read. - """ + """Detached in-memory compaction for a fork sharing the parent's session_id. Disabling + compression (the old guard against compacting the parent's live session) removed the only + bound on the review's snapshot. Persistence is already off, so compaction can only rewrite the + fork's transcript — but the compressor's own SessionDB/session_id binding must be severed too, + or cooldown/streak counters land on the parent's row. Force in-place mode and re-enable + compression ONLY after the rebind succeeded (fail-closed); gates stay deferred until the first + response so request #1 is a warm cache read.""" bind = getattr(getattr(review_agent, "context_compressor", None), "bind_session_state", None) detached = False if callable(bind): @@ -843,28 +780,19 @@ def _detach_fork_compression(review_agent: Any) -> None: def _fork_init_kwargs(agent: Any, rt: Dict[str, Any], routed: bool, max_iterations: int) -> Dict[str, Any]: - """AIAgent constructor kwargs for the review fork. - - skip_memory=True: an external memory plugin scoped to the parent's session_id would leak the - harness prompt into the user's real memory namespace; built-in MEMORY.md/USER.md state is - re-bound by the caller. Toolsets match the parent so ``tools[]`` is byte-identical - (Anthropic's cache key includes it); the runtime whitelist restricts dispatch. - """ + """AIAgent constructor kwargs for the review fork. skip_memory=True: an external memory plugin + scoped to the parent's session_id would leak the harness prompt into the user's real memory + namespace; built-in MEMORY.md/USER.md state is re-bound by the caller. Toolsets match the + parent so ``tools[]`` is byte-identical (Anthropic's cache key includes it); the runtime + whitelist restricts dispatch.""" kwargs: Dict[str, Any] = { - "model": rt.get("model") or agent.model, - "max_iterations": max_iterations, - "quiet_mode": True, - "platform": agent.platform, - "provider": rt.get("provider") or agent.provider, - "api_mode": rt.get("api_mode"), - "base_url": rt.get("base_url") or None, - "api_key": rt.get("api_key") or None, - "credential_pool": rt.get("credential_pool"), - "request_overrides": rt.get("request_overrides") or {}, - "parent_session_id": agent.session_id, + "model": rt.get("model") or agent.model, "max_iterations": max_iterations, "quiet_mode": True, + "platform": agent.platform, "provider": rt.get("provider") or agent.provider, + "api_mode": rt.get("api_mode"), "base_url": rt.get("base_url") or None, + "api_key": rt.get("api_key") or None, "credential_pool": rt.get("credential_pool"), + "request_overrides": rt.get("request_overrides") or {}, "parent_session_id": agent.session_id, "enabled_toolsets": getattr(agent, "enabled_toolsets", None), - "disabled_toolsets": getattr(agent, "disabled_toolsets", None), - "skip_memory": True, + "disabled_toolsets": getattr(agent, "disabled_toolsets", None), "skip_memory": True, } if isinstance(rt.get("max_tokens"), int): kwargs["max_tokens"] = rt["max_tokens"] @@ -880,15 +808,13 @@ def build_cache_parity_fork( agent: Any, task_cfg: Optional[Dict[str, Any]] = None, *, max_iterations: int, write_origin: str = "background_review", ) -> Tuple[Any, Dict[str, Any], bool]: - """Construct a detached AIAgent fork with warm prompt-cache parity (shared with ``/btw``). - - Same runtime/credentials as the parent, byte-identical system prompt / tools[] / reasoning - config on the same-model path, shared session_id for prefix warmth, full persistence - detachment (no state.db writes, rotation, or external memory providers; in-place-only - compaction). Returns ``(fork_agent, runtime_dict, routed)``; ``routed`` means a different - model (cache cold — replay a digest). The caller owns registration, whitelisting, running, - usage attribution and teardown. - """ + """Construct a detached AIAgent fork with warm prompt-cache parity (shared with ``/btw``): same + runtime/credentials as the parent, byte-identical system prompt / tools[] / reasoning config on + the same-model path, shared session_id for prefix warmth, full persistence detachment (no + state.db writes, rotation, or external memory providers; in-place-only compaction). Returns + ``(fork_agent, runtime_dict, routed)``; ``routed`` means a different model (cache cold — + replay a digest). The caller owns registration, whitelisting, running, usage attribution and + teardown.""" from run_agent import AIAgent # local: avoids a circular import at load # Inherit the parent's live runtime: AIAgent.__init__'s env auto-resolution fails for @@ -953,15 +879,12 @@ def _track_review_fork(agent: Any, review_agent: Any, *, register: bool) -> None elif agent._background_review_agent is review_agent: agent._background_review_agent = None if hasattr(agent, "_active_children"): - try: - with _optional_lock(agent, "_active_children_lock"): - if register: - agent._active_children.append(review_agent) - else: - agent._active_children.remove(review_agent) - except (ValueError, AttributeError): + with _optional_lock(agent, "_active_children_lock"): if register: - raise + agent._active_children.append(review_agent) + else: + with suppress(ValueError, AttributeError): + agent._active_children.remove(review_agent) def _review_tool_whitelist(review_agent: Any, task_cfg: Optional[Dict[str, Any]]) -> Tuple[set, set]: @@ -1151,28 +1074,27 @@ def _run_review_in_thread( _set_thread_approval_callback(None) +# (review_memory, review_skills) -> prompt attribute name; skills-only is also the default. +_PROMPT_NAME_BY_SCOPE = { + (True, True): "_COMBINED_REVIEW_PROMPT", (True, False): "_MEMORY_REVIEW_PROMPT", + (False, True): "_SKILL_REVIEW_PROMPT", (False, False): "_SKILL_REVIEW_PROMPT", +} + + def spawn_background_review_thread( agent: Any, messages_snapshot: List[Dict], review_memory: bool = False, review_skills: bool = False, focus: Optional[str] = None, task_cfg: Optional[Dict[str, Any]] = None, review_run: Optional[_BackgroundReviewRun] = None, ): """Return ``(target, prompt)``; the caller builds the ``threading.Thread`` so test patches of - ``run_agent.threading.Thread`` keep working. - - ``focus`` (``/refine [instructions]``) is appended to the chosen prompt; automatic reviews - pass ``None``. ``task_cfg`` is the pre-loaded ``auxiliary.background_review`` block; when - omitted it is read once here. - """ + ``run_agent.threading.Thread`` keep working. ``focus`` (``/refine [instructions]``) is appended + to the chosen prompt; automatic reviews pass ``None``. ``task_cfg`` is the pre-loaded + ``auxiliary.background_review`` block; when omitted it is read once here.""" if task_cfg is None: task_cfg = _background_review_task_config() # Per-agent overrides (agent._MEMORY_REVIEW_PROMPT etc.) keep working. - name = ( - "_COMBINED_REVIEW_PROMPT" if review_memory and review_skills - else "_MEMORY_REVIEW_PROMPT" if review_memory - else "_SKILL_REVIEW_PROMPT" - ) + name = _PROMPT_NAME_BY_SCOPE[(review_memory, review_skills)] prompt = getattr(agent, name, globals()[name]) - focus = (focus or "").strip() if focus: prompt = ( From 03bf62ae393ba0ffb672b4b4dafbc4922a6f0e57 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:17:22 -0700 Subject: [PATCH 10/12] refactor(agent/anthropic_adapter,message_convert): fold stream/create kwarg filtering, tool-result content resolution, replay helpers; drop blank-line padding inside builders --- agent/anthropic_adapter.py | 37 +++++------------ agent/anthropic_message_convert.py | 65 +++++++++--------------------- 2 files changed, 28 insertions(+), 74 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 5e373c75d4..2b882e91a0 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -570,7 +570,6 @@ def build_anthropic_kwargs( ``fast_mode`` adds ``extra_body.speed="fast"`` plus the fast-mode beta on native Anthropic only.""" system, anthropic_messages = convert_messages_to_anthropic(messages, base_url=base_url, model=model) anthropic_tools = convert_tools_to_anthropic(tools) if tools else [] - # Nous Portal routes on its own catalog ids (``anthropic/claude-opus-4.8``); normalizing would # make the model unresolvable there (prefix AND dots kept). if not _is_nous_portal_endpoint(base_url): @@ -579,15 +578,12 @@ def build_anthropic_kwargs( effective_max_tokens = _resolve_anthropic_messages_max_tokens(max_tokens, model, context_length=context_length) if context_length and effective_max_tokens > context_length: effective_max_tokens = max(context_length - 1, 1) - to_wire = _oauth_wire_namer(anthropic_tools) if is_oauth else None if to_wire: system = _apply_claude_code_identity(system, anthropic_tools, anthropic_messages, to_wire) - kwargs: Dict[str, Any] = {"model": model, "messages": anthropic_messages, "max_tokens": effective_max_tokens} if system: kwargs["system"] = system - if anthropic_tools: kwargs["tools"] = anthropic_tools if tool_choice == "none": @@ -598,27 +594,20 @@ def build_anthropic_kwargs( kwargs["tool_choice"] = _TOOL_CHOICE_MAP.get(tool_choice) or { "type": "tool", "name": to_wire(tool_choice) if to_wire else tool_choice } - if reasoning_config and isinstance(reasoning_config, dict): kwargs.update(_thinking_kwargs(reasoning_config, model, effective_max_tokens)) - # Safety net so upstream 4.6 -> 4.7 migrations don't need coordinated edits everywhere callers # (auxiliary_client, ...) set sampling params. if _forbids_sampling_params(model): for key in ("temperature", "top_p", "top_k"): kwargs.pop(key, None) - # Fast mode: native Anthropic only — third-party providers reject the unknown beta/param and # Anthropic scopes it to the Claude API (not Bedrock/Vertex/Foundry). Per-request extra_headers # OVERRIDE the client-level anthropic-beta header, so rebuild the full beta list. if fast_mode and not _is_third_party_anthropic_endpoint(base_url) and _supports_fast_mode(model): kwargs.setdefault("extra_body", {})["speed"] = "fast" - betas = list(_common_betas_for_base_url(base_url, drop_context_1m_beta=drop_context_1m_beta)) - if is_oauth: - betas.extend(_OAUTH_ONLY_BETAS) - betas.append(_FAST_MODE_BETA) - kwargs["extra_headers"] = _beta_header(betas) - + betas = _common_betas_for_base_url(base_url, drop_context_1m_beta=drop_context_1m_beta) + kwargs["extra_headers"] = _beta_header(betas + (_OAUTH_ONLY_BETAS if is_oauth else []) + [_FAST_MODE_BETA]) return kwargs @@ -633,9 +622,7 @@ def sanitize_anthropic_kwargs(api_kwargs: Any, *, log_prefix: str = "") -> Any: and dispatch): a Responses-shaped payload reaching ``messages.stream()`` dies with a non-retryable TypeError that takes the whole turn and fallback chain with it. Mutates and returns ``api_kwargs``; logs a WARNING so the race stays visible.""" - if not isinstance(api_kwargs, dict): - return api_kwargs - leaked = _RESPONSES_ONLY_KWARGS.intersection(api_kwargs) + leaked = _RESPONSES_ONLY_KWARGS.intersection(api_kwargs) if isinstance(api_kwargs, dict) else () if leaked: for key in leaked: del api_kwargs[key] @@ -655,18 +642,16 @@ def _is_stream_unavailable_error(exc: Exception) -> bool: err_lower = str(exc).lower() if "stream" in err_lower and "not supported" in err_lower: return True - if "invokemodelwithresponsestream" in err_lower: - from agent.bedrock_adapter import is_streaming_access_denied_error + if "invokemodelwithresponsestream" not in err_lower: + return False + from agent.bedrock_adapter import is_streaming_access_denied_error - return is_streaming_access_denied_error(exc) - return False + return is_streaming_access_denied_error(exc) def _stream_final_message(stream_fn, api_kwargs, log_prefix, on_stream_event, on_response): """``messages.stream()`` -> final Message, ticking the best-effort callbacks.""" - stream_kwargs = dict(api_kwargs) - stream_kwargs.pop("stream", None) - with stream_fn(**stream_kwargs) as stream: + with stream_fn(**{k: v for k, v in api_kwargs.items() if k != "stream"}) as stream: if callable(on_response): try: on_response(getattr(stream, "response", None)) @@ -700,7 +685,6 @@ def create_anthropic_message( ``on_response(httpx_response)`` exposes headers the parsed Message drops (Nous Portal's ``x-nous-credits-*`` balance family).""" sanitize_anthropic_kwargs(api_kwargs, log_prefix=log_prefix) - messages_api = getattr(client, "messages", None) stream_fn = getattr(messages_api, "stream", None) if prefer_stream and callable(stream_fn): @@ -714,7 +698,4 @@ def create_anthropic_message( logger.debug( "%sAnthropic Messages stream unavailable; falling back to messages.create(): %s", log_prefix, exc ) - - create_kwargs = dict(api_kwargs) - create_kwargs.pop("stream", None) - return messages_api.create(**create_kwargs) + return messages_api.create(**{k: v for k, v in api_kwargs.items() if k != "stream"}) diff --git a/agent/anthropic_message_convert.py b/agent/anthropic_message_convert.py index f0c76238e8..47721a0b3c 100644 --- a/agent/anthropic_message_convert.py +++ b/agent/anthropic_message_convert.py @@ -130,9 +130,7 @@ def normalize_model_name(model: str, preserve_dots: bool = False) -> str: def _sanitize_tool_id(tool_id: str) -> str: """Anthropic requires ids matching [a-zA-Z0-9_-]; replace the rest, never empty.""" - if not tool_id: - return "tool_0" - return re.sub(r"[^a-zA-Z0-9_-]", "_", tool_id) or "tool_0" + return (re.sub(r"[^a-zA-Z0-9_-]", "_", tool_id) if tool_id else "") or "tool_0" def _tool_use_block(tool_id: Any, name: Any, tool_input: Any) -> Dict[str, Any]: @@ -165,11 +163,9 @@ def _normalize_tool_input_schema(schema: Any) -> Dict[str, Any]: def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]: """Convert OpenAI tool definitions to Anthropic format. Duplicate names are dropped with a warning (Anthropic hard-400s on them); ``cache_control`` on the OpenAI tool dict is forwarded.""" - if not tools: - return [] result = [] seen_names: set = set() - for t in tools: + for t in tools or []: fn = t.get("function", {}) name = fn.get("name", "") if name and name in seen_names: @@ -178,8 +174,7 @@ def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]: if name: seen_names.add(name) anthropic_tool: Dict[str, Any] = { - "name": name, - "description": fn.get("description", ""), + "name": name, "description": fn.get("description", ""), "input_schema": _normalize_tool_input_schema(fn.get("parameters") or {}), } result.append(_carry_cache_control(anthropic_tool, t, copy=True)) @@ -201,11 +196,8 @@ def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]: """Convert one OpenAI-style content part to an Anthropic block (None -> dropped).""" if part is None: return None - if isinstance(part, str): - return _text_block(part) if not isinstance(part, dict): - return _text_block(str(part)) - + return _text_block(part if isinstance(part, str) else str(part)) ptype = part.get("type") if ptype in ("input_text", "text"): # Rebuild from whitelisted fields only: stored SDK text blocks carry output-only siblings @@ -217,7 +209,6 @@ def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]: block = {"type": "image", "source": _image_source_from_openai_url(url)} else: block = dict(part) - cache_control = _cache_control_of(part) if cache_control is not None: block.setdefault("cache_control", dict(cache_control)) @@ -228,12 +219,9 @@ def _to_plain_data(value: Any, *, _depth: int = 0, _path: Optional[set] = None) """Recursively convert SDK objects to plain Python data. ``_path`` tracks ids on the *current* recursion path (shared but non-cyclic objects convert normally while true cycles stringify); depth is capped at 20.""" - if _depth > 20: - return str(value) - if _path is None: - _path = set() + _path = set() if _path is None else _path obj_id = id(value) - if obj_id in _path: + if _depth > 20 or obj_id in _path: return str(value) def rec(v): @@ -282,10 +270,8 @@ def _convert_content_to_anthropic(content: Any) -> Any: def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]: """Tool-message content parts -> tool_result inner blocks (text + image only, the types Anthropic accepts there). Used for multimodal tool results.""" - if not isinstance(parts, list): - return [] out: List[Dict[str, Any]] = [] - for block in map(_convert_content_part_to_anthropic, parts): + for block in map(_convert_content_part_to_anthropic, parts if isinstance(parts, list) else []): if not block: continue btype, text_val, src = block.get("type"), block.get("text"), block.get("source") @@ -315,9 +301,7 @@ def _replay_text(b: Dict[str, Any]) -> Optional[Dict[str, Any]]: def _replay_thinking(b: Dict[str, Any]) -> Dict[str, Any]: out = {"type": "thinking", "thinking": b.get("thinking", "")} - if b.get("signature"): - out["signature"] = b["signature"] - return out + return {**out, "signature": b["signature"]} if b.get("signature") else out def _replay_redacted_thinking(b: Dict[str, Any]) -> Optional[Dict[str, Any]]: @@ -456,19 +440,16 @@ def _tool_result_content(m: Dict[str, Any]) -> Any: multimodal_blocks = [_text_block(str(content["text_summary"]))] elif isinstance(content, list): converted = _content_parts_to_anthropic_blocks(content) - if any(b.get("type") == "image" for b in converted): + if _has_block_type(converted, {"image"}): multimodal_blocks = converted if multimodal_blocks is None: # back-compat: blocks stashed under a private key stashed = m.get("_anthropic_content_blocks") if isinstance(stashed, list) and stashed: text_content = content if isinstance(content, str) and content.strip() else None multimodal_blocks = [_text_block(text_content)] + stashed if text_content else list(stashed) - if multimodal_blocks: return multimodal_blocks - if isinstance(content, str): - return content or "(no output)" - return json.dumps(content) if content else "(no output)" + return (content if isinstance(content, str) else json.dumps(content) if content else "") or "(no output)" def _convert_tool_message_to_result(result: List[Dict[str, Any]], m: Dict[str, Any]) -> None: @@ -480,9 +461,9 @@ def _convert_tool_message_to_result(result: List[Dict[str, Any]], m: Dict[str, A } _carry_cache_control(tool_result, m, copy=True) last = result[-1] if result else {} - if last.get("role") == "user" and isinstance(last.get("content"), list) and last["content"] \ - and last["content"][0].get("type") == "tool_result": - last["content"].append(tool_result) + last_content = last.get("content") if last.get("role") == "user" else None + if isinstance(last_content, list) and last_content and last_content[0].get("type") == "tool_result": + last_content.append(tool_result) else: result.append({"role": "user", "content": [tool_result]}) @@ -544,16 +525,13 @@ def _concat_content(prev: Any, curr: Any) -> Any: promoted to block lists.""" if isinstance(prev, str) and isinstance(curr, str): return prev + "\n" + curr - if isinstance(prev, str): - prev = [_text_block(prev)] - if isinstance(curr, str): - curr = [_text_block(curr)] - return prev + curr + as_blocks = lambda c: [_text_block(c)] if isinstance(c, str) else c # noqa: E731 + return as_blocks(prev) + as_blocks(curr) def _merge_consecutive_roles(result: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Merge consecutive same-role messages to enforce alternation. Returns a new list.""" - fixed = [] + fixed: List[Dict[str, Any]] = [] for m in result: if not (fixed and fixed[-1]["role"] == m["role"]): fixed.append(m) @@ -584,8 +562,7 @@ def _keep_valid_latest_thinking(content: List[Any], signature_dead: bool) -> Lis if signed and not signature_dead: new_content.append(b) elif (signature_dead or not is_redacted) and b.get("thinking"): - new_content.append(_text_block(b["thinking"])) # demote to plain text - # else: redacted_thinking without data — unverifiable, dropped + new_content.append(_text_block(b["thinking"])) # demote to plain text; dataless redacted dropped return new_content @@ -721,24 +698,20 @@ def convert_messages_to_anthropic( reasoning_content-derived blocks, which Kimi requires even when empty.""" system = None result: List[Dict[str, Any]] = [] - for m in messages: role = m.get("role", "user") - content = m.get("content", "") if role == "system": - system = _convert_system_content(content) + system = _convert_system_content(m.get("content", "")) elif role == "assistant": result.append(_convert_assistant_message(m)) elif role == "tool": _convert_tool_message_to_result(result, m) else: - result.append(_convert_user_message(content)) - + result.append(_convert_user_message(m.get("content", ""))) _strip_orphaned_tool_blocks(result) result = _merge_consecutive_roles(result) _ensure_leading_user_turn(result) _manage_thinking_signatures(result, base_url, model) _evict_old_screenshots(result) _scrub_blank_text_blocks(result) - return system, result From 1bc8337bd05d899693fdb8996fe9702d035bc87e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:27:46 -0700 Subject: [PATCH 11/12] refactor(agent/background_review,anthropic_*): inline request-phase finisher, walrus pin/prefill folds, drop intra-function padding --- agent/anthropic_adapter.py | 9 ----- agent/anthropic_endpoints.py | 1 - agent/anthropic_message_convert.py | 6 --- agent/background_review.py | 65 ++++++++---------------------- 4 files changed, 17 insertions(+), 64 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 2b882e91a0..49f97f5f29 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -322,7 +322,6 @@ def _attribution_headers() -> Dict[str, str]: def _client_timeout(timeout): """httpx.Timeout with the caller's read timeout (default 900s) and a 10s connect.""" from httpx import Timeout - read = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0 return Timeout(timeout=float(read), connect=10.0) @@ -353,9 +352,7 @@ def _build_anthropic_client_with_bearer_hook( placeholder ``auth_token`` is still required at construction and makes any leak diagnosable.""" sdk = _require_sdk("Azure Foundry Anthropic-style endpoints with Entra ID auth", verb="Install with") normalize_proxy_env_vars() - from agent.azure_identity_adapter import build_bearer_http_client - normalized_base_url, kwargs = _base_client_kwargs(base_url, timeout) kwargs["http_client"] = build_bearer_http_client(token_provider, timeout=kwargs["timeout"]) kwargs["auth_token"] = "entra-id-bearer-via-http-hook" @@ -406,13 +403,11 @@ def build_anthropic_client(api_key, base_url: str = None, timeout: float = None, return _build_anthropic_client_with_bearer_hook( api_key, base_url, timeout, drop_context_1m_beta=drop_context_1m_beta ) - normalize_proxy_env_vars() normalized_base_url, kwargs = _base_client_kwargs(base_url, timeout) if "default_query" in kwargs: # historical: this path also strips a stray trailing slash on Azure kwargs["base_url"] = normalized_base_url.rstrip("/") common_betas = _common_betas_for_base_url(normalized_base_url, drop_context_1m_beta=drop_context_1m_beta) - style = _auth_style(api_key, base_url, normalized_base_url) kwargs["auth_token" if style in ("bearer", "oauth") else "api_key"] = api_key headers = _beta_header(common_betas + _OAUTH_ONLY_BETAS if style == "oauth" else common_betas) @@ -421,7 +416,6 @@ def build_anthropic_client(api_key, base_url: str = None, timeout: float = None, elif style == "oauth": headers["user-agent"] = f"claude-code/{_get_claude_code_version()} (external, cli)" headers["x-app"] = "cli" - if _is_opencode_endpoint(base_url): # OpenCode identifies clients by request headers (like OpenRouter). The OpenAI-wire paths # get these from profile.default_headers, but this route never sees the profile. @@ -503,14 +497,12 @@ def _apply_claude_code_identity(system, anthropic_tools, anthropic_messages, to_ for old, new in _OAUTH_SYSTEM_REPLACEMENTS: text = text.replace(old, new) block["text"] = _apply_oauth_prose_aliases(text) - for tool in anthropic_tools or []: if "name" in tool: tool["name"] = to_wire(tool["name"]) description = tool.get("description") if isinstance(description, str): tool["description"] = _apply_oauth_prose_aliases(description) # prose-safe aliases only - for msg in anthropic_messages: content = msg.get("content") if isinstance(content, list): @@ -645,7 +637,6 @@ def _is_stream_unavailable_error(exc: Exception) -> bool: if "invokemodelwithresponsestream" not in err_lower: return False from agent.bedrock_adapter import is_streaming_access_denied_error - return is_streaming_access_denied_error(exc) diff --git a/agent/anthropic_endpoints.py b/agent/anthropic_endpoints.py index 0372bfd5d6..7cb1cd10d9 100644 --- a/agent/anthropic_endpoints.py +++ b/agent/anthropic_endpoints.py @@ -89,7 +89,6 @@ def _is_nous_portal_endpoint(base_url: str | None) -> bool: return True try: from hermes_cli.auth import _nous_inference_env_override - override = _nous_inference_env_override() except Exception: return False diff --git a/agent/anthropic_message_convert.py b/agent/anthropic_message_convert.py index 47721a0b3c..80482c66d4 100644 --- a/agent/anthropic_message_convert.py +++ b/agent/anthropic_message_convert.py @@ -145,9 +145,7 @@ def _normalize_tool_input_schema(schema: Any) -> Dict[str, Any]: generic 400, so they are dropped in favour of a plain object.""" if not schema: return dict(_EMPTY_SCHEMA) - from tools.schema_sanitizer import strip_nullable_unions - normalized = strip_nullable_unions(schema, keep_nullable_hint=False) if not isinstance(normalized, dict): return dict(_EMPTY_SCHEMA) @@ -398,7 +396,6 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: replayed = _replay_ordered_blocks(m, ordered_blocks) if replayed: return {"role": "assistant", "content": replayed} - blocks = _extract_preserved_thinking_blocks(m) # Blank text blocks are dropped; a cache marker riding on one is relocated onto the last # surviving cacheable block (prompt_caching sets cache_control on content[-1], which may be @@ -505,7 +502,6 @@ def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None: if len(kept) != len(m["content"]) and _has_block_type(m["content"], _THINKING_TYPES): m["_thinking_signature_invalidated"] = True m["content"] = kept if kept else [_text_block("(tool call removed)")] - # Pass 2: tool_result whose tool_use no longer exists anywhere. surviving_tool_use_ids: set = set() for _, m in _assistant_block_lists(result): @@ -580,7 +576,6 @@ def _manage_thinking_signatures(result: List[Dict[str, Any]], base_url: str | No is_kimi = _is_kimi_family_endpoint(base_url, model) is_deepseek = _is_deepseek_anthropic_endpoint(base_url) last_assistant_idx = next((i for i in range(len(result) - 1, -1, -1) if result[i].get("role") == "assistant"), None) - for idx, m in _assistant_block_lists(result): if is_kimi: pass # shared cleanup below still strips cache markers + the flag @@ -596,7 +591,6 @@ def _manage_thinking_signatures(result: List[Dict[str, Any]], base_url: str | No else: new_content = _keep_valid_latest_thinking(m["content"], bool(m.get("_thinking_signature_invalidated"))) m["content"] = new_content or [_text_block("(empty)")] - # cache_control on thinking blocks interferes with signature validation. for b in m["content"]: if _block_type(b) in _THINKING_TYPES: diff --git a/agent/background_review.py b/agent/background_review.py index 7894bf121a..73044a0656 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -102,7 +102,6 @@ def finish_background_review_run(agent: Any, run: Optional[_BackgroundReviewRun] def _interrupt_background_review(review_agent: Any) -> None: """Request abort off-thread so a wedged abort hook cannot stall the live turn (the bounded ``request_done`` wait in the canceller relies on this returning fast).""" - def _interrupt() -> None: try: from agent.interrupt_compat import request_hard_interrupt @@ -165,7 +164,6 @@ def _background_review_task_config(task_cfg: Optional[Dict[str, Any]] = None) -> return task_cfg if isinstance(task_cfg, dict) else {} try: from hermes_cli.config import load_config_readonly - return _task_block(load_config_readonly()) except Exception: return {} @@ -187,7 +185,6 @@ def load_background_review_settings() -> tuple[bool, Dict[str, Any]]: try: from hermes_cli.config import load_config_readonly from utils import is_truthy_value - task = _task_block(load_config_readonly()) return is_truthy_value(task.get("enabled"), default=True), task except Exception: @@ -618,7 +615,6 @@ def summarize_background_review_actions( verbose = mode == "verbose" existing_tool_call_ids, existing_tool_contents = _prior_tool_keys(prior_snapshot) all_tool_call_ids, call_details = _collect_review_call_details(review_messages) - actions: List[str] = [] for msg in _tool_messages(review_messages): tcid = msg.get("tool_call_id") @@ -679,11 +675,9 @@ def _record_review_usage_to_parent(parent_agent: Any, usage: Dict[str, Any]) -> try: session_db = getattr(parent_agent, "_session_db", None) session_id = getattr(parent_agent, "session_id", None) - if session_db is None or not session_id: - return counts = {key: int(usage.get(key) or 0) for key in _USAGE_COUNTERS} - if not any(counts.values()): - return # fork made no successful API calls (e.g. failed at spawn) + if session_db is None or not session_id or not any(counts.values()): + return # no DB, or the fork made no successful API calls (e.g. failed at spawn) session_db.record_auxiliary_usage( session_id, task="background_review", model=usage.get("model"), billing_provider=usage.get("provider"), billing_base_url=usage.get("base_url"), @@ -734,17 +728,13 @@ def _same_model_parity_kwargs(agent: Any) -> Dict[str, Any]: # is appended to the cached system prompt at API-call time (without it the prompt diverges). "reasoning_config": getattr(agent, "reasoning_config", None), "ephemeral_system_prompt": getattr(agent, "ephemeral_system_prompt", None), + **{attr: val for attr in _PROVIDER_PIN_ATTRS if (val := getattr(agent, attr, None))}, } # Prefill sits right after the system message, so a parent with prefill would diverge at # index 1. Deep copy: unicode-error recovery sanitizes prefill entries IN PLACE and must not # rewrite the parent's bytes. - parent_prefill = copy.deepcopy(getattr(agent, "prefill_messages", None) or []) - if parent_prefill: + if parent_prefill := copy.deepcopy(getattr(agent, "prefill_messages", None) or []): kwargs["prefill_messages"] = parent_prefill - for attr in _PROVIDER_PIN_ATTRS: - val = getattr(agent, attr, None) - if val: - kwargs[attr] = val return kwargs @@ -797,8 +787,7 @@ def _fork_init_kwargs(agent: Any, rt: Dict[str, Any], routed: bool, max_iteratio if isinstance(rt.get("max_tokens"), int): kwargs["max_tokens"] = rt["max_tokens"] if isinstance(rt.get("command"), str) and rt["command"]: - kwargs["acp_command"] = rt["command"] - kwargs["acp_args"] = rt.get("args") or [] + kwargs.update(acp_command=rt["command"], acp_args=rt.get("args") or []) if not routed: kwargs.update(_same_model_parity_kwargs(agent)) return kwargs @@ -816,7 +805,6 @@ def build_cache_parity_fork( replay a digest). The caller owns registration, whitelisting, running, usage attribution and teardown.""" from run_agent import AIAgent # local: avoids a circular import at load - # Inherit the parent's live runtime: AIAgent.__init__'s env auto-resolution fails for # OAuth-only providers, session-scoped creds and credential pools. _rt = _resolve_review_runtime(agent, task_cfg) @@ -859,7 +847,6 @@ def _bg_review_auto_deny(command, description, **kwargs): def _set_thread_approval_callback(callback: Any) -> None: from tools.terminal_tool import set_approval_callback - with suppress(Exception): set_approval_callback(callback) @@ -891,12 +878,10 @@ def _review_tool_whitelist(review_agent: Any, task_cfg: Optional[Dict[str, Any]] """``(whitelist, configured_extra_tools)`` for the review fork — DISPATCH-side only, so the advertised ``tools[]`` stays byte-identical to the parent's (prompt-cache parity).""" from model_tools import get_tool_definitions - # Gate the built-in memory tool on the profile's memory flags so a memory-disabled profile # is never contaminated by the review LLM. - review_toolsets = ["skills"] - if review_agent._memory_enabled or review_agent._user_profile_enabled: - review_toolsets.insert(0, "memory") + memory_on = review_agent._memory_enabled or review_agent._user_profile_enabled + review_toolsets = ["memory", "skills"] if memory_on else ["skills"] whitelist = {t["function"]["name"] for t in get_tool_definitions(enabled_toolsets=review_toolsets, quiet_mode=True)} # Read-only file tools: denying read_file/search_files caused a per-review denial storm that # starved the loop (read_file also registers the read with the read-before-write guard). @@ -906,9 +891,9 @@ def _review_tool_whitelist(review_agent: Any, task_cfg: Optional[Dict[str, Any]] # can only admit, never advertise: a listed tool must already exist in the inherited schema. configured_extra_tools: set = set() try: - _extra_raw = _background_review_task_config(task_cfg).get("extra_tools", []) - if isinstance(_extra_raw, list): - configured_extra_tools = {name.strip() for name in _extra_raw if isinstance(name, str) and name.strip()} + extra_raw = _background_review_task_config(task_cfg).get("extra_tools", []) + if isinstance(extra_raw, list): + configured_extra_tools = {name.strip() for name in extra_raw if isinstance(name, str) and name.strip()} except Exception: logger.debug("background_review extra_tools parse failed", exc_info=True) return whitelist | configured_extra_tools, configured_extra_tools @@ -930,12 +915,6 @@ def _release_fork_clients(review_agent: Any) -> None: review_agent.release_clients() -def _finish_request_phase(agent: Any, review_agent: Any, review_run: Optional[_BackgroundReviewRun]) -> None: - """Unregister the fork and publish request completion (identity-scoped, idempotent).""" - _track_review_fork(agent, review_agent, register=False) - finish_background_review_run(agent, review_run) - - def _run_review_fork( agent: Any, messages_snapshot: List[Dict], prompt: str, task_cfg: Optional[Dict[str, Any]], review_run: Optional[_BackgroundReviewRun], st: _ReviewForkState, @@ -945,9 +924,7 @@ def _run_review_fork( so the caller's error path still sees usage and the fork to clean up.""" st.review_agent, _rt, _routed = build_cache_parity_fork(agent, task_cfg, max_iterations=_REVIEW_MAX_ITERATIONS) _track_review_fork(agent, st.review_agent, register=True) - from hermes_cli.plugins import set_thread_tool_whitelist, clear_thread_tool_whitelist - review_whitelist, configured_extra_tools = _review_tool_whitelist(st.review_agent, task_cfg) extra_list = ", ".join(sorted(configured_extra_tools)) deny_extra = f" Configured extra tools also allowed: {extra_list}." if configured_extra_tools else "" @@ -963,9 +940,7 @@ def _run_review_fork( ) with suppress(Exception): from tools.skill_manager_tool import _reset_background_review_read_marks - _reset_background_review_read_marks() - try: if review_run is None or review_run.begin_request(st.review_agent): # Routed -> digest (cache cold anyway); same model -> full snapshot (warm cache reads). @@ -986,9 +961,9 @@ def _run_review_fork( st.review_usage.update(_snapshot_review_usage(st.review_agent)) _record_review_usage_to_parent(agent, st.review_usage) # Publish completion as soon as the provider-capable phase has returned or startup - # cancellation has fenced it out. - _finish_request_phase(agent, st.review_agent, review_run) - + # cancellation has fenced it out (unregister + finish are identity-scoped and idempotent). + _track_review_fork(agent, st.review_agent, register=False) + finish_background_review_run(agent, review_run) st.review_messages = list(getattr(st.review_agent, "_session_messages", [])) _release_fork_clients(st.review_agent) st.review_agent = None @@ -997,10 +972,9 @@ def _run_review_fork( def _publish_review_summary(agent: Any, actions: List[str]) -> None: summary = " · ".join(dict.fromkeys(actions)) agent._safe_print(f" 💾 Self-improvement review: {summary}") - _bg_cb = agent.background_review_callback - if _bg_cb: + if agent.background_review_callback: with suppress(Exception): - _bg_cb(f"💾 Self-improvement review: {summary}") + agent.background_review_callback(f"💾 Self-improvement review: {summary}") def _run_review_in_thread( @@ -1014,9 +988,7 @@ def _run_review_in_thread( if review_run is not None and review_run.cancel_requested.is_set(): finish_background_review_run(agent, review_run) return - _set_thread_approval_callback(_bg_review_auto_deny) - # A client that can't carry Hermes tool calls back would spawn a fork that cannot write # anything. Checked BEFORE the thread-scoped silence so the warning is not swallowed; cheap # check first so the normal path never resolves the runtime twice. @@ -1029,14 +1001,12 @@ def _run_review_in_thread( ) _set_thread_approval_callback(None) return - st = _ReviewForkState() try: # Silence stdout/stderr for THIS thread only: a process-global redirect would blank every # other thread's console for the whole review. with thread_scoped_silence(): _run_review_fork(agent, messages_snapshot, prompt, task_cfg, review_run, st) - # A buggy/legacy tool response shape must NOT take down the whole review (the outer # except would discard every action the fork DID complete), so coerce to an empty list. try: @@ -1052,11 +1022,9 @@ def _run_review_in_thread( e, ) actions = [] - _log_review_completion(st.review_usage, _classify_review_result(actions)) if actions: _publish_review_summary(agent, actions) - except Exception as e: logger.warning("Background memory/skill review failed: %s", e) if st.review_usage: @@ -1066,7 +1034,8 @@ def _run_review_in_thread( # Safety net for the exception path (setup failures before the request-phase finally). # Both cleanups are identity-scoped and idempotent; re-enter thread-scoped silence so # cleanup output stays quiet without blanking other threads. - _finish_request_phase(agent, st.review_agent, review_run) + _track_review_fork(agent, st.review_agent, register=False) + finish_background_review_run(agent, review_run) if st.review_agent is not None: with suppress(Exception), thread_scoped_silence(): _release_fork_clients(st.review_agent) From 96fb75621b6254cb35f61df706fbf053f243ccea Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:45:26 -0700 Subject: [PATCH 12/12] =?UTF-8?q?refactor(agent/anthropic=5F*,background?= =?UTF-8?q?=5Freview):=20final=20density=20pass=20=E2=80=94=20walrus=20gua?= =?UTF-8?q?rds,=20single-line=20SDK=20errors,=20screenshot=20placeholder?= =?UTF-8?q?=20hoist,=20run-token=20latch=20folds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/anthropic_adapter.py | 63 +++++++++++----------------- agent/anthropic_message_convert.py | 66 ++++++++++-------------------- agent/background_review.py | 55 ++++++++++--------------- 3 files changed, 66 insertions(+), 118 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 49f97f5f29..50ec70c268 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -63,10 +63,7 @@ def _require_sdk(purpose: str, verb: str = "Install it with"): """``_get_anthropic_sdk()`` or ImportError naming the feature that needs it.""" sdk = _get_anthropic_sdk() if sdk is None: - raise ImportError( - f"The 'anthropic' package is required for {purpose}. " - f"{verb}: pip install 'anthropic>=0.39.0'" - ) + raise ImportError(f"The 'anthropic' package is required for {purpose}. {verb}: pip install 'anthropic>=0.39.0'") return sdk @@ -333,9 +330,8 @@ def _base_client_kwargs(base_url, timeout) -> tuple[str, Dict[str, Any]]: SDK appends ``/v1/messages``. Azure's ``api-version`` goes through ``default_query`` so the base_url is not corrupted into ``/anthropic?api-version=.../v1/messages``.""" kwargs: Dict[str, Any] = {"timeout": _client_timeout(timeout), "max_retries": 0} - normalized = _normalize_base_url_text(base_url) + normalized = re.sub(r"/v1/?$", "", _normalize_base_url_text(base_url).rstrip("/")) if normalized: - normalized = re.sub(r"/v1/?$", "", normalized.rstrip("/")) kwargs["base_url"] = normalized if _is_azure_anthropic_endpoint(normalized) and "api-version" not in normalized: kwargs["default_query"] = {"api-version": "2025-04-15"} @@ -431,13 +427,9 @@ def build_anthropic_bedrock_client(region: str): ``context-1m-2025-08-07`` are attached: without the latter Bedrock caps Opus 4.6/4.7 at 200K.""" sdk = _require_sdk("the Bedrock provider") if not hasattr(sdk, "AnthropicBedrock"): - raise ImportError( - "anthropic.AnthropicBedrock not available. " - "Upgrade with: pip install 'anthropic>=0.39.0'" - ) + raise ImportError("anthropic.AnthropicBedrock not available. Upgrade with: pip install 'anthropic>=0.39.0'") return sdk.AnthropicBedrock( - aws_region=region, - timeout=_client_timeout(None), + aws_region=region, timeout=_client_timeout(None), max_retries=0, # retry belongs to hermes's outer loop (honors Retry-After) default_headers=_beta_header([*_COMMON_BETAS, _CONTEXT_1M_BETA]), ) @@ -451,9 +443,7 @@ def _normalize_to_mcp_wire(name: str) -> str: land on the double-underscore form. normalize_response reverses both via registry lookup.""" if name.startswith("mcp__"): return name # already correct, don't double-prefix - if name.startswith("mcp_"): - return "mcp__" + name[len("mcp_"):] - return _MCP_TOOL_PREFIX + name + return _MCP_TOOL_PREFIX + name.removeprefix("mcp_") def _oauth_wire_namer(anthropic_tools: List[Dict[str, Any]]): @@ -500,15 +490,12 @@ def _apply_claude_code_identity(system, anthropic_tools, anthropic_messages, to_ for tool in anthropic_tools or []: if "name" in tool: tool["name"] = to_wire(tool["name"]) - description = tool.get("description") - if isinstance(description, str): - tool["description"] = _apply_oauth_prose_aliases(description) # prose-safe aliases only + if isinstance(tool.get("description"), str): + tool["description"] = _apply_oauth_prose_aliases(tool["description"]) # prose-safe aliases only for msg in anthropic_messages: - content = msg.get("content") - if isinstance(content, list): - for block in content: - if isinstance(block, dict) and block.get("type") == "tool_use" and "name" in block: - block["name"] = to_wire(block["name"]) # tool_result pairs by id, not name + for block in msg.get("content") if isinstance(msg.get("content"), list) else []: + if isinstance(block, dict) and block.get("type") == "tool_use" and "name" in block: + block["name"] = to_wire(block["name"]) # tool_result pairs by id, not name return system @@ -526,15 +513,12 @@ def _thinking_kwargs(reasoning_config: Dict[str, Any], model: str, effective_max if "haiku" in model.lower(): return {} effort = str(reasoning_config.get("effort", "medium")).lower() - budget = THINKING_BUDGET.get(effort, 8000) if _supports_adaptive_thinking(model): adaptive_effort = ADAPTIVE_EFFORT_MAP.get(effort, "medium") if adaptive_effort == "xhigh" and not _supports_xhigh_effort(model): adaptive_effort = "max" - return { - "thinking": {"type": "adaptive", "display": "summarized"}, - "output_config": {"effort": adaptive_effort}, - } + return {"thinking": {"type": "adaptive", "display": "summarized"}, "output_config": {"effort": adaptive_effort}} + budget = THINKING_BUDGET.get(effort, 8000) return { "thinking": {"type": "enabled", "budget_tokens": budget}, "temperature": 1, # required when thinking is enabled on older models @@ -648,18 +632,17 @@ def _stream_final_message(stream_fn, api_kwargs, log_prefix, on_stream_event, on on_response(getattr(stream, "response", None)) except Exception: logger.debug("%son_response callback failed", log_prefix, exc_info=True) - if callable(on_stream_event): - # Consume manually so each event ticks the progress callback; get_final_message then - # returns the accumulated snapshot. - for _event in stream: - try: - on_stream_event(_event) - except TimeoutError: - # The callback is the caller's deadline seam: the host has given up, so abandon - # the stream (``with`` closes it) instead of streaming an answer nobody reads. - raise - except Exception: - logger.debug("%son_stream_event callback failed", log_prefix, exc_info=True) + # Consume manually so each event ticks the progress callback; get_final_message then + # returns the accumulated snapshot. TimeoutError is the caller's deadline seam: the host + # has given up, so abandon the stream (``with`` closes it) instead of streaming an answer + # nobody reads. + for event in stream if callable(on_stream_event) else (): + try: + on_stream_event(event) + except TimeoutError: + raise + except Exception: + logger.debug("%son_stream_event callback failed", log_prefix, exc_info=True) return stream.get_final_message() diff --git a/agent/anthropic_message_convert.py b/agent/anthropic_message_convert.py index 80482c66d4..124574584b 100644 --- a/agent/anthropic_message_convert.py +++ b/agent/anthropic_message_convert.py @@ -1,11 +1,8 @@ -"""OpenAI-style -> Anthropic Messages API request conversion. - -Everything here rewrites *request payloads*: model-id normalization, tool schemas, and the -message list (content blocks, thinking blocks and their signatures, tool_use/tool_result -pairing, cache_control placement, screenshot eviction, blank-block scrubbing). Endpoint -predicates come from ``agent/anthropic_endpoints.py``, so this module never imports the adapter -and there is no cycle. ``agent.anthropic_adapter`` re-exports every name below. -""" +"""OpenAI-style -> Anthropic Messages API request conversion: model-id normalization, tool +schemas, and the message list (content blocks, thinking blocks and their signatures, +tool_use/tool_result pairing, cache_control placement, screenshot eviction, blank-block +scrubbing). Endpoint predicates come from ``agent/anthropic_endpoints.py`` so this module never +imports the adapter (no cycle); ``agent.anthropic_adapter`` re-exports the public names.""" import copy import json @@ -24,9 +21,7 @@ _THINKING_TYPES = frozenset(("thinking", "redacted_thinking")) _CACHEABLE_TYPES = frozenset(("text", "tool_use")) _EMPTY_TEXT_PLACEHOLDER = "(empty)" _EMPTY_SCHEMA = {"type": "object", "properties": {}} -_BEDROCK_REGION_PREFIXES = ( - "global.", "us.", "eu.", "apac.", "ap.", "au.", "jp.", "ca.", "sa.", "me.", "af.", -) +_BEDROCK_REGION_PREFIXES = ("global.", "us.", "eu.", "apac.", "ap.", "au.", "jp.", "ca.", "sa.", "me.", "af.") def _block_type(b: Any) -> Any: @@ -41,10 +36,7 @@ def _has_block_type(blocks: List[Any], types) -> bool: def _is_blank_text_block(b: Any) -> bool: """A text block whose ``text`` is not a non-whitespace string (None/int/blank all count) — Anthropic 400s on them ("text content blocks must contain non-whitespace text").""" - if _block_type(b) != "text": - return False - text = b.get("text") - return not (isinstance(text, str) and text.strip()) + return _block_type(b) == "text" and not (isinstance(b.get("text"), str) and b["text"].strip()) def _cache_control_of(b: Any) -> Optional[Dict[str, Any]]: @@ -97,17 +89,10 @@ def _carry_cache_control(out: Dict[str, Any], b: Any, *, copy: bool = False) -> def _split_blank_text_blocks(blocks: List[Any]) -> Tuple[List[Any], Any, List[int]]: """``(kept, relocated_cache_control, dropped_indexes)``: drop blank text blocks, remembering the cache_control of the last one dropped so the caller can relocate the breakpoint.""" - kept: List[Any] = [] - relocated_cc = None - dropped: List[int] = [] - for i, blk in enumerate(blocks): - if _is_blank_text_block(blk): - if _cache_control_of(blk) is not None: - relocated_cc = blk["cache_control"] - dropped.append(i) - else: - kept.append(blk) - return kept, relocated_cc, dropped + dropped = [i for i, blk in enumerate(blocks) if _is_blank_text_block(blk)] + kept = [blk for i, blk in enumerate(blocks) if i not in dropped] + relocated = [cc for i in dropped if (cc := _cache_control_of(blocks[i])) is not None] + return kept, relocated[-1] if relocated else None, dropped def _is_bedrock_model_id(model: str) -> bool: @@ -143,10 +128,9 @@ def _normalize_tool_input_schema(schema: Any) -> Dict[str, Any]: optionality is already expressed by ``required``; ``keep_nullable_hint=False`` because the OpenAPI ``nullable`` keyword is not recognized. Top-level oneOf/allOf/anyOf are rejected with a generic 400, so they are dropped in favour of a plain object.""" - if not schema: - return dict(_EMPTY_SCHEMA) from tools.schema_sanitizer import strip_nullable_unions - normalized = strip_nullable_unions(schema, keep_nullable_hint=False) + + normalized = strip_nullable_unions(schema, keep_nullable_hint=False) if schema else None if not isinstance(normalized, dict): return dict(_EMPTY_SCHEMA) banned = {"oneOf", "allOf", "anyOf"} @@ -207,8 +191,7 @@ def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]: block = {"type": "image", "source": _image_source_from_openai_url(url)} else: block = dict(part) - cache_control = _cache_control_of(part) - if cache_control is not None: + if (cache_control := _cache_control_of(part)) is not None: block.setdefault("cache_control", dict(cache_control)) return block @@ -359,15 +342,12 @@ def _replay_ordered_blocks(m: Dict[str, Any], ordered_blocks: List[Any]) -> Opti for b in ordered_blocks: clean = _sanitize_replay_block(b) if clean is None: - if _block_type(b) == "text": - dropped_blank_text = True - if _cache_control_of(b) is not None: # relocate a dropped block's breakpoint - relocated_cc = b["cache_control"] + dropped_blank_text = dropped_blank_text or _block_type(b) == "text" + if (cc := _cache_control_of(b)) is not None: # relocate a dropped block's breakpoint + relocated_cc = cc continue - if clean.get("type") == "tool_use": - redacted = redacted_input_by_id.get(clean.get("id", "")) - if redacted is not None: - clean["input"] = redacted + if clean.get("type") == "tool_use" and (redacted := redacted_input_by_id.get(clean.get("id", ""))) is not None: + clean["input"] = redacted replayed.append(clean) # Nothing cacheable survived (e.g. signed thinking + blank text): emit the placeholder so the # turn stays schema-valid and a relocated marker has a carrier. @@ -610,9 +590,8 @@ def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None: continue image_count += 1 if image_count > 3: - block["content"] = [ - b if b.get("type") != "image" else _text_block("[screenshot removed to save context]") for b in inner - ] + placeholder = _text_block("[screenshot removed to save context]") + block["content"] = [placeholder if b.get("type") == "image" else b for b in inner] def _ensure_leading_user_turn(result: List[Dict[str, Any]]) -> None: @@ -637,8 +616,7 @@ def _fix_blank_text_blocks_in_list( "(message_index=%d role=%s location=%s block_index=%d block_type=text)", msg_index, role, location, block_index, ) - if not kept: - kept.append(_text_block(placeholder_text)) + kept = kept or [_text_block(placeholder_text)] _apply_assistant_cache_control_to_last_cacheable_block(kept, relocated_cache_control) return kept diff --git a/agent/background_review.py b/agent/background_review.py index 73044a0656..3959ca0d1a 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -31,8 +31,7 @@ class _BackgroundReviewRun: self.request_done = threading.Event() self._lock = threading.Lock() self._review_agent = None - self._request_finished = False - self._cancel_dispatched = False + self._request_finished = self._cancel_dispatched = False def begin_request(self, review_agent: Any) -> bool: """Atomically admit the first provider-capable review phase.""" @@ -46,18 +45,17 @@ class _BackgroundReviewRun: """Fence startup and return the running fork, if one was admitted.""" with self._lock: self.cancel_requested.set() - if self._review_agent is not None and not self._cancel_dispatched: - self._cancel_dispatched = True - return self._review_agent - return None + if self._review_agent is None or self._cancel_dispatched: + return None + self._cancel_dispatched = True + return self._review_agent def mark_request_finished(self) -> bool: """Latch request completion once; the caller publishes the event.""" with self._lock: if self._request_finished: return False - self._request_finished = True - self._review_agent = None + self._request_finished, self._review_agent = True, None return True @@ -251,11 +249,9 @@ def _parent_can_emit_tool_calls(agent: Any) -> bool: def _msg_text(m: Dict) -> str: c = m.get("content") - if isinstance(c, str): - return c.strip() if isinstance(c, list): - return " ".join(b.get("text", "") for b in c if isinstance(b, dict)).strip() - return "" + c = " ".join(b.get("text", "") for b in c if isinstance(b, dict)) + return c.strip() if isinstance(c, str) else "" def _digest_history(messages_snapshot: List[Dict], tail: int = 24) -> List[Dict]: @@ -274,8 +270,7 @@ def _digest_history(messages_snapshot: List[Dict], tail: int = 24) -> List[Dict] for m in msgs[:-len(keep)]: if not isinstance(m, dict): continue - role = m.get("role") - text = _msg_text(m).replace("\n", " ") + role, text = m.get("role"), _msg_text(m).replace("\n", " ") if role == "user" and text: lines.append(f"USER: {text[:300]}") elif role == "assistant": @@ -507,13 +502,12 @@ def _verbose_skill_line(data: Dict, detail: Dict, message: str) -> str: change: dict = change_raw if isinstance(change_raw, dict) else {} old_string = change.get("old", "") or detail.get("old_string", "") new_string = change.get("new", "") or detail.get("new_string", "") - description = change.get("description", "") if action == "patch" and (old_string or new_string): old_preview, new_preview = (_preview(t, 80).replace("\n", " ") for t in (old_string, new_string)) return f"📝 Skill '{skill_name}' patched: \"{old_preview}\" → \"{new_preview}\"" verb = {"create": "created", "edit": "rewritten"}.get(action) - if verb and description: - return f"📝 Skill '{skill_name}' {verb}: {description}" + if verb and change.get("description"): + return f"📝 Skill '{skill_name}' {verb}: {change['description']}" return f"📝 {message}" if message else f"Skill {action}" @@ -581,24 +575,16 @@ def _action_lines(data: Dict, detail: Dict, verbose: bool) -> List[str]: message = data.get("message", "") target = data.get("target", "") or detail.get("target", "") is_skill = detail.get("tool") == "skill_manage" - message_lower = message.lower() - if not verbose and ( - "created" in message_lower or "updated" in message_lower or (is_skill and "patched" in message_lower) - ): + lower = message.lower() + if not verbose and ("created" in lower or "updated" in lower or (is_skill and "patched" in lower)): return [message] - if is_skill: - label = "Skill" - elif target: - label = "Memory" if target == "memory" else "User profile" if target == "user" else target - else: + if not is_skill and not target: return [] + label = "Skill" if is_skill else {"memory": "Memory", "user": "User profile"}.get(target, target) if verbose: return [_verbose_skill_line(data, detail, message)] if is_skill else _verbose_memory_lines(label, detail) - if any(k in message_lower for k in ("added", "replaced", "removed", "applied")) or ( - target and "add" in message_lower - ): - return [f"{label} updated"] - return [] + hit = any(k in lower for k in ("added", "replaced", "removed", "applied")) or (target and "add" in lower) + return [f"{label} updated"] if hit else [] def summarize_background_review_actions( @@ -847,6 +833,7 @@ def _bg_review_auto_deny(command, description, **kwargs): def _set_thread_approval_callback(callback: Any) -> None: from tools.terminal_tool import set_approval_callback + with suppress(Exception): set_approval_callback(callback) @@ -940,6 +927,7 @@ def _run_review_fork( ) with suppress(Exception): from tools.skill_manager_tool import _reset_background_review_read_marks + _reset_background_review_read_marks() try: if review_run is None or review_run.begin_request(st.review_agent): @@ -992,7 +980,7 @@ def _run_review_in_thread( # A client that can't carry Hermes tool calls back would spawn a fork that cannot write # anything. Checked BEFORE the thread-scoped silence so the warning is not swallowed; cheap # check first so the normal path never resolves the runtime twice. - if not _parent_can_emit_tool_calls(agent) and not bool(_resolve_review_runtime(agent, task_cfg).get("routed")): + if not _parent_can_emit_tool_calls(agent) and not _resolve_review_runtime(agent, task_cfg).get("routed"): logger.warning( "Background review skipped: provider %r cannot emit Hermes tool calls, " "so the review fork could not write memories or skills. Set " @@ -1064,8 +1052,7 @@ def spawn_background_review_thread( # Per-agent overrides (agent._MEMORY_REVIEW_PROMPT etc.) keep working. name = _PROMPT_NAME_BY_SCOPE[(review_memory, review_skills)] prompt = getattr(agent, name, globals()[name]) - focus = (focus or "").strip() - if focus: + if focus := (focus or "").strip(): prompt = ( f"{prompt}\n\nThe user explicitly requested this review with the following " f"focus — prioritize it over the general instructions above:\n{focus}"