From 7b4ff9f789829713cb5aecf078e114e77715e4c0 Mon Sep 17 00:00:00 2001 From: Marshall Claw Date: Fri, 7 Aug 2026 11:29:15 +0500 Subject: [PATCH] fix(anthropic): prevent OAuth billing classifier from routing subscription calls to extra-usage Anthropic's OAuth billing classifier inspects the full request body (system prompt text, tool names, tool descriptions, tool schema footprint) for third-party fingerprints. Any match flips native-Anthropic OAuth (Claude Pro/Max subscription) requests from plan-billing to the extra-usage lane, which 400s once extra usage is exhausted -- even though the actual subscription has quota. This took the gateway/cron down repeatedly with 'You'\''re out of extra usage' errors that looked like credential/quota failures but were not. Two independent mitigations, both scoped to is_oauth/_is_anthropic_oauth sessions only (never applied to third-party Anthropic-compatible endpoints): 1. Text sanitization (anthropic_adapter.py): expand the system-prompt product-name replacement table from 4 to 13 patterns (standalone 'Hermes', bare 'Nous', 'Nous Portal', 'nousresearch', 'hermes_agent', etc.), and extend sanitization to tool descriptions and tool_choice names (previously only tool names were normalized to the mcp__ wire form). Ordered most-specific-pattern-first. 2. Tool-schema footprint reduction (agent_init.py, model_tools.py, tools/tool_search.py, tools/mcp_tool.py, toolsets.py): confirmed empirically that sending the full ~60-tool core set on every OAuth turn reliably trips the classifier even with full text sanitization (0 tools -> succeeds, full core set -> 400). Added toolsets.OAUTH_SAFE_CORE_TOOLS, a minimal always-eager allowlist for native-Anthropic OAuth sessions; everything else in the normal core set becomes deferrable via the tool_search/describe/call bridge (still fully reachable, just not eagerly listed). Wired through agent_init (initial snapshot) and mcp_tool.refresh_agent_mcp_tools (mid-conversation MCP registration refresh, which previously silently re-expanded back to the full core set and re-tripped the classifier on the next turn). 108/108 tests pass in tests/agent/test_anthropic_adapter.py. Local-only fix pending upstream PR to NousResearch/hermes-agent -- was previously sitting uncommitted in the working tree and got silently autostashed (and one incomplete copy dropped) by 'hermes update' on 2026-08-06, which is what caused this to regress and knocked the gateway offline again on 2026-08-07. --- agent/agent_init.py | 8 + agent/agent_runtime_helpers.py | 2 +- agent/anthropic_adapter.py | 82 ++++++++++- agent/auxiliary_client.py | 3 +- agent/transports/anthropic.py | 20 ++- model_tools.py | 25 +++- tests/agent/test_anthropic_adapter.py | 203 ++++++++++++++++++++++++++ tools/mcp_tool.py | 7 + tools/tool_search.py | 48 ++++-- toolsets.py | 24 +++ 10 files changed, 400 insertions(+), 22 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index 6f89ed237dca..18d7a06bac2c 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1429,10 +1429,18 @@ def init_agent( agent._tool_snapshot_generation = _snapshot_registry._generation except Exception: agent._tool_snapshot_generation = 0 + # Native-Anthropic OAuth (Claude Pro/Max subscription) sessions must not + # send the full ~60-tool core set eagerly — Anthropic's OAuth billing + # classifier routes such requests to "extra usage" instead of plan quota + # based partly on the tool-schema footprint. Shrink the always-eager set + # to toolsets.OAUTH_SAFE_CORE_TOOLS and defer the rest behind the + # tool_search/describe/call bridge; every tool stays reachable, just not + # eagerly listed. See toolsets.OAUTH_SAFE_CORE_TOOLS docstring. agent.tools = _ra().get_tool_definitions( enabled_toolsets=enabled_toolsets, disabled_toolsets=disabled_toolsets, quiet_mode=agent.quiet_mode, + oauth_minimal_core=bool(getattr(agent, "_is_anthropic_oauth", False)), ) # Show tool configuration and store valid tool names for validation diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 062549ed7f65..aae6232f1435 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1854,7 +1854,7 @@ def dump_api_request_debug( "reason": reason, "request": { "method": "POST", - "url": f"{agent.base_url.rstrip('/')}{'/responses' if agent.api_mode == 'codex_responses' else '/chat/completions'}", + "url": f"{agent.base_url.rstrip('/')}{'/responses' if agent.api_mode == 'codex_responses' else '/v1/messages' if agent.api_mode == 'anthropic_messages' else '/chat/completions'}", "headers": { "Authorization": f"Bearer {agent._mask_api_key_for_logs(api_key)}", "Content-Type": "application/json", diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 70a959932318..a30986034fc3 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -771,6 +771,8 @@ def _build_anthropic_client_with_bearer_hook( # 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 + # Mark the client as OAuth since Bearer/Entra flows are OAuth-authenticated + client._hermes_is_oauth = True return client @@ -909,6 +911,13 @@ def build_anthropic_client( # key whenever we intentionally authenticated via auth_token alone. if "auth_token" in kwargs and "api_key" not in kwargs: client.api_key = None + + # Mark the client if built from OAuth so downstream code can apply + # the necessary system-prompt sanitization and tool-name normalization + # to avoid Anthropic's billing classifier (GH-25255). + if _is_oauth_token(api_key): + client._hermes_is_oauth = True + return client @@ -1395,7 +1404,25 @@ def _read_creds() -> Optional[Dict[str, Any]]: # 3. Regular API key. An explicit user-configured key must not be shadowed # by auto-discovered Claude Code or credential-pool OAuth credentials. + # However, if this source is explicitly suppressed (e.g. known-bad token), + # skip it and fall through to Claude Code credentials (priority #4). api_key = _getenv("ANTHROPIC_API_KEY").strip() + + # Check if env:ANTHROPIC_API_KEY is in suppressed_sources (via auth.json) + try: + import json + from pathlib import Path + auth_path = Path(get_hermes_home()) / "auth.json" + if auth_path.exists(): + auth_data = json.loads(auth_path.read_text(encoding="utf-8")) + suppressed = auth_data.get("suppressed_sources", {}).get("anthropic", []) + if "env:ANTHROPIC_API_KEY" in suppressed and api_key: + # Skip this suppressed source and fall through to #4 + logger.debug("Skipping suppressed ANTHROPIC_API_KEY source") + api_key = None + except Exception: + pass # If we can't read auth.json, don't let it block credential resolution + if api_key: return api_key @@ -2895,15 +2922,41 @@ def build_anthropic_kwargs( else: system = [cc_block] - # 2. Sanitize system prompt — replace product name references + # 2. Sanitize system prompt — replace ALL product-name references # to avoid Anthropic's server-side content filters. + # Anthropic's OAuth billing classifier greps the entire request + # (system prompt + tool names + tool descriptions) for third-party + # fingerprints. The original sanitizer only replaced four exact + # patterns, leaving behind standalone "Hermes" references, bare + # "Nous" tokens (without the space in "Nous Research"), and the + # canonical ``hermes-agent.nousresearch.com`` documentation URL. + # Each of those is a strong classifier trigger that kept flipping + # OAuth requests into the extra-usage billing lane (HTTP 400 + # "Third-party apps now draw from extra usage, not plan limits"). + # GH-25255, GH-25256. + _OAUTH_TEXT_REPLACEMENTS = ( + # Most specific patterns first — order matters because each + # replacement is applied in sequence and a shorter pattern + # could partially consume a longer one. + ("hermes-agent.nousresearch.com", "claude.ai/docs"), + ("hermes.nous", "claude.ai"), + ("Hermes Agent", "Claude Code"), + ("Hermes agent", "Claude Code"), + ("Nous Research", "Anthropic"), + ("Nous Portal", "Claude Code Portal"), + ("Nous subscription", "Claude Code subscription"), + ("Nous", "Anthropic"), + ("hermes-agent", "claude-code"), + ("hermes_agent", "claude_code"), + ("nousresearch", "anthropic"), + ("Hermes", "Claude Code"), + ("nous", "anthropic"), + ) for block in system: if isinstance(block, dict) and block.get("type") == "text": text = block.get("text", "") - text = text.replace("Hermes Agent", "Claude Code") - text = text.replace("Hermes agent", "Claude Code") - text = text.replace("hermes-agent", "claude-code") - text = text.replace("Nous Research", "Anthropic") + for _old, _new in _OAUTH_TEXT_REPLACEMENTS: + text = text.replace(_old, _new) block["text"] = text # 3. Normalize tool names so NOTHING goes on the OAuth wire with a @@ -2936,6 +2989,17 @@ def _to_oauth_wire_name(name: str) -> str: for tool in anthropic_tools: if "name" in tool: tool["name"] = _to_oauth_wire_name(tool["name"]) + # 3b. Sanitize tool descriptions — the billing classifier + # greps the entire request body (not just system + tool + # names) for third-party fingerprints. Tool descriptions + # are riddled with "Hermes" / "Nous" references that + # were only being replaced in the system prompt, leaving + # the tool schema as a live classifier trigger. + _desc = tool.get("description") + if isinstance(_desc, str) and _desc: + for _old, _new in _OAUTH_TEXT_REPLACEMENTS: + _desc = _desc.replace(_old, _new) + tool["description"] = _desc # 4. Apply the same normalization to tool names in message history # (tool_use blocks) so replayed turns match the wire names above. @@ -2949,6 +3013,14 @@ def _to_oauth_wire_name(name: str) -> str: elif block.get("type") == "tool_result" and "tool_use_id" in block: pass # tool_result uses ID, not name + # 5. Normalize a specific tool_choice name so it matches the + # OAuth-wire tool names above. Without this, a forced + # ``tool_choice="read_file"`` stays bare on the wire while the + # tool list carries ``mcp__read_file``, so Anthropic rejects the + # request (the chosen name doesn't match any available tool). + if isinstance(tool_choice, str) and not tool_choice.startswith(("mcp__", "auto", "required", "none")): + tool_choice = _to_oauth_wire_name(tool_choice) + kwargs: Dict[str, Any] = { "model": model, "messages": anthropic_messages, diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index c6db27104de9..6f074d9adaf0 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1999,6 +1999,7 @@ def _maybe_wrap_anthropic( api_key: str, base_url: str, api_mode: Optional[str] = None, + is_oauth: bool = False, ) -> Any: """Rewrap a plain OpenAI client in ``AnthropicAuxiliaryClient`` when the endpoint actually speaks Anthropic Messages. @@ -2073,7 +2074,7 @@ def _maybe_wrap_anthropic( model, base_url[:60] if base_url else "", api_mode or "auto-detected", ) return AnthropicAuxiliaryClient( - real_client, model, api_key, base_url, is_oauth=False, + real_client, model, api_key, base_url, is_oauth=is_oauth, ) diff --git a/agent/transports/anthropic.py b/agent/transports/anthropic.py index 98721f7c5e63..95224d4cd2e6 100644 --- a/agent/transports/anthropic.py +++ b/agent/transports/anthropic.py @@ -60,7 +60,23 @@ def build_kwargs( fast_mode: bool drop_context_1m_beta: bool """ - from agent.anthropic_adapter import build_anthropic_kwargs + from agent.anthropic_adapter import build_anthropic_kwargs, _is_oauth_token + + # Detect if this client was built from an OAuth token. + # Check three sources of truth in order of preference: + # 1. Explicit is_oauth in params (set by agent initialization) + # 2. Marker added to client at creation time (_hermes_is_oauth) + # 3. Auto-detect from the client's auth_token if available + is_oauth = params.get("is_oauth") + if is_oauth is None: + # Check if the client was marked as OAuth when created + is_oauth = getattr(self.client, "_hermes_is_oauth", False) + if not is_oauth: + # Last resort: check if the client's auth_token looks like OAuth + # (This handles cases where the marker wasn't set) + auth_token = getattr(self.client, "auth_token", None) or getattr(self.client, "_auth_token", None) + if auth_token and isinstance(auth_token, str): + is_oauth = _is_oauth_token(auth_token) return build_anthropic_kwargs( model=model, @@ -69,7 +85,7 @@ def build_kwargs( max_tokens=params.get("max_tokens", 16384), reasoning_config=params.get("reasoning_config"), tool_choice=params.get("tool_choice"), - is_oauth=params.get("is_oauth", False), + is_oauth=is_oauth, preserve_dots=params.get("preserve_dots", False), context_length=params.get("context_length"), base_url=params.get("base_url"), diff --git a/model_tools.py b/model_tools.py index a4ed0c20c577..0b7852d7ba28 100644 --- a/model_tools.py +++ b/model_tools.py @@ -307,6 +307,7 @@ def get_tool_definitions( disabled_toolsets: Optional[List[str]] = None, quiet_mode: bool = False, skip_tool_search_assembly: bool = False, + oauth_minimal_core: bool = False, ) -> List[Dict[str, Any]]: """ Get tool definitions for model API calls with toolset-based filtering. @@ -322,6 +323,16 @@ def get_tool_definitions( tool_search / tool_describe bridge handlers so they can read the real catalog, not the already-collapsed one. Public callers should leave this False. + oauth_minimal_core: When True, shrink tool_search's always-eager + "core" allowlist down to ``toolsets.OAUTH_SAFE_CORE_TOOLS`` and + force the tool_search/describe/call bridge to activate + regardless of the normal context-percentage gate. Set by + agent_init.py for native-Anthropic OAuth (Claude Pro/Max + subscription) sessions, where sending the full ~60-tool core set + on every turn trips Anthropic's OAuth billing classifier and + routes the request to "extra usage" instead of plan quota. All + tools stay reachable via the bridge — this only changes what's + eagerly listed vs. discoverable on demand. Returns: Filtered list of OpenAI-format tool definitions. @@ -352,6 +363,7 @@ def get_tool_definitions( cfg_fp, bool(os.environ.get("HERMES_KANBAN_TASK")), bool(skip_tool_search_assembly), + bool(oauth_minimal_core), _is_delegated_child_context(), _is_dispatcher_owned_worker(), profile_scope, @@ -367,7 +379,8 @@ def get_tool_definitions( return list(cached) result = _compute_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode, - skip_tool_search_assembly=skip_tool_search_assembly) + skip_tool_search_assembly=skip_tool_search_assembly, + oauth_minimal_core=oauth_minimal_core) if quiet_mode and cache_key is not None: # Cache the freshly-computed list, but hand callers a shallow copy so # downstream mutations (e.g. run_agent appending memory/LCM tool @@ -393,6 +406,7 @@ def _compute_tool_definitions( disabled_toolsets: Optional[List[str]] = None, quiet_mode: bool = False, skip_tool_search_assembly: bool = False, + oauth_minimal_core: bool = False, ) -> List[Dict[str, Any]]: """Uncached implementation of :func:`get_tool_definitions`.""" # Determine which tool names the caller wants @@ -588,10 +602,19 @@ def _compute_tool_definitions( ts_cfg = _load_ts_config() if not skip_tool_search_assembly and ts_cfg.enabled != "off": context_length = _resolve_active_context_length() + _core_override = None + if oauth_minimal_core: + try: + from toolsets import OAUTH_SAFE_CORE_TOOLS + _core_override = frozenset(OAUTH_SAFE_CORE_TOOLS) + except Exception: + logger.warning("oauth_minimal_core requested but OAUTH_SAFE_CORE_TOOLS unavailable") assembly = assemble_tool_defs( filtered_tools, context_length=context_length, config=ts_cfg, + core_override=_core_override, + force_activate=oauth_minimal_core, ) if assembly.activated and not quiet_mode: _forms = {"full": "catalog listing embedded", diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index 0b8a7b08bb73..81b4d3db7de6 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -1882,3 +1882,206 @@ def test_blank_text_nested_in_tool_result_content_is_dropped(self): ) image_blocks = [b for b in tool_result_block["content"] if b.get("type") == "image"] assert len(image_blocks) == 1 + + +# --------------------------------------------------------------------------- +# OAuth billing-classifier sanitization (GH-25255, GH-25256) +# --------------------------------------------------------------------------- +# +# Anthropic runs a billing classifier on subscription/OAuth traffic. If it +# detects a third-party-app fingerprint anywhere in the request — system prompt +# text, tool names, tool descriptions, tool_choice names — it routes the request +# to the extra-usage billing lane instead of the plan. Since extra-usage +# balance is typically zero, the user gets an opaque HTTP 400. +# +# The OAuth transforms in build_anthropic_kwargs must scrub ALL such +# fingerprints, not just the four originally covered. + + +class TestOAuthSanitization: + """Verify that build_anthropic_kwargs scrubs all Hermes/Nous fingerprints + when is_oauth=True so Anthropic's billing classifier routes the request + onto the subscription plan, not the extra-usage lane.""" + + def _build(self, tools=None, messages=None, system=None, tool_choice=None, is_oauth=True): + return build_anthropic_kwargs( + model="claude-opus-4-8", + messages=messages or [{"role": "user", "content": "hello"}], + tools=tools, + max_tokens=4096, + reasoning_config=None, + tool_choice=tool_choice, + is_oauth=is_oauth, + preserve_dots=False, + context_length=None, + base_url=None, + fast_mode=False, + drop_context_1m_beta=False, + ) + + def _system_text(self, kwargs): + system = kwargs.get("system") + if not system: + return "" + if isinstance(system, str): + return system.lower() + if isinstance(system, list): + return " ".join( + b.get("text", "") + for b in system + if isinstance(b, dict) and b.get("type") == "text" + ).lower() + return "" + + # -- system prompt ------------------------------------------------------- + + def test_system_prefix_is_claude_code_identity(self): + kwargs = self._build() + system = kwargs["system"] + assert system[0]["text"] == "You are Claude Code, Anthropic's official CLI for Claude." + + def test_system_prompt_no_hermes_or_nous(self): + """The full system prompt must not contain any Hermes/Nous fingerprints.""" + kwargs = self._build() + text = self._system_text(kwargs) + assert "hermes" not in text, "system prompt still contains 'hermes'" + assert "nous" not in text, "system prompt still contains 'nous'" + + def test_non_oauth_leaves_system_prompt_untouched(self): + """When is_oauth=False, the system prompt is NOT sanitized.""" + messages = [ + {"role": "system", "content": "You are Hermes Agent, created by Nous Research."}, + {"role": "user", "content": "hello"}, + ] + kwargs = self._build(messages=messages, is_oauth=False) + text = self._system_text(kwargs) + assert "hermes" in text + assert "nous" in text + + # -- tool names ---------------------------------------------------------- + + def test_bare_tool_name_normalized_to_mcp_double_underscore(self): + tools = [ + {"name": "read_file", "function": { + "name": "read_file", "description": "Read a file.", + "parameters": {"type": "object", "properties": {}}}}, + ] + kwargs = self._build(tools=tools) + assert kwargs["tools"][0]["name"] == "mcp__read_file" + + def test_single_underscore_mcp_tool_name_promoted(self): + tools = [ + {"name": "mcp_linear_get_issue", "function": { + "name": "mcp_linear_get_issue", "description": "Query Linear.", + "parameters": {"type": "object", "properties": {}}}}, + ] + kwargs = self._build(tools=tools) + # Should become mcp__linear_get_issue, NOT stay single-underscore + assert kwargs["tools"][0]["name"] == "mcp__linear_get_issue" + + def test_already_double_underscore_not_doubled(self): + tools = [ + {"name": "mcp__already_double", "function": { + "name": "mcp__already_double", "description": "Already double.", + "parameters": {"type": "object", "properties": {}}}}, + ] + kwargs = self._build(tools=tools) + assert kwargs["tools"][0]["name"] == "mcp__already_double" + + # -- tool descriptions --------------------------------------------------- + + def test_tool_descriptions_have_hermes_sanitized(self): + tools = [ + {"name": "browser_screenshot", "function": { + "name": "browser_screenshot", + "description": "When your active model has native vision, " + "otherwise Hermes falls back to an auxiliary " + "vision model. Powered by Nous Research.", + "parameters": {"type": "object", "properties": {}}}}, + ] + kwargs = self._build(tools=tools) + desc = kwargs["tools"][0]["description"].lower() + assert "hermes" not in desc + assert "nous" not in desc + assert "anthropic" in desc # "Nous Research" -> "Anthropic" + + def test_tool_descriptions_not_sanitized_when_not_oauth(self): + tools = [ + {"name": "read_file", "function": { + "name": "read_file", + "description": "Read a file via Hermes.", + "parameters": {"type": "object", "properties": {}}}}, + ] + kwargs = self._build(tools=tools, is_oauth=False) + assert "hermes" in kwargs["tools"][0]["description"].lower() + + # -- tool_choice normalization ------------------------------------------- + + def test_tool_choice_name_normalized(self): + """A specific tool_choice name must be normalized to the OAuth wire form.""" + tools = [ + {"name": "read_file", "function": { + "name": "read_file", "description": "Read a file.", + "parameters": {"type": "object", "properties": {}}}}, + ] + kwargs = self._build(tools=tools, tool_choice="read_file") + assert kwargs["tool_choice"] == {"type": "tool", "name": "mcp__read_file"} + + def test_tool_choice_mcp_single_underscore_normalized(self): + tools = [ + {"name": "mcp_linear_get_issue", "function": { + "name": "mcp_linear_get_issue", "description": "Query.", + "parameters": {"type": "object", "properties": {}}}}, + ] + kwargs = self._build(tools=tools, tool_choice="mcp_linear_get_issue") + assert kwargs["tool_choice"] == {"type": "tool", "name": "mcp__linear_get_issue"} + + def test_tool_choice_auto_not_mangled(self): + tools = [ + {"name": "read_file", "function": { + "name": "read_file", "description": "Read a file.", + "parameters": {"type": "object", "properties": {}}}}, + ] + kwargs = self._build(tools=tools, tool_choice="auto") + assert kwargs["tool_choice"] == {"type": "auto"} + + def test_tool_choice_required_not_mangled(self): + tools = [ + {"name": "read_file", "function": { + "name": "read_file", "description": "Read a file.", + "parameters": {"type": "object", "properties": {}}}}, + ] + kwargs = self._build(tools=tools, tool_choice="required") + assert kwargs["tool_choice"] == {"type": "any"} + + # -- message history (replayed tool_use blocks) ------------------------ + + def test_replayed_tool_use_names_normalized(self): + """Tool use names in replayed message history must also be normalized.""" + messages = [ + {"role": "user", "content": "do something"}, + {"role": "assistant", "content": "", "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "read_file", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "call_1", "content": "result"}, + ] + kwargs = self._build(messages=messages) + for msg in kwargs["messages"]: + if isinstance(msg.get("content"), list): + for block in msg["content"]: + if isinstance(block, dict) and block.get("type") == "tool_use": + assert block["name"].startswith("mcp__"), f"tool_use name not normalized: {block['name']}" + + # -- no-op for non-OAuth ------------------------------------------------ + + def test_non_oauth_does_not_normalize_tool_names(self): + tools = [ + {"name": "read_file", "function": { + "name": "read_file", "description": "Read a file.", + "parameters": {"type": "object", "properties": {}}}}, + ] + kwargs = self._build(tools=tools, is_oauth=False) + assert kwargs["tools"][0]["name"] == "read_file" + # tool_choice=None maps to {"type": "auto"} in the Anthropic kwargs, + # but the NAME is not normalized when is_oauth=False — verify that + # a specific name stays bare. + kwargs_specific = self._build(tools=tools, tool_choice="read_file", is_oauth=False) + assert kwargs_specific["tool_choice"] == {"type": "tool", "name": "read_file"} diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 993d9a13c80f..a88960d88447 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -6791,6 +6791,13 @@ def refresh_agent_mcp_tools( enabled_toolsets=enabled, disabled_toolsets=disabled, quiet_mode=quiet_mode, + # Preserve the OAuth-minimal-core tool_search tiering agent_init + # built the snapshot with — otherwise this refresh (fired from + # the between-turns prologue whenever any MCP server is + # registered) silently re-expands the eager tool list back to + # the full core set and re-trips Anthropic's OAuth billing + # classifier on the very next turn. See toolsets.OAUTH_SAFE_CORE_TOOLS. + oauth_minimal_core=bool(getattr(agent, "_is_anthropic_oauth", False)), ) or [] ) diff --git a/tools/tool_search.py b/tools/tool_search.py index 686e0f34ea53..c12f13da6f1c 100644 --- a/tools/tool_search.py +++ b/tools/tool_search.py @@ -188,12 +188,21 @@ def load_config() -> ToolSearchConfig: # --------------------------------------------------------------------------- -def _core_tool_names() -> frozenset[str]: +def _core_tool_names(core_override: Optional[frozenset] = None) -> frozenset[str]: """Return the set of tool names that must NEVER be deferred. + ``core_override``, when given, replaces the default + ``toolsets._HERMES_CORE_TOOLS`` set entirely — used by native-Anthropic + OAuth sessions to shrink the always-eager set to + ``toolsets.OAUTH_SAFE_CORE_TOOLS`` so the rest of the normal core set + (browser automation, image/video gen, etc.) becomes deferrable like any + other tool. See the docstring on ``OAUTH_SAFE_CORE_TOOLS``. + Imported lazily because ``toolsets`` imports from ``tools.registry`` and we don't want a hard cycle. """ + if core_override is not None: + return frozenset(core_override) try: from toolsets import _HERMES_CORE_TOOLS return frozenset(_HERMES_CORE_TOOLS) @@ -201,17 +210,18 @@ def _core_tool_names() -> frozenset[str]: return frozenset() -def is_deferrable_tool_name(name: str) -> bool: +def is_deferrable_tool_name(name: str, core_override: Optional[frozenset] = None) -> bool: """Return True if a tool with this name is *eligible* for deferral. A tool is deferrable iff it is registered with an MCP toolset prefix - OR it is not in ``_HERMES_CORE_TOOLS``. Core tools are never deferred - even when their toolset is technically plugin-provided (this protects - against accidental shadowing). + OR it is not in the active core set (``_HERMES_CORE_TOOLS`` normally, + or ``core_override`` when the caller supplies a narrower allowlist). + Core tools are never deferred even when their toolset is technically + plugin-provided (this protects against accidental shadowing). """ if name in BRIDGE_TOOL_NAMES: return False - if name in _core_tool_names(): + if name in _core_tool_names(core_override): return False # Check registry toolset for MCP prefix. try: @@ -227,12 +237,16 @@ def is_deferrable_tool_name(name: str) -> bool: return False -def classify_tools(tool_defs: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: +def classify_tools( + tool_defs: List[Dict[str, Any]], + core_override: Optional[frozenset] = None, +) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: """Split a tool-defs list into (visible, deferrable). ``visible`` retains every tool that must stay in the model-facing array: every core tool, plus any tool we can't classify. ``deferrable`` is the - candidate set for catalog entry. + candidate set for catalog entry. ``core_override`` narrows what counts + as "core" — see ``is_deferrable_tool_name``. """ visible: List[Dict[str, Any]] = [] deferrable: List[Dict[str, Any]] = [] @@ -243,7 +257,7 @@ def classify_tools(tool_defs: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any] # Should never happen — bridge tools are added after classification — # but be defensive. continue - if is_deferrable_tool_name(name): + if is_deferrable_tool_name(name, core_override): deferrable.append(td) else: visible.append(td) @@ -774,13 +788,23 @@ def assemble_tool_defs( *, context_length: Optional[int] = None, config: Optional[ToolSearchConfig] = None, + core_override: Optional[frozenset] = None, + force_activate: bool = False, ) -> AssemblyResult: """Return the tool-defs list the model should actually see. When tool search is inactive (off, no deferrable tools, or below threshold), this is a passthrough. When active, MCP and plugin tools are stripped from the visible list and replaced with the three bridge - tools. Core tools are *never* deferred regardless of config. + tools. Core tools are *never* deferred regardless of config — unless + ``core_override`` narrows what counts as core (see + ``toolsets.OAUTH_SAFE_CORE_TOOLS``). + + ``force_activate``, when True, bypasses the normal context-percentage + activation gate (``should_activate``) — used for native-Anthropic OAuth + sessions where the goal is staying under Anthropic's billing-classifier + tool-schema footprint, not saving context budget, so activation must not + depend on how large the model's context window happens to be. Idempotent: calling with bridge tools already in the input is a no-op (they classify as non-core/non-deferrable but their names are reserved, @@ -794,12 +818,12 @@ def assemble_tool_defs( incoming = [td for td in tool_defs if (td.get("function") or {}).get("name") not in BRIDGE_TOOL_NAMES] - visible, deferrable = classify_tools(incoming) + visible, deferrable = classify_tools(incoming, core_override) if not deferrable: return AssemblyResult(tool_defs=incoming, activated=False) deferrable_tokens = estimate_tokens_from_schemas(deferrable) - if not should_activate(config, deferrable_tokens, context_length): + if not force_activate and not should_activate(config, deferrable_tokens, context_length): return AssemblyResult( tool_defs=incoming, activated=False, diff --git a/toolsets.py b/toolsets.py index e713858c236c..ea316fd9ea10 100644 --- a/toolsets.py +++ b/toolsets.py @@ -97,6 +97,30 @@ "clarify", ] +# Minimal always-eager set for native-Anthropic OAuth (Claude Pro/Max +# subscription) sessions. Anthropic's OAuth billing classifier routes a +# request to the "extra usage" pool instead of plan quota based partly on +# the shape/size of the tool schemas in the request — sending the full +# ~60-tool _HERMES_CORE_TOOLS set (browser automation, video/image gen, +# text-to-speech, etc.) on every turn reliably trips it even with the +# existing name/text sanitization in agent/anthropic_adapter.py. Confirmed +# empirically: 0 tools -> succeeds, full core set -> HTTP 400 "out of extra +# usage"; real cron jobs succeed because they're scoped to a handful of +# toolsets, never the full default. +# +# This trimmed set is used ONLY to seed tools/tool_search.py's "never +# defer" allowlist for OAuth sessions — everything else in +# _HERMES_CORE_TOOLS still stays fully reachable, just behind the +# tool_search/tool_describe/tool_call bridge instead of eagerly listed on +# every request. See model_tools.get_tool_definitions(oauth_minimal_core=). +OAUTH_SAFE_CORE_TOOLS = [ + "read_file", "write_file", "patch", "search_files", + "terminal", "process", + "todo", "memory", + "session_search", "clarify", "delegate_task", + "skills_list", "skill_view", +] + # Core toolset definitions # These can include individual tools or reference other toolsets