{"repo":"NousResearch/hermes-agent","public_id":"13919","display_label":"AWS Bedrock auxiliary task routing and auth fixes","summary":"Multiple fixes addressing AWS Bedrock (aws_sdk) authentication support and routing of auxiliary tasks such as context compression and summarization through the appropriate Bedrock SDK or Converse API. Includes related improvements to model-ID detection, context length handling, and connection management.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":15184,"merged_at":"2026-04-24T14:26:07+00:00"},"resolution":{"state":"resolved","by":"pr-15184"},"freshness":{"state":"stale","last_verified_at":"2026-04-24T14:26:07+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":12295,"pr_number":15184,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-24T14:26:07+00:00"},{"issue_number":12727,"pr_number":15184,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-24T14:26:07+00:00"},{"issue_number":13919,"pr_number":15184,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-24T14:26:07+00:00"}],"members":[{"kind":"issue","number":13919,"title":"[Bug]: auxiliary_client が Bedrock (aws_sdk) 認証に未対応 — context compression / summarization が動作しない","url":null,"state":"closed"},{"kind":"pr","number":11612,"title":"fix(bedrock): route auxiliary tasks through AnthropicBedrock SDK","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7dc6eb9fbf6bae050db113f9a1cc1f9fdb83b534","number":0},{"kind":"pr","value":"#13919","number":13919}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":14770,"title":"fix(agent): handle aws_sdk auth type in resolve_provider_client","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7dc6eb9fb","number":0},{"kind":"pr","value":"#11612","number":11612},{"kind":"pr","value":"#13919","number":13919},{"kind":"pr","value":"#12365","number":12365}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":15184,"title":"fix(bedrock): consolidated — model-ID detection, context length priority, aux aws_sdk, stale-connection eviction, interrupt rebuild","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py","agent/auxiliary_client.py","agent/bedrock_adapter.py","agent/model_metadata.py","run_agent.py","scripts/release.py","tests/agent/test_bedrock_adapter.py","tests/agent/test_bedrock_integration.py","tests/agent/test_model_metadata.py"],"fix_hunks":[{"file":"agent/anthropic_adapter.py","hunks":["@@ -986,6 +986,26 @@ def read_hermes_oauth_credentials() -> Optional[Dict[str, Any]]:\n # ---------------------------------------------------------------------------\n \n \n+def _is_bedrock_model_id(model: str) -> bool:\n+    \"\"\"Detect AWS Bedrock model IDs that use dots as namespace separators.\n+\n+    Bedrock model IDs come in two forms:\n+    - Bare:    ``anthropic.claude-opus-4-7``\n+    - Regional (inference profiles): ``us.anthropic.claude-sonnet-4-5-v1:0``\n+\n+    In both cases the dots separate namespace components, not version\n+    numbers, and must be preserved verbatim for the Bedrock API.\n+    \"\"\"\n+    lower = model.lower()\n+    # Regional inference-profile prefixes\n+    if any(lower.startswith(p) for p in (\"global.\", \"us.\", \"eu.\", \"ap.\", \"jp.\")):\n+        return True\n+    # Bare Bedrock model IDs: provider.model-family\n+    if lower.startswith(\"anthropic.\"):\n+        return True\n+    return False\n+\n+\n def normalize_model_name(model: str, preserve_dots: bool = False) -> str:\n     \"\"\"Normalize a model name for the Anthropic API.\n ","@@ -993,11 +1013,19 @@ def normalize_model_name(model: str, preserve_dots: bool = False) -> str:\n     - Converts dots to hyphens in version numbers (OpenRouter uses dots,\n       Anthropic uses hyphens: claude-opus-4.6 → claude-opus-4-6), unless\n       preserve_dots is True (e.g. for Alibaba/DashScope: qwen3.5-plus).\n+    - Preserves Bedrock model IDs (``anthropic.claude-opus-4-7``) and\n+      regional inference profiles (``us.anthropic.claude-*``) whose dots\n+      are namespace separators, not version separators.\n     \"\"\"\n     lower = model.lower()\n     if lower.startswith(\"anthropic/\"):\n         model = model[len(\"anthropic/\"):]\n     if not preserve_dots:\n+        # Bedrock model IDs use dots as namespace separators\n+        # (e.g. \"anthropic.claude-opus-4-7\", \"us.anthropic.claude-*\").\n+        # These must not be converted to hyphens.  See issue #12295.\n+        if _is_bedrock_model_id(model):\n+            return model\n         # OpenRouter uses dots for version separators (claude-opus-4.6),\n         # Anthropic uses hyphens (claude-opus-4-6). Convert dots to hyphens.\n         model = model.replace(\".\", \"-\")"]},{"file":"agent/auxiliary_client.py","hunks":["@@ -1993,6 +1993,39 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\"):\n                        \"directly supported\", provider)\n         return None, None\n \n+    elif pconfig.auth_type == \"aws_sdk\":\n+        # AWS SDK providers (Bedrock) — use the Anthropic Bedrock client via\n+        # boto3's credential chain (IAM roles, SSO, env vars, instance metadata).\n+        try:\n+            from agent.bedrock_adapter import has_aws_credentials, resolve_bedrock_region\n+            from agent.anthropic_adapter import build_anthropic_bedrock_client\n+        except ImportError:\n+            logger.warning(\"resolve_provider_client: bedrock requested but \"\n+                           \"boto3 or anthropic SDK not installed\")\n+            return None, None\n+\n+        if not has_aws_credentials():\n+            logger.debug(\"resolve_provider_client: bedrock requested but \"\n+                         \"no AWS credentials found\")\n+            return None, None\n+\n+        region = resolve_bedrock_region()\n+        default_model = \"anthropic.claude-haiku-4-5-20251001-v1:0\"\n+        final_model = _normalize_resolved_model(model or default_model, provider)\n+        try:\n+            real_client = build_anthropic_bedrock_client(region)\n+        except ImportError as exc:\n+            logger.warning(\"resolve_provider_client: cannot create Bedrock \"\n+                           \"client: %s\", exc)\n+            return None, None\n+        client = AnthropicAuxiliaryClient(\n+            real_client, final_model, api_key=\"[redacted:secret-assignment]\",\n+            base_url=f\"https://bedrock-runtime.{region}.amazonaws.com\",\n+        )\n+        logger.debug(\"resolve_provider_client: bedrock (%s, %s)\", final_model, region)\n+        return (_to_async_client(client, final_model) if async_mode\n+                else (client, final_model))\n+\n     elif pconfig.auth_type in (\"oauth_device_code\", \"oauth_external\"):\n         # OAuth providers — route through their specific try functions\n         if provider == \"nous\":"]},{"file":"agent/bedrock_adapter.py","hunks":["@@ -87,6 +87,114 @@ def reset_client_cache():\n     _bedrock_control_client_cache.clear()\n \n \n+def invalidate_runtime_client(region: str) -> bool:\n+    \"\"\"Evict the cached ``bedrock-runtime`` client for a single region.\n+\n+    Per-region counterpart to :func:`reset_client_cache`. Used by the converse\n+    call wrappers to discard clients whose underlying HTTP connection has\n+    gone stale, so the next call allocates a fresh client (with a fresh\n+    connection pool) instead of reusing a dead socket.\n+\n+    Returns True if a cached entry was evicted, False if the region was not\n+    cached.\n+    \"\"\"\n+    existed = region in _bedrock_runtime_client_cache\n+    _bedrock_runtime_client_cache.pop(region, None)\n+    return existed\n+\n+\n+# ---------------------------------------------------------------------------\n+# Stale-connection detection\n+# ---------------------------------------------------------------------------\n+#\n+# boto3 caches its HTTPS connection pool inside the client object. When a\n+# pooled connection is killed out from under us (NAT timeout, VPN flap,\n+# server-side TCP RST, proxy idle cull, etc.), the next use surfaces as\n+# one of a handful of low-level exceptions — most commonly\n+# ``botocore.exceptions.ConnectionClosedError`` or\n+# ``urllib3.exceptions.ProtocolError``. urllib3 also trips an internal\n+# ``assert`` in a couple of paths (connection pool state checks, chunked\n+# response readers) which bubbles up as a bare ``AssertionError`` with an\n+# empty ``str(exc)``.\n+#\n+# In all of these cases the client is the problem, not the request: retrying\n+# with the same cached client reproduces the failure until the process\n+# restarts. The fix is to evict the region's cached client so the next\n+# attempt builds a new one.\n+\n+_STALE_LIB_MODULE_PREFIXES = (\n+    \"urllib3.\",\n+    \"botocore.\",\n+    \"boto3.\",\n+)\n+\n+\n+def _traceback_frames_modules(exc: BaseException):\n+    \"\"\"Yield ``__name__``-style module strings for each frame in exc's traceback.\"\"\"\n+    tb = getattr(exc, \"__traceback__\", None)\n+    while tb is not None:\n+        frame = tb.tb_frame\n+        module = frame.f_globals.get(\"__name__\", \"\")\n+        yield module or \"\"\n+        tb = tb.tb_next\n+\n+\n+def is_stale_connection_error(exc: BaseException) -> bool:\n+    \"\"\"Return True if ``exc`` indicates a dead/stale Bedrock HTTP connection.\n+\n+    Matches:\n+      * ``botocore.exceptions.ConnectionError`` and subclasses\n+        (``ConnectionClosedError``, ``EndpointConnectionError``,\n+        ``ReadTimeoutError``, ``ConnectTimeoutError``).\n+      * ``urllib3.exceptions.ProtocolError`` / ``NewConnectionError`` /\n+        ``ConnectionError`` (best-effort import — urllib3 is a transitive\n+        dependency of botocore so it is always available in practice).\n+      * Bare ``AssertionError`` raised from a frame inside urllib3, botocore,\n+        or boto3. These are internal-invariant failures (typically triggered\n+        by corrupted connection-pool state after a dropped socket) and are\n+        recoverable by swapping the client.\n+\n+    Non-library ``AssertionError``s (from application code or tests) are\n+    intentionally not matched — only library-internal asserts signal stale\n+    connection state.\n+    \"\"\"\n+    # botocore: the canonical signal — HTTPClientError is the umbrella for\n+    # ConnectionClosedError, ReadTimeoutError, EndpointConnectionError,\n+    # ConnectTimeoutError, and ProxyConnectionError. ConnectionError covers\n+    # the same family via a different branch of the hierarchy.\n+    try:\n+        from botocore.exceptions import (\n+            ConnectionError as BotoConnectionError,\n+            HTTPClientError,\n+        )\n+        botocore_errors: tuple = (BotoConnectionError, HTTPClientError)\n+    except ImportError:  # pragma: no cover — botocore always present with boto3\n+        botocore_errors = ()\n+    if botocore_errors and isinstance(exc, botocore_errors):\n+        return True\n+\n+    # urllib3: low-level transport failures\n+    try:\n+        from urllib3.exceptions import (\n+            ProtocolError,\n+            NewConnectionError,\n+            ConnectionError as Urllib3ConnectionError,\n+        )\n+        urllib3_errors = (ProtocolError, NewConnectionError, Urllib3ConnectionError)\n+    except ImportError:  # pragma: no cover\n+        urllib3_errors = ()\n+    if urllib3_errors and isinstance(exc, urllib3_errors):\n+        return True\n+\n+    # Library-internal AssertionError (urllib3 / botocore / boto3)\n+    if isinstance(exc, AssertionError):\n+        for module in _traceback_frames_modules(exc):\n+            if any(module.startswith(prefix) for prefix in _STALE_LIB_MODULE_PREFIXES):\n+                return True\n+\n+    return False\n+\n+\n # ---------------------------------------------------------------------------\n # AWS credential detection\n # ---------------------------------------------------------------------------","@@ -787,7 +895,17 @@ def call_converse(\n         guardrail_config=guardrail_config,\n     )\n \n-    response = client.converse(**kwargs)\n+    try:\n+        response = client.converse(**kwargs)\n+    except Exception as exc:\n+        if is_stale_connection_error(exc):\n+            logger.warning(\n+                \"bedrock: stale-connection error on converse(region=%s, model=%s): \"\n+                \"%s — evicting cached client so the next call reconnects.\",\n+                region, model, type(exc).__name__,\n+            )\n+            invalidate_runtime_client(region)\n+        raise\n     return normalize_converse_response(response)\n \n ","@@ -819,7 +937,17 @@ def call_converse_stream(\n         guardrail_config=guardrail_config,\n     )\n \n-    response = client.converse_stream(**kwargs)\n+    try:\n+        response = client.converse_stream(**kwargs)\n+    except Exception as exc:\n+        if is_stale_connection_error(exc):\n+            logger.warning(\n+                \"bedrock: stale-connection error on converse_stream(region=%s, \"\n+                \"model=%s): %s — evicting cached client so the next call reconnects.\",\n+                region, model, type(exc).__name__,\n+            )\n+            invalidate_runtime_client(region)\n+        raise\n     return normalize_converse_stream_events(response)\n \n "]},{"file":"agent/model_metadata.py","hunks":["@@ -1199,6 +1199,7 @@ def get_model_context_length(\n     Resolution order:\n     0. Explicit config override (model.context_length or custom_providers per-model)\n     1. Persistent cache (previously discovered via probing)\n+    1b. AWS Bedrock static table (must precede custom-endpoint probe)\n     2. Active endpoint metadata (/models for explicit custom endpoints)\n     3. Local server query (for local endpoints)\n     4. Anthropic /v1/models API (API-key users only, not OAuth)","@@ -1237,6 +1238,26 @@ def get_model_context_length(\n             else:\n                 return cached\n \n+    # 1b. AWS Bedrock — use static context length table.\n+    # Bedrock's ListFoundationModels API doesn't expose context window sizes,\n+    # so we maintain a curated table in bedrock_adapter.py that reflects\n+    # AWS-imposed limits (e.g. 200K for Claude models vs 1M on the native\n+    # Anthropic API).  This must run BEFORE the custom-endpoint probe at\n+    # step 2 — bedrock-runtime.<region>.amazonaws.com is not in\n+    # _URL_TO_PROVIDER, so it would otherwise be treated as a custom endpoint,\n+    # fail the /models probe (Bedrock doesn't expose that shape), and fall\n+    # back to the 128K default before reaching the original step 4b branch.\n+    if provider == \"bedrock\" or (\n+        base_url\n+        and base_url_hostname(base_url).startswith(\"bedrock-runtime.\")\n+        and base_url_host_matches(base_url, \"amazonaws.com\")\n+    ):\n+        try:\n+            from agent.bedrock_adapter import get_bedrock_context_length\n+            return get_bedrock_context_length(model)\n+        except ImportError:\n+            pass  # boto3 not installed — fall through to generic resolution\n+\n     # 2. Active endpoint metadata for truly custom/unknown endpoints.\n     # Known providers (Copilot, OpenAI, Anthropic, etc.) skip this — their\n     # /models endpoint may report a provider-imposed limit (e.g. Copilot","@@ -1282,19 +1303,7 @@ def get_model_context_length(\n         if ctx:\n             return ctx\n \n-    # 4b. AWS Bedrock — use static context length table.\n-    # Bedrock's ListFoundationModels doesn't expose context window sizes,\n-    # so we maintain a curated table in bedrock_adapter.py.\n-    if provider == \"bedrock\" or (\n-        base_url\n-        and base_url_hostname(base_url).startswith(\"bedrock-runtime.\")\n-        and base_url_host_matches(base_url, \"amazonaws.com\")\n-    ):\n-        try:\n-            from agent.bedrock_adapter import get_bedrock_context_length\n-            return get_bedrock_context_length(model)\n-        except ImportError:\n-            pass  # boto3 not installed — fall through to generic resolution\n+    # 4b. (Bedrock handled earlier at step 1b — before custom-endpoint probe.)\n \n     # 5. Provider-aware lookups (before generic OpenRouter cache)\n     # These are provider-specific and take priority over the generic OR cache,"]},{"file":"run_agent.py","hunks":["@@ -5432,6 +5432,26 @@ def _anthropic_messages_create(self, api_kwargs: dict):\n             self._try_refresh_anthropic_client_credentials()\n         return self._anthropic_client.messages.create(**api_kwargs)\n \n+    def _rebuild_anthropic_client(self) -> None:\n+        \"\"\"Rebuild the Anthropic client after an interrupt or stale call.\n+\n+        Handles both direct Anthropic and Bedrock-hosted Anthropic models\n+        correctly — rebuilding with the Bedrock SDK when provider is bedrock,\n+        rather than always falling back to build_anthropic_client() which\n+        requires a direct Anthropic API key.\n+        \"\"\"\n+        if getattr(self, \"provider\", None) == \"bedrock\":\n+            from agent.anthropic_adapter import build_anthropic_bedrock_client\n+            region = getattr(self, \"_bedrock_region\", \"us-east-1\") or \"us-east-1\"\n+            self._anthropic_client = build_anthropic_bedrock_client(region)\n+        else:\n+            from agent.anthropic_adapter import build_anthropic_client\n+            self._anthropic_client = build_anthropic_client(\n+                self._anthropic_api_key,\n+                getattr(self, \"_anthropic_base_url\", None),\n+                timeout=get_provider_request_timeout(self.provider, self.model),\n+            )\n+\n     def _interruptible_api_call(self, api_kwargs: dict):\n         \"\"\"\n         Run the API call in a background thread so the main conversation loop","@@ -5467,12 +5487,21 @@ def _call():\n                     # bedrock responses like chat_completions responses.\n                     from agent.bedrock_adapter import (\n                         _get_bedrock_runtime_client,\n+                        invalidate_runtime_client,\n+                        is_stale_connection_error,\n                         normalize_converse_response,\n                     )\n                     region = api_kwargs.pop(\"__bedrock_region__\", \"us-east-1\")\n                     api_kwargs.pop(\"__bedrock_converse__\", None)\n                     client = _get_bedrock_runtime_client(region)\n-                    raw_response = client.converse(**api_kwargs)\n+                    try:\n+                        raw_response = client.converse(**api_kwargs)\n+                    except Exception as _bedrock_exc:\n+                        # Evict the cached client on stale-connection failures\n+                        # so the outer retry loop builds a fresh client/pool.\n+                        if is_stale_connection_error(_bedrock_exc):\n+                            invalidate_runtime_client(region)\n+                        raise\n                     result[\"response\"] = normalize_converse_response(raw_response)\n                 else:\n                     request_client_holder[\"client\"] = self._create_request_openai_client(reason=\"chat_completion_request\")","@@ -5530,14 +5559,8 @@ def _call():\n                 )\n                 try:\n                     if self.api_mode == \"anthropic_messages\":\n-                        from agent.anthropic_adapter import build_anthropic_client\n-\n                         self._anthropic_client.close()\n-                        self._anthropic_client = build_anthropic_client(\n-                            self._anthropic_api_key,\n-                            getattr(self, \"_anthropic_base_url\", None),\n-                            timeout=get_provider_request_timeout(self.provider, self.model),\n-                        )\n+                        self._rebuild_anthropic_client()\n                     else:\n                         rc = request_client_holder.get(\"client\")\n                         if rc is not None:","@@ -5562,14 +5585,8 @@ def _call():\n                 # seed future retries.\n                 try:\n                     if self.api_mode == \"anthropic_messages\":\n-                        from agent.anthropic_adapter import build_anthropic_client\n-\n                         self._anthropic_client.close()\n-                        self._anthropic_client = build_anthropic_client(\n-                            self._anthropic_api_key,\n-                            getattr(self, \"_anthropic_base_url\", None),\n-                            timeout=get_provider_request_timeout(self.provider, self.model),\n-                        )\n+                        self._rebuild_anthropic_client()\n                     else:\n                         request_client = request_client_holder.get(\"client\")\n                         if request_client is not None:","@@ -5725,12 +5742,21 @@ def _bedrock_call():\n                 try:\n                     from agent.bedrock_adapter import (\n                         _get_bedrock_runtime_client,\n+                        invalidate_runtime_client,\n+                        is_stale_connection_error,\n                         stream_converse_with_callbacks,\n                     )\n                     region = api_kwargs.pop(\"__bedrock_region__\", \"us-east-1\")\n                     api_kwargs.pop(\"__bedrock_converse__\", None)\n                     client = _get_bedrock_runtime_client(region)\n-                    raw_response = client.converse_stream(**api_kwargs)\n+                    try:\n+                        raw_response = client.converse_stream(**api_kwargs)\n+                    except Exception as _bedrock_exc:\n+                        # Evict the cached client on stale-connection failures\n+                        # so the outer retry loop builds a fresh client/pool.\n+                        if is_stale_connection_error(_bedrock_exc):\n+                            invalidate_runtime_client(region)\n+                        raise\n \n                     def _on_text(text):\n                         _fire_first()","@@ -6410,14 +6436,8 @@ def _call():\n             if self._interrupt_requested:\n                 try:\n                     if self.api_mode == \"anthropic_messages\":\n-                        from agent.anthropic_adapter import build_anthropic_client\n-\n                         self._anthropic_client.close()\n-                        self._anthropic_client = build_anthropic_client(\n-                            self._anthropic_api_key,\n-                            getattr(self, \"_anthropic_base_url\", None),\n-                            timeout=get_provider_request_timeout(self.provider, self.model),\n-                        )\n+                        self._rebuild_anthropic_client()\n                     else:\n                         request_client = request_client_holder.get(\"client\")\n                         if request_client is not None:"]},{"file":"scripts/release.py","hunks":["@@ -201,6 +201,9 @@\n     \"[redacted:email]\": \"5park1e\",\n     \"158153005+5park1e@users.noreply.github.com\": \"5park1e\",\n     \"[redacted:email]\": \"innocarpe\",\n+    \"[redacted:email]\": \"qike-ms\",\n+    \"[redacted:email]\": \"AndreKurait\",\n+    \"bsgdigital@users.noreply.github.com\": \"bsgdigital\",\n     \"[redacted:email]\": \"nummanali\",\n     \"[redacted:email]\": \"whitehatjr1001\",\n     \"0xNyk@users.noreply.github.com\": \"0xNyk\","]},{"file":"tests/agent/test_bedrock_adapter.py","hunks":["@@ -1230,3 +1230,210 @@ def test_real_text_preserved(self):\n         from agent.bedrock_adapter import _convert_content_to_converse\n         blocks = _convert_content_to_converse(\"Hello\")\n         assert blocks[0][\"text\"] == \"Hello\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Stale-connection detection and per-region client invalidation\n+# ---------------------------------------------------------------------------\n+\n+class TestInvalidateRuntimeClient:\n+    \"\"\"Per-region eviction used to discard dead/stale bedrock-runtime clients.\"\"\"\n+\n+    def test_evicts_only_the_target_region(self):\n+        from agent.bedrock_adapter import (\n+            _bedrock_runtime_client_cache,\n+            invalidate_runtime_client,\n+            reset_client_cache,\n+        )\n+        reset_client_cache()\n+        _bedrock_runtime_client_cache[\"us-east-1\"] = \"dead-client\"\n+        _bedrock_runtime_client_cache[\"us-west-2\"] = \"live-client\"\n+\n+        evicted = invalidate_runtime_client(\"us-east-1\")\n+\n+        assert evicted is True\n+        assert \"us-east-1\" not in _bedrock_runtime_client_cache\n+        assert _bedrock_runtime_client_cache[\"us-west-2\"] == \"live-client\"\n+\n+    def test_returns_false_when_region_not_cached(self):\n+        from agent.bedrock_adapter import invalidate_runtime_client, reset_client_cache\n+        reset_client_cache()\n+        assert invalidate_runtime_client(\"eu-west-1\") is False\n+\n+\n+class TestIsStaleConnectionError:\n+    \"\"\"Classifier that decides whether an exception warrants client eviction.\"\"\"\n+\n+    def test_detects_botocore_connection_closed_error(self):\n+        from agent.bedrock_adapter import is_stale_connection_error\n+        from botocore.exceptions import ConnectionClosedError\n+        exc = ConnectionClosedError(endpoint_url=\"https://bedrock.example\")\n+        assert is_stale_connection_error(exc) is True\n+\n+    def test_detects_botocore_endpoint_connection_error(self):\n+        from agent.bedrock_adapter import is_stale_connection_error\n+        from botocore.exceptions import EndpointConnectionError\n+        exc = EndpointConnectionError(endpoint_url=\"https://bedrock.example\")\n+        assert is_stale_connection_error(exc) is True\n+\n+    def test_detects_botocore_read_timeout(self):\n+        from agent.bedrock_adapter import is_stale_connection_error\n+        from botocore.exceptions import ReadTimeoutError\n+        exc = ReadTimeoutError(endpoint_url=\"https://bedrock.example\")\n+        assert is_stale_connection_error(exc) is True\n+\n+    def test_detects_urllib3_protocol_error(self):\n+        from agent.bedrock_adapter import is_stale_connection_error\n+        from urllib3.exceptions import ProtocolError\n+        exc = ProtocolError(\"Connection broken\")\n+        assert is_stale_connection_error(exc) is True\n+\n+    def test_detects_library_internal_assertion_error(self):\n+        \"\"\"A bare AssertionError raised from inside urllib3/botocore signals\n+        a corrupted connection-pool invariant and should trigger eviction.\"\"\"\n+        from agent.bedrock_adapter import is_stale_connection_error\n+\n+        # Fabricate an AssertionError whose traceback's last frame belongs\n+        # to a module named \"urllib3.connectionpool\". We do this by exec'ing\n+        # a tiny `assert False` under a fake globals dict — the resulting\n+        # frame's ``f_globals[\"__name__\"]`` is what the classifier inspects.\n+        fake_globals = {\"__name__\": \"urllib3.connectionpool\"}\n+        try:\n+            exec(\"def _boom():\\n    assert False\\n_boom()\", fake_globals)\n+        except AssertionError as exc:\n+            assert is_stale_connection_error(exc) is True\n+        else:\n+            pytest.fail(\"AssertionError not raised\")\n+\n+    def test_detects_botocore_internal_assertion_error(self):\n+        \"\"\"Same as above but for a frame inside the botocore namespace.\"\"\"\n+        from agent.bedrock_adapter import is_stale_connection_error\n+        fake_globals = {\"__name__\": \"botocore.httpsession\"}\n+        try:\n+            exec(\"def _boom():\\n    assert False\\n_boom()\", fake_globals)\n+        except AssertionError as exc:\n+            assert is_stale_connection_error(exc) is True\n+        else:\n+            pytest.fail(\"AssertionError not raised\")\n+\n+    def test_ignores_application_assertion_error(self):\n+        \"\"\"AssertionError from application code (not urllib3/botocore) should\n+        NOT be classified as stale — those are real test/code bugs.\"\"\"\n+        from agent.bedrock_adapter import is_stale_connection_error\n+        try:\n+            assert False, \"test-only\"  # noqa: B011\n+        except AssertionError as exc:\n+            assert is_stale_connection_error(exc) is False\n+\n+    def test_ignores_unrelated_exceptions(self):\n+        from agent.bedrock_adapter import is_stale_connection_error\n+        assert is_stale_connection_error(ValueError(\"bad input\")) is False\n+        assert is_stale_connection_error(KeyError(\"missing\")) is False\n+\n+\n+class TestCallConverseInvalidatesOnStaleError:\n+    \"\"\"call_converse / call_converse_stream evict the cached client when the\n+    boto3 call raises a stale-connection error — so the next invocation\n+    reconnects instead of reusing the dead socket.\"\"\"\n+\n+    def test_converse_evicts_client_on_stale_error(self):\n+        from agent.bedrock_adapter import (\n+            _bedrock_runtime_client_cache,\n+            call_converse,\n+            reset_client_cache,\n+        )\n+        from botocore.exceptions import ConnectionClosedError\n+\n+        reset_client_cache()\n+        dead_client = MagicMock()\n+        dead_client.converse.side_effect = ConnectionClosedError(\n+            endpoint_url=\"https://bedrock.example\",\n+        )\n+        _bedrock_runtime_client_cache[\"us-east-1\"] = dead_client\n+\n+        with pytest.raises(ConnectionClosedError):\n+            call_converse(\n+                region=\"us-east-1\",\n+                model=\"anthropic.claude-3-sonnet-20240229-v1:0\",\n+                messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n+            )\n+\n+        assert \"us-east-1\" not in _bedrock_runtime_client_cache, (\n+            \"stale client should have been evicted so the retry reconnects\"\n+        )\n+\n+    def test_converse_stream_evicts_client_on_stale_error(self):\n+        from agent.bedrock_adapter import (\n+            _bedrock_runtime_client_cache,\n+            call_converse_stream,\n+            reset_client_cache,\n+        )\n+        from botocore.exceptions import ConnectionClosedError\n+\n+        reset_client_cache()\n+        dead_client = MagicMock()\n+        dead_client.converse_stream.side_effect = ConnectionClosedError(\n+            endpoint_url=\"https://bedrock.example\",\n+        )\n+        _bedrock_runtime_client_cache[\"us-east-1\"] = dead_client\n+\n+        with pytest.raises(ConnectionClosedError):\n+            call_converse_stream(\n+                region=\"us-east-1\",\n+                model=\"anthropic.claude-3-sonnet-20240229-v1:0\",\n+                messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n+            )\n+\n+        assert \"us-east-1\" not in _bedrock_runtime_client_cache\n+\n+    def test_converse_does_not_evict_on_non_stale_error(self):\n+        \"\"\"Non-stale errors (e.g. ValidationException) leave the client cache alone.\"\"\"\n+        from agent.bedrock_adapter import (\n+            _bedrock_runtime_client_cache,\n+            call_converse,\n+            reset_client_cache,\n+        )\n+        from botocore.exceptions import ClientError\n+\n+        reset_client_cache()\n+        live_client = MagicMock()\n+        live_client.converse.side_effect = ClientError(\n+            error_response={\"Error\": {\"Code\": \"ValidationException\", \"Message\": \"bad\"}},\n+            operation_name=\"Converse\",\n+        )\n+        _bedrock_runtime_client_cache[\"us-east-1\"] = live_client\n+\n+        with pytest.raises(ClientError):\n+            call_converse(\n+                region=\"us-east-1\",\n+                model=\"anthropic.claude-3-sonnet-20240229-v1:0\",\n+                messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n+            )\n+\n+        assert _bedrock_runtime_client_cache.get(\"us-east-1\") is live_client, (\n+            \"validation errors do not indicate a dead connection — keep the client\"\n+        )\n+\n+    def test_converse_leaves_successful_client_in_cache(self):\n+        from agent.bedrock_adapter import (\n+            _bedrock_runtime_client_cache,\n+            call_converse,\n+            reset_client_cache,\n+        )\n+\n+        reset_client_cache()\n+        live_client = MagicMock()\n+        live_client.converse.return_value = {\n+            \"output\": {\"message\": {\"role\": \"assistant\", \"content\": [{\"text\": \"hi\"}]}},\n+            \"stopReason\": \"end_turn\",\n+            \"usage\": {\"inputTokens\": 1, \"outputTokens\": 1, \"totalTokens\": 2},\n+        }\n+        _bedrock_runtime_client_cache[\"us-east-1\"] = live_client\n+\n+        call_converse(\n+            region=\"us-east-1\",\n+            model=\"anthropic.claude-3-sonnet-20240229-v1:0\",\n+            messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n+        )\n+\n+        assert _bedrock_runtime_client_cache.get(\"us-east-1\") is live_client"]},{"file":"tests/agent/test_bedrock_integration.py","hunks":["@@ -376,17 +376,15 @@ def test_apac_anthropic_haiku_inference_profile_preserved(self):\n             \"apac.anthropic.claude-haiku-4-5\", preserve_dots=True\n         ) == \"apac.anthropic.claude-haiku-4-5\"\n \n-    def test_preserve_false_mangles_as_documented(self):\n-        \"\"\"Canary: with ``preserve_dots=False`` the function still\n-        produces the broken all-hyphen form — this is the shape that\n-        Bedrock rejected and that the fix avoids.  Keeping this test\n-        locks in the existing behaviour of ``normalize_model_name`` so a\n-        future refactor doesn't accidentally decouple the knob from its\n-        effect.\"\"\"\n+    def test_bedrock_prefix_preserved_without_preserve_dots(self):\n+        \"\"\"Bedrock inference profile IDs are auto-detected by prefix and\n+        always returned unmangled -- ``preserve_dots`` is irrelevant for\n+        these IDs because the dots are namespace separators, not version\n+        separators.  Regression for #12295.\"\"\"\n         from agent.anthropic_adapter import normalize_model_name\n         assert normalize_model_name(\n             \"global.anthropic.claude-opus-4-7\", preserve_dots=False\n-        ) == \"global-anthropic-claude-opus-4-7\"\n+        ) == \"global.anthropic.claude-opus-4-7\"\n \n     def test_bare_foundation_model_id_preserved(self):\n         \"\"\"Non-inference-profile Bedrock IDs","@@ -422,12 +420,11 @@ def test_bedrock_inference_profile_survives_build_kwargs(self):\n             f\"{kwargs['model']!r}\"\n         )\n \n-    def test_bedrock_model_mangled_without_preserve_dots(self):\n-        \"\"\"Inverse canary: without the flag, ``build_anthropic_kwargs``\n-        still produces the broken form — so the fix in\n-        ``_anthropic_preserve_dots`` is the load-bearing piece that\n-        wires ``preserve_dots=True`` through to this builder for the\n-        Bedrock case.\"\"\"\n+    def test_bedrock_model_preserved_without_preserve_dots(self):\n+        \"\"\"Bedrock inference profile IDs survive ``build_anthropic_kwargs``\n+        even without ``preserve_dots=True`` -- the prefix auto-detection\n+        in ``normalize_model_name`` is the load-bearing piece.\n+        Regression for #12295.\"\"\"\n         from agent.anthropic_adapter import build_anthropic_kwargs\n         kwargs = build_anthropic_kwargs(\n             model=\"global.anthropic.claude-opus-4-7\",","@@ -437,4 +434,157 @@ def test_bedrock_model_mangled_without_preserve_dots(self):\n             reasoning_config=None,\n             preserve_dots=False,\n         )\n-        assert kwargs[\"model\"] == \"global-anthropic-claude-opus-4-7\"\n+        assert kwargs[\"model\"] == \"global.anthropic.claude-opus-4-7\"\n+\n+\n+class TestBedrockModelIdDetection:\n+    \"\"\"Tests for ``_is_bedrock_model_id`` and the auto-detection that\n+    makes ``normalize_model_name`` preserve dots for Bedrock IDs\n+    regardless of ``preserve_dots``.  Regression for #12295.\"\"\"\n+\n+    def test_bare_bedrock_id_detected(self):\n+        from agent.anthropic_adapter import _is_bedrock_model_id\n+        assert _is_bedrock_model_id(\"anthropic.claude-opus-4-7\") is True\n+\n+    def test_regional_us_prefix_detected(self):\n+        from agent.anthropic_adapter import _is_bedrock_model_id\n+        assert _is_bedrock_model_id(\"us.anthropic.claude-sonnet-4-5-v1:0\") is True\n+\n+    def test_regional_global_prefix_detected(self):\n+        from agent.anthropic_adapter import _is_bedrock_model_id\n+        assert _is_bedrock_model_id(\"global.anthropic.claude-opus-4-7\") is True\n+\n+    def test_regional_eu_prefix_detected(self):\n+        from agent.anthropic_adapter import _is_bedrock_model_id\n+        assert _is_bedrock_model_id(\"eu.anthropic.claude-sonnet-4-6\") is True\n+\n+    def test_openrouter_format_not_detected(self):\n+        from agent.anthropic_adapter import _is_bedrock_model_id\n+        assert _is_bedrock_model_id(\"claude-opus-4.6\") is False\n+\n+    def test_bare_claude_not_detected(self):\n+        from agent.anthropic_adapter import _is_bedrock_model_id\n+        assert _is_bedrock_model_id(\"claude-opus-4-7\") is False\n+\n+    def test_bare_bedrock_id_preserved_without_flag(self):\n+        \"\"\"The primary bug from #12295: ``anthropic.claude-opus-4-7``\n+        sent to bedrock-mantle via auxiliary clients that don't pass\n+        ``preserve_dots=True``.\"\"\"\n+        from agent.anthropic_adapter import normalize_model_name\n+        assert normalize_model_name(\n+            \"anthropic.claude-opus-4-7\", preserve_dots=False\n+        ) == \"anthropic.claude-opus-4-7\"\n+\n+    def test_openrouter_dots_still_converted(self):\n+        \"\"\"Non-Bedrock dotted model names must still be converted.\"\"\"\n+        from agent.anthropic_adapter import normalize_model_name\n+        assert normalize_model_name(\"claude-opus-4.6\") == \"claude-opus-4-6\"\n+\n+    def test_bare_bedrock_id_survives_build_kwargs(self):\n+        \"\"\"End-to-end: bare Bedrock ID through ``build_anthropic_kwargs``\n+        without ``preserve_dots=True`` -- the auxiliary client path.\"\"\"\n+        from agent.anthropic_adapter import build_anthropic_kwargs\n+        kwargs = build_anthropic_kwargs(\n+            model=\"anthropic.claude-opus-4-7\",\n+            messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n+            tools=None,\n+            max_tokens=1024,\n+            reasoning_config=None,\n+            preserve_dots=False,\n+        )\n+        assert kwargs[\"model\"] == \"anthropic.claude-opus-4-7\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# auxiliary_client Bedrock resolution — fix for #13919\n+# ---------------------------------------------------------------------------\n+# Before the fix, resolve_provider_client(\"bedrock\", ...) fell through to the\n+# \"unhandled auth_type\" warning and returned (None, None), breaking all\n+# auxiliary tasks (compression, memory, summarization) for Bedrock users.\n+\n+\n+class TestAuxiliaryClientBedrockResolution:\n+    \"\"\"Verify resolve_provider_client handles Bedrock's aws_sdk auth type.\"\"\"\n+\n+    def test_bedrock_returns_client_with_credentials(self, monkeypatch):\n+        \"\"\"With valid AWS credentials, Bedrock should return a usable client.\"\"\"\n+        monkeypatch.setenv(\"AWS_ACCESS_KEY_ID\", \"[redacted:aws-access-key]\")\n+        monkeypatch.setenv(\"AWS_SECRET_ACCESS_KEY\", \"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\")\n+        monkeypatch.setenv(\"AWS_REGION\", \"us-west-2\")\n+\n+        mock_anthropic_bedrock = MagicMock()\n+        with patch(\"agent.anthropic_adapter.build_anthropic_bedrock_client\",\n+                   return_value=mock_anthropic_bedrock):\n+            from agent.auxiliary_client import resolve_provider_client, AnthropicAuxiliaryClient\n+            client, model = resolve_provider_client(\"bedrock\", None)\n+\n+        assert client is not None, (\n+            \"resolve_provider_client('bedrock') returned None — \"\n+            \"aws_sdk auth type is not handled\"\n+        )\n+        assert isinstance(client, AnthropicAuxiliaryClient)\n+        assert model is not None\n+        assert client.api_key == \"aws-sdk\"\n+        assert \"us-west-2\" in client.base_url\n+\n+    def test_bedrock_returns_none_without_credentials(self, monkeypatch):\n+        \"\"\"Without AWS credentials, Bedrock should return (None, None) gracefully.\"\"\"\n+        with patch(\"agent.bedrock_adapter.has_aws_credentials\", return_value=False):\n+            from agent.auxiliary_client import resolve_provider_client\n+            client, model = resolve_provider_client(\"bedrock\", None)\n+\n+        assert client is None\n+        assert model is None\n+\n+    def test_bedrock_uses_configured_region(self, monkeypatch):\n+        \"\"\"Bedrock client base_url should reflect AWS_REGION.\"\"\"\n+        monkeypatch.setenv(\"AWS_ACCESS_KEY_ID\", \"[redacted:aws-access-key]\")\n+        monkeypatch.setenv(\"AWS_SECRET_ACCESS_KEY\", \"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\")\n+        monkeypatch.setenv(\"AWS_REGION\", \"eu-central-1\")\n+\n+        with patch(\"agent.anthropic_adapter.build_anthropic_bedrock_client\",\n+                   return_value=MagicMock()):\n+            from agent.auxiliary_client import resolve_provider_client\n+            client, _ = resolve_provider_client(\"bedrock\", None)\n+\n+        assert client is not None\n+        assert \"eu-central-1\" in client.base_url\n+\n+    def test_bedrock_respects_explicit_model(self, monkeypatch):\n+        \"\"\"When caller passes an explicit model, it should be used.\"\"\"\n+        monkeypatch.setenv(\"AWS_ACCESS_KEY_ID\", \"[redacted:aws-access-key]\")\n+        monkeypatch.setenv(\"AWS_SECRET_ACCESS_KEY\", \"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\")\n+\n+        with patch(\"agent.anthropic_adapter.build_anthropic_bedrock_client\",\n+                   return_value=MagicMock()):\n+            from agent.auxiliary_client import resolve_provider_client\n+            _, model = resolve_provider_client(\n+                \"bedrock\", \"us.anthropic.claude-sonnet-4-5-20250929-v1:0\"\n+            )\n+\n+        assert \"claude-sonnet\" in model\n+\n+    def test_bedrock_async_mode(self, monkeypatch):\n+        \"\"\"Async mode should return an AsyncAnthropicAuxiliaryClient.\"\"\"\n+        monkeypatch.setenv(\"AWS_ACCESS_KEY_ID\", \"[redacted:aws-access-key]\")\n+        monkeypatch.setenv(\"AWS_SECRET_ACCESS_KEY\", \"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\")\n+\n+        with patch(\"agent.anthropic_adapter.build_anthropic_bedrock_client\",\n+                   return_value=MagicMock()):\n+            from agent.auxiliary_client import resolve_provider_client, AsyncAnthropicAuxiliaryClient\n+            client, model = resolve_provider_client(\"bedrock\", None, async_mode=True)\n+\n+        assert client is not None\n+        assert isinstance(client, AsyncAnthropicAuxiliaryClient)\n+\n+    def test_bedrock_default_model_is_haiku(self, monkeypatch):\n+        \"\"\"Default auxiliary model for Bedrock should be Haiku (fast, cheap).\"\"\"\n+        monkeypatch.setenv(\"AWS_ACCESS_KEY_ID\", \"[redacted:aws-access-key]\")\n+        monkeypatch.setenv(\"AWS_SECRET_ACCESS_KEY\", \"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\")\n+\n+        with patch(\"agent.anthropic_adapter.build_anthropic_bedrock_client\",\n+                   return_value=MagicMock()):\n+            from agent.auxiliary_client import resolve_provider_client\n+            _, model = resolve_provider_client(\"bedrock\", None)\n+\n+        assert \"haiku\" in model.lower()"]},{"file":"tests/agent/test_model_metadata.py","hunks":["@@ -588,6 +588,57 @@ def test_config_context_length_none_is_ignored(self, mock_fetch):\n         assert result == 200000\n \n \n+# =========================================================================\n+# Bedrock context resolution — must run BEFORE custom-endpoint probe\n+# =========================================================================\n+\n+class TestBedrockContextResolution:\n+    \"\"\"Regression tests for Bedrock context-length resolution order.\n+\n+    Bug: because ``bedrock-runtime.<region>.amazonaws.com`` is not listed in\n+    ``_URL_TO_PROVIDER``, ``_is_known_provider_base_url`` returned False and\n+    the custom-endpoint probe at step 2 ran first — fetching ``/models`` from\n+    Bedrock (which it doesn't serve), returning the 128K default-fallback\n+    before execution ever reached the Bedrock branch.\n+\n+    Fix: promote the Bedrock branch ahead of the custom-endpoint probe.\n+    \"\"\"\n+\n+    @patch(\"agent.model_metadata.fetch_endpoint_model_metadata\")\n+    def test_bedrock_provider_returns_static_table_before_probe(self, mock_fetch):\n+        \"\"\"provider='bedrock' resolves via static table, bypasses /models probe.\"\"\"\n+        ctx = get_model_context_length(\n+            \"anthropic.claude-opus-4-v1:0\",\n+            provider=\"bedrock\",\n+            base_url=\"https://bedrock-runtime.us-east-1.amazonaws.com\",\n+        )\n+        # Must return the static Bedrock table value (200K for Claude),\n+        # NOT DEFAULT_FALLBACK_CONTEXT (128K).\n+        assert ctx == 200000\n+        mock_fetch.assert_not_called()\n+\n+    @patch(\"agent.model_metadata.fetch_endpoint_model_metadata\")\n+    def test_bedrock_url_without_provider_hint(self, mock_fetch):\n+        \"\"\"bedrock-runtime host infers Bedrock even when provider is omitted.\"\"\"\n+        ctx = get_model_context_length(\n+            \"anthropic.claude-sonnet-4-v1:0\",\n+            base_url=\"https://bedrock-runtime.us-west-2.amazonaws.com\",\n+        )\n+        assert ctx == 200000\n+        mock_fetch.assert_not_called()\n+\n+    @patch(\"agent.model_metadata.fetch_endpoint_model_metadata\")\n+    def test_non_bedrock_url_still_probes(self, mock_fetch):\n+        \"\"\"Non-Bedrock hosts still reach the custom-endpoint probe.\"\"\"\n+        mock_fetch.return_value = {\"some-model\": {\"context_length\": 50000}}\n+        ctx = get_model_context_length(\n+            \"some-model\",\n+            base_url=\"https://api.example.com/v1\",\n+        )\n+        assert ctx == 50000\n+        assert mock_fetch.called\n+\n+\n # =========================================================================\n # _strip_provider_prefix — Ollama model:tag vs provider:model\n # =========================================================================\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}