Files
hermes-agent/tools/managed_gateway_identity.py
Siddharth Balyan 626eeec287 fix(connectors): log each connector outcome after the body is checked
A 200 whose body failed used to log a DEBUG success line before the body
was read: a failed call inside the execute envelope, or a body that does
not parse, had no WARNING carrying the gateway request id.

- The gateway client's _request returns the 2xx reply unchecked, with its
  request id and route. Each caller parses it through _checked, which
  writes the one outcome line: a failed parse or a failed execute call is
  a WARNING with the request id, success is DEBUG.
- The portal client logs a 2xx in _parse for the same reason.
- log_connector_outcome treats a line as a failure when it carries a code,
  not by status, so the portal tools route's 304 (a cache refresh) stays
  DEBUG.
2026-10-13 01:35:42 +05:30

136 lines
5.0 KiB
Python

"""Caller identity for requests to the first-party gateways: who is calling, and which turn asked.
``user_agent()`` goes on every request. The correlation headers name the conversation, turn and tool
call, so they go only to connector routes and only when the profile has opted in to sending
telemetry. The gateway strips every ``x-nous-`` header before it calls a vendor.
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass
from typing import Mapping, Optional
logger = logging.getLogger(__name__)
__all__ = [
"CallRef",
"connector_headers",
"correlation_headers",
"log_connector_outcome",
"request_surface",
"response_request_id",
"user_agent",
]
SESSION_HEADER = "x-nous-hermes-session"
TURN_HEADER = "x-nous-hermes-turn"
TOOL_CALL_HEADER = "x-nous-hermes-tool-call"
REQUEST_ID_HEADER = "x-request-id"
# sys.platform -> the shared-metrics OS family.
_OS_FAMILY = {"darwin": "macos", "linux": "linux", "win32": "windows"}
# The account route answers 404 with this code until the vendor has created the account.
_EXPECTED_MISSES = frozenset({"connection_not_found"})
@dataclass(frozen=True)
class CallRef:
"""The model's ids for the call a request serves. Absent fields are not sent."""
turn_id: Optional[str] = None
tool_call_id: Optional[str] = None
def _platform_name() -> str:
"""The session's platform: the running turn's agent platform, else the bound session vars.
Off a turn (a desktop page action, an account operation thread) only the session vars exist.
The classic CLI and one-shot runs bind neither, so they read as ``cli``."""
from agent.relay_runtime import current_turn
from gateway.session_context import get_session_env
from tools.connectors.gateway.config import session_platform
from utils import is_truthy_value
turn = current_turn()
if turn is not None and turn.lease.platform:
return turn.lease.platform
if platform := session_platform():
return platform
return "cron" if is_truthy_value(get_session_env("HERMES_CRON_SESSION", "")) else "cli"
def request_surface() -> str:
from hermes_cli.observability.shared_metrics_contract import execution_surface
surface = execution_surface({"execution_surface": _platform_name()})
return "other" if surface == "unknown" else surface
def user_agent() -> str:
from hermes_cli.version_info import get_version_info
from hermes_platform.host.facts import os_family
os_name = _OS_FAMILY.get(os_family(), "unknown")
return f"hermes-agent/{get_version_info().base_version} ({request_surface()}; {os_name})"
def _send_consented() -> bool:
from hermes_cli.config import read_raw_config_readonly
from hermes_cli.observability.shared_metrics_send_config import resolve_send_config
return resolve_send_config(read_raw_config_readonly() or {}).send is True
def _conversation_id() -> Optional[str]:
from agent.portal_tags import get_conversation_context
from gateway.session_context import get_session_env
return get_conversation_context() or get_session_env("HERMES_SESSION_ID", "") or None
def correlation_headers(call: Optional[CallRef] = None) -> dict[str, str]:
"""``x-request-id`` (fresh per HTTP attempt) and the conversation, turn and tool call ids, or
``{}`` when the profile has not opted in to sending telemetry."""
if not _send_consented():
return {}
headers = {REQUEST_ID_HEADER: str(uuid.uuid4())}
ids = {
SESSION_HEADER: _conversation_id(),
TURN_HEADER: call.turn_id if call else None,
TOOL_CALL_HEADER: call.tool_call_id if call else None,
}
headers.update({name: str(value) for name, value in ids.items() if value})
return headers
def connector_headers(call: Optional[CallRef] = None) -> dict[str, str]:
"""Every identity header for one connector HTTP attempt. Call it once per attempt."""
return {"User-Agent": user_agent(), **correlation_headers(call)}
def response_request_id(response: object) -> Optional[str]:
headers = getattr(response, "headers", None)
if not isinstance(headers, Mapping):
return None
value = headers.get(REQUEST_ID_HEADER) or headers.get("X-Request-Id")
return str(value) if value else None
def log_connector_outcome(
route: str, status: Optional[int], *, code: Optional[str] = None, request_id: Optional[str] = None,
call: Optional[CallRef] = None,
) -> None:
"""One line per connector HTTP outcome: failures at WARNING, success and expected misses at DEBUG.
The ``code`` makes a line a failure, not the status: a 200 whose body failed carries one, and a
304 that refreshed a cached list does not."""
level = logging.WARNING if code is not None and code not in _EXPECTED_MISSES else logging.DEBUG
logger.log(
level, "connector %s %s %s request_id=%s dispatch=%s",
route, status if status is not None else "transport", code or "-", request_id or "-",
(call.tool_call_id if call else None) or "-",
)