Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,10 @@ def _apply_claude_code_identity(system, anthropic_tools, anthropic_messages, to_
text = _OAUTH_SLUG_PATTERN.sub("claude-code", text)
block["text"] = _apply_oauth_prose_aliases(text)
for tool in anthropic_tools or []:
# Server tools carry a versioned ``type`` and a canonical name Anthropic itself intercepts;
# renaming one turns it back into an ordinary client tool and breaks execution.
if "type" in tool:
continue
if "name" in tool:
tool["name"] = to_wire(tool["name"])
if isinstance(tool.get("description"), str):
Expand Down Expand Up @@ -552,7 +556,7 @@ def build_anthropic_kwargs(
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)
anthropic_tools = convert_tools_to_anthropic(tools) if tools else []
anthropic_tools = convert_tools_to_anthropic(tools, base_url=base_url) 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):
Expand All @@ -574,8 +578,11 @@ def build_anthropic_kwargs(
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.
# Server tools are the exception β€” they keep their canonical name in tools[] (see
# _apply_claude_code_identity), so forcing one must name it verbatim.
forced_server_tool = any("type" in t and t.get("name") == tool_choice for t in anthropic_tools)
kwargs["tool_choice"] = _TOOL_CHOICE_MAP.get(tool_choice) or {
"type": "tool", "name": to_wire(tool_choice) if to_wire else tool_choice
"type": "tool", "name": to_wire(tool_choice) if to_wire and not forced_server_tool else tool_choice
}
# Map reasoning_config to Anthropic's thinking parameter. Claude 4.6+ models use adaptive thinking +
# output_config.effort. Older models use manual thinking with budget_tokens. MiniMax Anthropic-compat
Expand Down
9 changes: 5 additions & 4 deletions agent/anthropic_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ def _normalized_lower(base_url) -> str:


def _is_third_party_anthropic_endpoint(base_url: str | None) -> bool:
"""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
"""Any endpoint not hosted on anthropic.com or a subdomain of it (own x-api-key keys; skip
OAuth detection). Hostname match, not substring, so ``api.anthropic.com.example`` and
``proxy.example/api.anthropic.com`` are third-party. No base_url = direct Anthropic API."""
normalized = _normalize_base_url_text(base_url)
return bool(normalized) and not base_url_host_matches(normalized, "anthropic.com")


def _is_kimi_coding_endpoint(base_url: str | None) -> bool:
Expand Down
72 changes: 69 additions & 3 deletions agent/anthropic_message_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,66 @@ def _normalize_tool_input_schema(schema: Any) -> Dict[str, Any]:
return normalized


def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:
# Server-only tools already reported as withheld from a compatible third-party endpoint, as
# ``(tool_name, base_url)``. Tool conversion runs on every request, so the diagnosis is emitted
# once per process rather than once per turn. A duplicate line from a benign race is harmless.
_reported_third_party_server_tool_drops: set = set()


def _report_third_party_server_tool_drop(name: str, base_url: str | None) -> None:
"""Log, once per process, a server-only tool withheld from a proxy endpoint."""
key = (name, str(base_url or ""))
if key in _reported_third_party_server_tool_drops:
return
_reported_third_party_server_tool_drops.add(key)
logger.warning(
"Tool %r only runs inside Anthropic's own Messages API; %s is a compatible third-party endpoint, "
"so the tool is omitted from these requests and the model cannot call it. Select a backend this "
"endpoint supports (`hermes tools`) to restore this capability.",
name, base_url or "the configured endpoint",
)


def convert_tools_to_anthropic(tools: List[Dict], base_url: str | None = None) -> 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."""
warning (Anthropic hard-400s on them); ``cache_control`` on the OpenAI tool dict is forwarded.
A function schema may carry a generic ``_hermes_server_tool`` binding: on Anthropic's native
endpoint a matching binding replaces the client-side function definition with a copy of its
provider-native spec. Compatible third-party endpoints omit server-only tools because neither
the endpoint nor Hermes's local dispatcher can execute them."""
result = []
seen_names: set = set()
for t in tools or []:
fn = t.get("function", {})
name = fn.get("name", "")
if (server_binding := fn.get("_hermes_server_tool")) is not None:
server_spec = (
server_binding.get("definition")
if isinstance(server_binding, dict) and server_binding.get("api_mode") == "anthropic_messages"
else None
)
if isinstance(server_spec, dict) and server_spec.get("type"):
if _is_third_party_anthropic_endpoint(base_url):
# The endpoint speaks the Messages API but is not assumed to host Anthropic's
# server-side tools, so the tool is withheld. Say so: the operator enabled this
# capability and would otherwise watch it vanish from the request with no
# diagnosis anywhere.
_report_third_party_server_tool_drop(name, base_url)
else:
server_name = server_spec.get("name", "")
if server_name and server_name in seen_names:
logger.warning(
"convert_tools_to_anthropic: duplicate tool name '%s' β€” dropping second occurrence",
server_name,
)
else:
result.append(copy.deepcopy(server_spec))
if server_name:
seen_names.add(server_name)
# Server-only bindings never degrade to local function tools: a third-party
# Anthropic-compatible endpoint cannot execute the native definition, while the selected
# local backend is also intentionally non-executable.
continue
# Defensive dedup: Anthropic rejects requests with duplicate tool names. Upstream injection paths
# already dedup, but this guard converts a hard API failure into a warning. See: #18478
if name and name in seen_names:
Expand Down Expand Up @@ -302,9 +354,23 @@ def _replay_image(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return {"type": "image", "source": src} if isinstance(src, dict) else None


def _replay_server_tool_use(b: Dict[str, Any]) -> Dict[str, Any]:
out = {
"type": "server_tool_use", "id": b.get("id", ""), "name": b.get("name", ""),
"input": copy.deepcopy(b.get("input", {})),
}
return _carry_cache_control(out, b, copy=True)


def _replay_server_tool_result(b: Dict[str, Any]) -> Dict[str, Any]:
out = {"type": b["type"], "tool_use_id": b.get("tool_use_id", ""), "content": copy.deepcopy(b.get("content"))}
return _carry_cache_control(out, b, copy=True)


_REPLAY_SANITIZERS = {
"text": _replay_text, "thinking": _replay_thinking, "redacted_thinking": _replay_redacted_thinking,
"tool_use": _replay_tool_use, "image": _replay_image,
"tool_use": _replay_tool_use, "image": _replay_image, "server_tool_use": _replay_server_tool_use,
"web_search_tool_result": _replay_server_tool_result, "web_fetch_tool_result": _replay_server_tool_result,
}


Expand Down
2 changes: 2 additions & 0 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -1320,6 +1320,8 @@ class _LoopState:
interrupted: bool = False
failed: bool = False
codex_ack_continuations: int = 0
# Consecutive Anthropic ``pause_turn`` continuations; any other response resets it.
anthropic_pause_continuations: int = 0
length_continue_retries: int = 0
# Per-turn backstop for the refunding restarts (redirect / rebuilt-for-fallback).
# Unlike ``retry_count`` (rebound to 0 each iteration) this accumulates for the whole
Expand Down
46 changes: 40 additions & 6 deletions agent/transports/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

_MCP_PREFIX = "mcp__"
_THINKING_TYPES = ("thinking", "redacted_thinking")
# Blocks of an Anthropic-executed server tool (native web search / fetch).
_SERVER_TOOL_BLOCK_TYPES = ("server_tool_use", "web_search_tool_result", "web_fetch_tool_result")


def _unprefix_oauth_tool_name(name: str) -> str:
Expand Down Expand Up @@ -35,7 +37,7 @@ class AnthropicTransport(ProviderTransport):

_STOP_REASON_MAP = {
"end_turn": "stop", "tool_use": "tool_calls", "max_tokens": "length", "stop_sequence": "stop",
"refusal": "content_filter", "model_context_window_exceeded": "length",
"refusal": "content_filter", "model_context_window_exceeded": "length", "pause_turn": "pause_turn",
}

@property
Expand All @@ -50,15 +52,15 @@ def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> Any:
def convert_tools(self, tools: List[Dict[str, Any]]) -> Any:
"""Convert OpenAI tool schemas to Anthropic input_schema format."""
from agent.anthropic_message_convert import convert_tools_to_anthropic
return convert_tools_to_anthropic(tools)
return convert_tools_to_anthropic(self.project_tools(tools) or [])

def build_kwargs(
self, model: str, messages: List[Dict[str, Any]], tools: Optional[List[Dict[str, Any]]] = None, **params,
) -> Dict[str, Any]:
"""Build Anthropic messages.create() kwargs (converts messages and tools internally)."""
from agent.anthropic_adapter import build_anthropic_kwargs
return build_anthropic_kwargs(
model=model, messages=messages, tools=tools,
model=model, messages=messages, tools=self.project_tools(tools),
**{key: params.get(key, default) for key, default in _BUILD_KWARG_DEFAULTS.items()},
)

Expand All @@ -68,6 +70,14 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
from agent.anthropic_message_convert import _sanitize_replay_block, _to_plain_data
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
text_parts, reasoning_parts, reasoning_details, tool_calls = [], [], [], []
citation_sources, seen_citation_urls = [], set()

def _add_citation_source(url: Any, title: Any = None) -> None:
if not isinstance(url, str) or not url or url in seen_citation_urls:
return
seen_citation_urls.add(url)
citation_sources.append((" ".join(str(title or url).split()), url))

# Anthropic signs each thinking block against the blocks PRECEDING it; when thinking
# interleaves with tool_use the parallel lists lose that order and replay -> HTTP 400.
ordered_blocks = []
Expand All @@ -79,6 +89,21 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
ordered_blocks.append(clean_block)
if block.type == "text":
text_parts.append(block.text)
# Citations arrive as structured metadata, not inline Markdown: ordered_blocks keeps them for
# replay, and a compact source list is rendered into the neutral text channel below so
# CLI/gateway users do not lose the URLs.
for citation in getattr(block, "citations", None) or []:
citation_dict = _to_plain_data(citation)
if not isinstance(citation_dict, dict):
continue
url = citation_dict.get("url")
_add_citation_source(url, citation_dict.get("title") or citation_dict.get("cited_text") or url)
elif block.type == "web_fetch_tool_result":
# Fetch citations may use document-relative locations with no URL on the text block; the
# server result stays the authoritative source for the fetched URL, so surface it as a fallback.
result_content = (clean_block or {}).get("content")
if isinstance(result_content, dict):
_add_citation_source(result_content.get("url"), result_content.get("title"))
elif block.type in _THINKING_TYPES:
if block.type == "thinking":
reasoning_parts.append(block.thinking)
Expand All @@ -94,12 +119,21 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
stop_details = _to_plain_data(getattr(response, "stop_details", None))
if stop_details is not None:
provider_data["stop_details"] = stop_details
# Ordered channel only for the shape the parallel lists reconstruct wrongly.
# Ordered channel only for the shapes the parallel lists cannot reconstruct: signed thinking
# interleaved with tool_use, and server-tool blocks, which no parallel list carries at all.
signed = any(b.get("type") in _THINKING_TYPES and (b.get("signature") or b.get("data")) for b in ordered_blocks)
if signed and any(b.get("type") == "tool_use" for b in ordered_blocks):
if (signed and any(b.get("type") == "tool_use" for b in ordered_blocks)) or any(
b.get("type") in _SERVER_TOOL_BLOCK_TYPES for b in ordered_blocks
):
provider_data["anthropic_content_blocks"] = ordered_blocks
content = "\n".join(text_parts) if text_parts else None
if citation_sources:
sources = "Sources:\n" + "\n".join(
f"- {url}" if title == url else f"- {title}: {url}" for title, url in citation_sources
)
content = f"{content}\n\n{sources}" if content else sources
return NormalizedResponse(
content="\n".join(text_parts) if text_parts else None, tool_calls=tool_calls or None,
content=content, tool_calls=tool_calls or None,
finish_reason=self.response_finish_reason(response),
reasoning="\n\n".join(reasoning_parts) if reasoning_parts else None, usage=None,
provider_data=provider_data or None,
Expand Down
70 changes: 70 additions & 0 deletions agent/transports/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,77 @@
-> normalize_response), NOT client construction, streaming, credentials, caching, interrupts
or retries β€” those stay on AIAgent."""

import logging
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional

from agent.transports.types import NormalizedResponse

logger = logging.getLogger(__name__)

_HERMES_SERVER_TOOL_KEY = "_hermes_server_tool"

# Server-only drops already reported, as ``(tool_name, required_api_mode, active_api_mode)``. Projection runs
# on every request, so without this the same diagnosis would repeat every turn. A duplicate line from a
# benign race is harmless; a missing one is not, so no lock is taken.
_reported_server_tool_drops: set = set()


def report_server_tool_drop(name: str, required_api_mode: Any, api_mode: str) -> None:
"""Log, once per process, that a server-only tool is being withheld.

Dropping a tool the operator enabled is a silent capability loss: ``hermes tools`` lists it and startup
counts it, yet the model never sees it and so can never surface the handler's own error. Naming the
mismatch and the remedy is the only diagnostic this path has.
"""
key = (name, str(required_api_mode), api_mode)
if key in _reported_server_tool_drops:
return
_reported_server_tool_drops.add(key)
logger.warning(
"Tool %r runs inside the provider's API and is bound to api_mode %r, but the active model uses %r β€” "
"it is omitted from these requests and the model cannot call it. Select a backend the active model "
"supports (`hermes tools`) to restore this capability.",
name, required_api_mode, api_mode,
)


def project_tools_for_transport(
tools: Optional[List[Dict[str, Any]]], api_mode: str,
) -> Optional[List[Dict[str, Any]]]:
"""Project logical Hermes tools onto one provider transport.

A function definition carrying ``_hermes_server_tool`` is server-only: it may be advertised solely to the
api_mode named by that binding. The target transport consumes the binding and emits its provider-native
tool definition; every other transport omits the tool entirely. This keeps Hermes-internal metadata off
the wire and prevents fallbacks from exposing a client function whose handler cannot execute locally.
Each omission is reported once (see :func:`report_server_tool_drop`), so an operator whose model cannot
run a tool they enabled is told why rather than losing the capability without a trace.
Ordinary tools retain their original objects so the common path does not copy the tool list per request.
"""
if tools is None:
return None
projected: List[Dict[str, Any]] = []
changed = False
for tool in tools:
function = tool.get("function") if isinstance(tool, dict) else None
if not isinstance(function, dict) or _HERMES_SERVER_TOOL_KEY not in function:
projected.append(tool)
continue
binding = function.get(_HERMES_SERVER_TOOL_KEY)
if isinstance(binding, dict) and binding.get("api_mode") == api_mode:
projected.append(tool)
else:
# A malformed or foreign binding is deliberately omitted: forwarding it either leaks internal
# metadata or exposes an unexecutable tool.
changed = True
report_server_tool_drop(
function.get("name", "<unnamed>"),
binding.get("api_mode") if isinstance(binding, dict) else binding,
api_mode,
)
return projected if changed else tools


class ProviderTransport(ABC):
"""Base class for provider-specific format conversion and normalization."""
Expand Down Expand Up @@ -51,3 +117,7 @@ def extract_cache_stats(self, response: Any) -> Optional[Dict[str, int]]:
def map_finish_reason(self, raw_reason: str) -> str:
"""Map a provider stop reason via ``_STOP_REASON_MAP`` (unknown -> 'stop'); passthrough when no map."""
return raw_reason if self._STOP_REASON_MAP is None else self._STOP_REASON_MAP.get(raw_reason, "stop")

def project_tools(self, tools: Optional[List[Dict[str, Any]]]) -> Optional[List[Dict[str, Any]]]:
"""Return only tool definitions executable through this transport."""
return project_tools_for_transport(tools, self.api_mode)
Loading