mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Stacked on #49003. That PR added always-on metadata (method/path/status/ latency + WS lifecycle) to the gui surface. This adds the heavy diagnostic tier — actual HTTP request bodies and PTY/WebSocket frames — for the hard dashboard/TUI bugs where metadata alone isn't enough. Body content can carry conversation data, so this is opt-in and built to be structurally incapable of leaking into a shared debug report (see #22016): - New config logging.capture_bodies (default false), surfaced in the dashboard / hermes tools config UI via _SCHEMA_OVERRIDES with a warning description. - When enabled, bodies go to a SEPARATE gui_bodies.log written by a dedicated logger (hermes_body_capture, propagate=False) that is deliberately NOT a member of any COMPONENT_PREFIXES. Four structural guarantees, all tested: 1. not under any component prefix -> never lands in gui.log / agent.log 2. not in hermes_cli/logs.py LOG_FILES -> not tailable via --- ~/.hermes/logs/agent.log (last 50) --- 2026-06-19 18:48:30,316 INFO [20260619_173001_f45949] agent.conversation_loop: API call #4: model=anthropic/claude-opus-4.8 provider=openrouter in=288583 out=529 total=289112 latency=10.6s cache=284912/288583 (99%) 2026-06-19 18:48:30,318 INFO [20260619_173001_f45949] agent.conversation_loop: Turn ended: reason=text_response(finish_reason=stop) model=anthropic/claude-opus-4.8 api_calls=4/16 budget=4/16 tool_turns=110 last_msg_role=assistant response_len=1474 session=20260619_173001_f45949 2026-06-19 18:48:30,325 INFO [20260619_173001_f45949] run_agent: OpenAI client closed (agent_close, shared=True, tcp_force_closed=0) thread=bg-review:6349795328 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:48:30,652 INFO run_agent: OpenAI client closed (stream_request_complete, shared=False, tcp_force_closed=0) thread=Thread-747 (_call):6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:48:30,653 INFO [20260619_153431_51fd01] agent.conversation_loop: API call #139: model=anthropic/claude-opus-4.8 provider=openrouter in=243960 out=991 total=244951 latency=11.5s cache=242196/243960 (99%) 2026-06-19 18:48:31,348 INFO [20260619_153431_51fd01] agent.tool_executor: tool terminal completed (0.69s, 161 chars) 2026-06-19 18:48:31,384 INFO run_agent: OpenAI client created (chat_completion_stream_request, shared=False) thread=Thread-749 (_call):6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:48:51,510 INFO run_agent: OpenAI client closed (stream_request_complete, shared=False, tcp_force_closed=0) thread=Thread-749 (_call):6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:48:51,511 INFO [20260619_153431_51fd01] agent.conversation_loop: API call #140: model=anthropic/claude-opus-4.8 provider=openrouter in=245038 out=1783 total=246821 latency=20.1s cache=243477/245038 (99%) 2026-06-19 18:48:52,215 INFO [20260619_153431_51fd01] agent.tool_executor: tool terminal completed (0.70s, 153 chars) 2026-06-19 18:48:52,245 INFO run_agent: OpenAI client created (chat_completion_stream_request, shared=False) thread=Thread-751 (_call):6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:48:59,489 INFO run_agent: OpenAI client closed (stream_request_complete, shared=False, tcp_force_closed=0) thread=Thread-751 (_call):6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:48:59,490 INFO [20260619_153431_51fd01] agent.conversation_loop: API call #141: model=anthropic/claude-opus-4.8 provider=openrouter in=246873 out=493 total=247366 latency=7.3s cache=244127/246873 (99%) 2026-06-19 18:49:13,666 INFO [20260619_153431_51fd01] agent.tool_executor: tool terminal completed (14.17s, 979 chars) 2026-06-19 18:49:13,692 INFO run_agent: OpenAI client created (chat_completion_stream_request, shared=False) thread=Thread-753 (_call):6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:49:22,930 INFO run_agent: OpenAI client closed (stream_request_complete, shared=False, tcp_force_closed=0) thread=Thread-753 (_call):6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:49:22,932 INFO [20260619_153431_51fd01] agent.conversation_loop: API call #142: model=anthropic/claude-opus-4.8 provider=openrouter in=247686 out=548 total=248234 latency=9.3s cache=245109/247686 (99%) 2026-06-19 18:49:23,254 INFO [20260619_153431_51fd01] agent.tool_executor: tool patch completed (0.10s, 1394 chars) 2026-06-19 18:49:23,287 INFO run_agent: OpenAI client created (chat_completion_stream_request, shared=False) thread=Thread-762 (_call):6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:49:26,661 INFO run_agent: OpenAI client closed (stream_request_complete, shared=False, tcp_force_closed=0) thread=Thread-762 (_call):6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:49:26,662 INFO [20260619_153431_51fd01] agent.conversation_loop: API call #143: model=anthropic/claude-opus-4.8 provider=openrouter in=248814 out=104 total=248918 latency=3.4s cache=246934/248814 (99%) 2026-06-19 18:49:27,958 INFO [20260619_153431_51fd01] agent.tool_executor: tool terminal completed (1.29s, 14487 chars) 2026-06-19 18:49:27,984 INFO run_agent: OpenAI client created (chat_completion_stream_request, shared=False) thread=Thread-764 (_call):6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:49:43,991 INFO run_agent: OpenAI client closed (stream_request_complete, shared=False, tcp_force_closed=0) thread=Thread-764 (_call):6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:49:43,992 INFO [20260619_153431_51fd01] agent.conversation_loop: API call #144: model=anthropic/claude-opus-4.8 provider=openrouter in=255375 out=938 total=256313 latency=16.0s cache=247771/255375 (97%) 2026-06-19 18:49:44,087 INFO [20260619_153431_51fd01] agent.conversation_loop: Turn ended: reason=text_response(finish_reason=stop) model=anthropic/claude-opus-4.8 api_calls=36/90 budget=31/90 tool_turns=129 last_msg_role=assistant response_len=2300 session=20260619_153431_51fd01 2026-06-19 18:49:44,112 INFO run_agent: OpenAI client created (agent_init, shared=True) thread=bg-review:6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:49:44,454 INFO [20260619_153431_51fd01] agent.turn_context: conversation turn: session=20260619_153431_51fd01 model=anthropic/claude-opus-4.8 provider=openrouter platform=cli history=310 msg='Review the conversation above and update the skill library. Be ACTIVE — most ses...' 2026-06-19 18:49:44,573 INFO run_agent: OpenAI client created (chat_completion_stream_request, shared=False) thread=Thread-765 (_call):6150942720 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:49:54,258 INFO run_agent: OpenAI client closed (stream_request_complete, shared=False, tcp_force_closed=0) thread=Thread-765 (_call):6150942720 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:49:54,259 INFO [20260619_153431_51fd01] agent.conversation_loop: API call #1: model=anthropic/claude-opus-4.8 provider=openrouter in=258322 out=423 total=258745 latency=9.8s cache=248822/258322 (96%) 2026-06-19 18:49:54,360 INFO [20260619_153431_51fd01] agent.tool_executor: tool skills_list completed (0.10s, 21152 chars) 2026-06-19 18:49:54,383 INFO run_agent: OpenAI client created (chat_completion_stream_request, shared=False) thread=Thread-766 (_call):6150942720 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:50:02,705 INFO run_agent: OpenAI client closed (stream_request_complete, shared=False, tcp_force_closed=0) thread=Thread-766 (_call):6150942720 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:50:02,706 INFO [20260619_153431_51fd01] agent.conversation_loop: API call #2: model=anthropic/claude-opus-4.8 provider=openrouter in=266258 out=313 total=266571 latency=8.3s cache=258320/266258 (97%) 2026-06-19 18:50:02,814 INFO [20260619_153431_51fd01] agent.tool_executor: tool skill_view completed (0.11s, 111769 chars) 2026-06-19 18:50:02,836 INFO [20260619_153431_51fd01] tools.tool_result_storage: Persisted large tool result: skill_view (toolu_01G7Zvw8ttjsUkomENppFu5T, 111769 chars -> /var/folders/p5/nqn3gs293rv3wtvf01pl9_vr0000gn/T/hermes-results/toolu_01G7Zvw8ttjsUkomENppFu5T.txt) 2026-06-19 18:50:02,861 INFO run_agent: OpenAI client created (chat_completion_stream_request, shared=False) thread=Thread-769 (_call):6150942720 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:50:12,687 INFO run_agent: OpenAI client closed (stream_request_complete, shared=False, tcp_force_closed=0) thread=Thread-769 (_call):6150942720 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:50:12,688 INFO [20260619_153431_51fd01] agent.conversation_loop: API call #3: model=anthropic/claude-opus-4.8 provider=openrouter in=267367 out=386 total=267753 latency=9.8s cache=258694/267367 (97%) 2026-06-19 18:50:12,749 INFO [20260619_153431_51fd01] agent.tool_executor: tool skill_view completed (0.06s, 22856 chars) 2026-06-19 18:50:12,776 INFO run_agent: OpenAI client created (chat_completion_stream_request, shared=False) thread=Thread-770 (_call):6150942720 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:50:18,989 INFO [20260619_153431_51fd01] agent.turn_context: conversation turn: session=20260619_153431_51fd01 model=anthropic/claude-opus-4.8 provider=openrouter platform=cli history=310 msg='yes' 2026-06-19 18:50:19,032 INFO run_agent: OpenAI client created (chat_completion_stream_request, shared=False) thread=Thread-772 (_call):12901707776 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:50:21,530 INFO run_agent: OpenAI client closed (stream_request_complete, shared=False, tcp_force_closed=0) thread=Thread-770 (_call):6150942720 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:50:21,531 INFO [20260619_153431_51fd01] agent.conversation_loop: API call #4: model=anthropic/claude-opus-4.8 provider=openrouter in=276591 out=415 total=277006 latency=8.8s cache=266515/276591 (96%) 2026-06-19 18:50:21,585 WARNING [20260619_153431_51fd01] agent.tool_executor: Tool skill_view returned error (0.05s): {"success": false, "error": "File 'references/stacked-feature-prs.md' not found in skill 'incremental-architecture-refactor'.", "available_files": {}, "hint": "Use one of the available file paths list 2026-06-19 18:50:21,613 INFO run_agent: OpenAI client created (chat_completion_stream_request, shared=False) thread=Thread-773 (_call):6150942720 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:50:36,019 INFO run_agent: OpenAI client closed (stream_request_complete, shared=False, tcp_force_closed=0) thread=Thread-772 (_call):12901707776 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:50:36,020 INFO [20260619_153431_51fd01] agent.conversation_loop: API call #145: model=anthropic/claude-opus-4.8 provider=openrouter in=256317 out=997 total=257314 latency=17.0s cache=256311/256317 (100%) 3. not in debug.py _capture_default_log_snapshots() -> NEVER uploaded by ⚠️ This will upload the following to a public paste service: • System info (OS, Python version, Hermes version, provider, which API keys are configured — NOT the actual keys) • Recent log lines (agent.log, errors.log, gateway.log, desktop.log — may contain conversation fragments and file paths) • Full agent.log, gateway.log, and desktop.log (up to 512 KB each — likely contains conversation content, tool outputs, and file paths) Pastes auto-delete after 6 hours. Collecting debug report... Uploading... Debug report uploaded: Report https://paste.rs/nnfZj (failed to upload: agent.log: Failed to upload to any paste service: paste.rs: HTTP Error 500: Internal Server Error dpaste.com: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1016)>, gateway.log: Failed to upload to any paste service: paste.rs: HTTP Error 500: Internal Server Error dpaste.com: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1016)>, desktop.log: Failed to upload to any paste service: paste.rs: HTTP Error 500: Internal Server Error dpaste.com: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1016)>) ⏱ Pastes will auto-delete in 6 hours. To delete now: hermes debug delete <url> Share these links with the Hermes team for support. 4. still redacted via RedactingFormatter as defence-in-depth - Disabled state attaches a NullHandler and sets the level above CRITICAL, so _capture_body() is a cheap no-op (single isEnabledFor check) on the hot path. Captured bodies are truncated to 4096 bytes. request.body() is Starlette- cached, so reading it in the access middleware does not consume the stream for downstream handlers. Capture sites: HTTP request body (access middleware), PTY in/out frames. Tests (tests/test_hermes_logging.py::TestBodyCaptureOptIn): disabled-by-default creates no file and captures nothing; enabled writes to gui_bodies.log and the payload is ABSENT from gui.log; large bodies truncate; the body logger is isolated from every component; and the body file is excluded from both LOG_FILES and the debug-share snapshot set.
621 lines
24 KiB
Python
621 lines
24 KiB
Python
"""Centralized logging setup for Hermes Agent.
|
|
|
|
Provides a single ``setup_logging()`` entry point that both the CLI and
|
|
gateway call early in their startup path. All log files live under
|
|
``~/.hermes/logs/`` (profile-aware via ``get_hermes_home()``).
|
|
|
|
Log files produced:
|
|
agent.log — INFO+, all agent/tool/session activity (the main log)
|
|
errors.log — WARNING+, errors and warnings only (quick triage)
|
|
gateway.log — INFO+, gateway-only events (created when mode="gateway")
|
|
gui.log — INFO+, dashboard/websocket/TUI-gateway events
|
|
(created when mode="gui")
|
|
|
|
All files use ``RotatingFileHandler`` with ``RedactingFormatter`` so
|
|
secrets are never written to disk.
|
|
|
|
Component separation:
|
|
gateway.log only receives records from ``gateway.*`` loggers —
|
|
platform adapters, session management, slash commands, delivery.
|
|
gui.log receives dashboard-side records from ``hermes_cli.web_server``,
|
|
``hermes_cli.pty_bridge``, ``tui_gateway.*``, and ``uvicorn.*``.
|
|
agent.log remains the catch-all (everything goes there).
|
|
|
|
Session context:
|
|
Call ``set_session_context(session_id)`` at the start of a conversation
|
|
and ``clear_session_context()`` when done. All log lines emitted on
|
|
that thread will include ``[session_id]`` for filtering/correlation.
|
|
"""
|
|
|
|
import io
|
|
import logging
|
|
import os
|
|
import sys
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Optional, Sequence
|
|
|
|
# On Windows, stdlib ``RotatingFileHandler`` calls ``os.rename()`` in
|
|
# ``doRollover()`` and fails with ``PermissionError [WinError 32]`` whenever
|
|
# another process holds an append-mode handle on ``agent.log`` — which is
|
|
# essentially always in Hermes (TUI, gateway, ``hy_memory`` server, MCP
|
|
# servers, and on-demand CLI commands all log from separate processes),
|
|
# pinning ``agent.log`` at the 5 MiB threshold and spamming stderr with
|
|
# a traceback on every emit. ``concurrent-log-handler`` wraps the rename in a
|
|
# cross-process file lock (via ``portalocker``: pywin32 on Windows) so only
|
|
# one process rotates at a time and the others wait their turn.
|
|
#
|
|
# This swap is Windows-ONLY and deliberately so:
|
|
# * The bug (WinError 32 on rename-while-open) is specific to Windows file
|
|
# locking semantics — POSIX renames an open file fine, so stdlib already
|
|
# works correctly on Linux/macOS.
|
|
# * On POSIX, managed-mode (NixOS) relies on the exact ``_open()`` /
|
|
# ``doRollover()`` lifecycle of stdlib ``RotatingFileHandler`` (the
|
|
# ``_ManagedRotatingFileHandler`` subclass chmods 0660 after each). CLH
|
|
# opens lazily and rotates differently, which breaks the group-writable
|
|
# guarantee and the eager file-creation those paths depend on.
|
|
# Aliasing keeps every existing ``RotatingFileHandler`` reference in this
|
|
# module (class declaration, ``isinstance`` checks, docstring) working
|
|
# unchanged. See #44873.
|
|
if sys.platform == "win32":
|
|
from concurrent_log_handler import ( # noqa: E402
|
|
ConcurrentRotatingFileHandler as RotatingFileHandler,
|
|
)
|
|
else:
|
|
from logging.handlers import RotatingFileHandler # noqa: E402
|
|
|
|
|
|
from hermes_constants import get_config_path, get_hermes_home
|
|
|
|
# Sentinel to track whether setup_logging() has already run. The function
|
|
# is idempotent — calling it twice is safe but the second call is a no-op
|
|
# unless ``force=True``.
|
|
_logging_initialized = False
|
|
|
|
# Thread-local storage for per-conversation session context.
|
|
_session_context = threading.local()
|
|
|
|
# Default log format — includes timestamp, level, optional session tag,
|
|
# logger name, and message. The ``%(session_tag)s`` field is guaranteed to
|
|
# exist on every LogRecord via _install_session_record_factory() below.
|
|
_LOG_FORMAT = "%(asctime)s %(levelname)s%(session_tag)s %(name)s: %(message)s"
|
|
_LOG_FORMAT_VERBOSE = "%(asctime)s - %(name)s - %(levelname)s%(session_tag)s - %(message)s"
|
|
|
|
|
|
def _safe_stderr(): # type: ignore[return]
|
|
"""Return a stderr stream that tolerates Unicode on all platforms.
|
|
|
|
On Windows the console encoding is often a legacy MBCS codec
|
|
(cp949, cp1252, …) that raises ``UnicodeEncodeError`` for characters
|
|
like the em-dash (U+2014). We wrap ``sys.stderr`` in a
|
|
``TextIOWrapper`` with ``errors='replace'`` so log lines are never
|
|
lost — un-encodable characters are replaced with ``?`` instead of
|
|
crashing the process.
|
|
"""
|
|
stream = sys.stderr
|
|
encoding = getattr(stream, "encoding", None) or "utf-8"
|
|
# Already UTF-8 or surrogate-aware — no wrapping needed.
|
|
if encoding.lower().replace("-", "") in ("utf8", "utf8surrogateescape"):
|
|
return stream
|
|
try:
|
|
buf = getattr(stream, "buffer", None)
|
|
if buf is not None:
|
|
wrapped = io.TextIOWrapper(
|
|
buf,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
line_buffering=True,
|
|
)
|
|
# Prevent the wrapper from closing the underlying buffer
|
|
# when it is garbage-collected.
|
|
wrapped.close = lambda: None # type: ignore[assignment]
|
|
return wrapped
|
|
except Exception:
|
|
pass
|
|
# Best-effort: if wrapping fails, return the original stream.
|
|
return stream
|
|
|
|
# Third-party loggers that are noisy at DEBUG/INFO level.
|
|
_NOISY_LOGGERS = (
|
|
"openai",
|
|
"openai._base_client",
|
|
"httpx",
|
|
"httpcore",
|
|
"asyncio",
|
|
"hpack",
|
|
"hpack.hpack",
|
|
"grpc",
|
|
"modal",
|
|
"urllib3",
|
|
"urllib3.connectionpool",
|
|
"websockets",
|
|
"charset_normalizer",
|
|
"markdown_it",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public session context API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def set_session_context(session_id: str) -> None:
|
|
"""Set the session ID for the current thread.
|
|
|
|
All subsequent log records on this thread will include ``[session_id]``
|
|
in the formatted output. Call at the start of ``run_conversation()``.
|
|
"""
|
|
_session_context.session_id = session_id
|
|
|
|
|
|
def clear_session_context() -> None:
|
|
"""Clear the session ID for the current thread."""
|
|
_session_context.session_id = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Record factory — injects session_tag into every LogRecord at creation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _install_session_record_factory() -> None:
|
|
"""Replace the global LogRecord factory with one that adds ``session_tag``.
|
|
|
|
Unlike a ``logging.Filter`` on a handler or logger, the record factory
|
|
runs for EVERY record in the process — including records that propagate
|
|
from child loggers and records handled by third-party handlers. This
|
|
guarantees ``%(session_tag)s`` is always available in format strings,
|
|
eliminating the KeyError that would occur if a handler used our format
|
|
without having a ``_SessionFilter`` attached.
|
|
|
|
Idempotent — checks for a marker attribute to avoid double-wrapping if
|
|
the module is reloaded.
|
|
"""
|
|
current_factory = logging.getLogRecordFactory()
|
|
if getattr(current_factory, "_hermes_session_injector", False):
|
|
return # already installed
|
|
|
|
def _session_record_factory(*args, **kwargs):
|
|
record = current_factory(*args, **kwargs)
|
|
sid = getattr(_session_context, "session_id", None)
|
|
record.session_tag = f" [{sid}]" if sid else "" # type: ignore[attr-defined]
|
|
return record
|
|
|
|
_session_record_factory._hermes_session_injector = True # type: ignore[attr-defined]
|
|
logging.setLogRecordFactory(_session_record_factory)
|
|
|
|
|
|
# Install immediately on import — session_tag is available on all records
|
|
# from this point forward, even before setup_logging() is called.
|
|
_install_session_record_factory()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Filters
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _ComponentFilter(logging.Filter):
|
|
"""Only pass records whose logger name starts with one of *prefixes*.
|
|
|
|
Used to route gateway-specific records to ``gateway.log`` while
|
|
keeping ``agent.log`` as the catch-all.
|
|
"""
|
|
|
|
def __init__(self, prefixes: Sequence[str]) -> None:
|
|
super().__init__()
|
|
self._prefixes = tuple(prefixes)
|
|
|
|
def filter(self, record: logging.LogRecord) -> bool:
|
|
return record.name.startswith(self._prefixes)
|
|
|
|
|
|
# Logger name prefixes that belong to each component.
|
|
# Used by _ComponentFilter and exposed for ``hermes logs --component``.
|
|
COMPONENT_PREFIXES = {
|
|
"gateway": ("gateway", "hermes_plugins"),
|
|
"agent": ("agent", "run_agent", "model_tools", "batch_runner"),
|
|
"tools": ("tools",),
|
|
"cli": ("hermes_cli", "cli"),
|
|
"cron": ("cron",),
|
|
"gui": (
|
|
"hermes_cli.web_server",
|
|
"hermes_cli.pty_bridge",
|
|
"tui_gateway",
|
|
"uvicorn",
|
|
),
|
|
}
|
|
|
|
# Dedicated logger for opt-in HTTP/WS body capture. Intentionally NOT a member
|
|
# of any COMPONENT_PREFIXES above and configured with propagate=False, so its
|
|
# records reach only the isolated gui_bodies.log handler — never gui.log,
|
|
# agent.log, or anything the diagnostic surfaces (debug share) read.
|
|
BODY_CAPTURE_LOGGER = "hermes_body_capture"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main setup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def setup_logging(
|
|
*,
|
|
hermes_home: Optional[Path] = None,
|
|
log_level: Optional[str] = None,
|
|
max_size_mb: Optional[int] = None,
|
|
backup_count: Optional[int] = None,
|
|
mode: Optional[str] = None,
|
|
force: bool = False,
|
|
) -> Path:
|
|
"""Configure the Hermes logging subsystem.
|
|
|
|
Safe to call multiple times — the second call is a no-op unless
|
|
*force* is ``True``.
|
|
|
|
Parameters
|
|
----------
|
|
hermes_home
|
|
Override for the Hermes home directory. Falls back to
|
|
``get_hermes_home()`` (profile-aware).
|
|
log_level
|
|
Minimum level for the ``agent.log`` file handler. Accepts any
|
|
standard Python level name (``"DEBUG"``, ``"INFO"``, ``"WARNING"``).
|
|
Defaults to ``"INFO"`` or the value from config.yaml ``logging.level``.
|
|
max_size_mb
|
|
Maximum size of each log file in megabytes before rotation.
|
|
Defaults to 5 or the value from config.yaml ``logging.max_size_mb``.
|
|
backup_count
|
|
Number of rotated backup files to keep.
|
|
Defaults to 3 or the value from config.yaml ``logging.backup_count``.
|
|
mode
|
|
Caller context: ``"cli"``, ``"gateway"``, ``"gui"``, ``"cron"``.
|
|
When ``"gateway"``, an additional ``gateway.log`` file is created
|
|
that receives only gateway-component records.
|
|
When ``"gui"``, an additional ``gui.log`` file is created that
|
|
receives dashboard and TUI-gateway component records.
|
|
force
|
|
Re-run setup even if it has already been called.
|
|
|
|
Returns
|
|
-------
|
|
Path
|
|
The ``logs/`` directory where files are written.
|
|
"""
|
|
global _logging_initialized
|
|
home = hermes_home or get_hermes_home()
|
|
log_dir = home / "logs"
|
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Read config defaults (best-effort — config may not be loaded yet).
|
|
cfg_level, cfg_max_size, cfg_backup = _read_logging_config()
|
|
|
|
level_name = (log_level or cfg_level or "INFO").upper()
|
|
level = getattr(logging, level_name, logging.INFO)
|
|
max_bytes = (max_size_mb or cfg_max_size or 5) * 1024 * 1024
|
|
backups = backup_count or cfg_backup or 3
|
|
|
|
# Lazy import to avoid circular dependency at module load time.
|
|
from agent.redact import RedactingFormatter
|
|
|
|
root = logging.getLogger()
|
|
|
|
# --- agent.log (INFO+) — the main activity log -------------------------
|
|
_add_rotating_handler(
|
|
root,
|
|
log_dir / "agent.log",
|
|
level=level,
|
|
max_bytes=max_bytes,
|
|
backup_count=backups,
|
|
formatter=RedactingFormatter(_LOG_FORMAT),
|
|
)
|
|
|
|
# --- errors.log (WARNING+) — quick triage log --------------------------
|
|
_add_rotating_handler(
|
|
root,
|
|
log_dir / "errors.log",
|
|
level=logging.WARNING,
|
|
max_bytes=2 * 1024 * 1024,
|
|
backup_count=2,
|
|
formatter=RedactingFormatter(_LOG_FORMAT),
|
|
)
|
|
|
|
# --- gateway.log (INFO+, gateway component only) ------------------------
|
|
if mode == "gateway":
|
|
_add_rotating_handler(
|
|
root,
|
|
log_dir / "gateway.log",
|
|
level=logging.INFO,
|
|
max_bytes=5 * 1024 * 1024,
|
|
backup_count=3,
|
|
formatter=RedactingFormatter(_LOG_FORMAT),
|
|
log_filter=_ComponentFilter(COMPONENT_PREFIXES["gateway"]),
|
|
)
|
|
|
|
# --- gui.log (INFO+, dashboard/tui-gateway components) -----------------
|
|
if mode == "gui":
|
|
_add_rotating_handler(
|
|
root,
|
|
log_dir / "gui.log",
|
|
level=logging.INFO,
|
|
max_bytes=10 * 1024 * 1024,
|
|
backup_count=5,
|
|
formatter=RedactingFormatter(_LOG_FORMAT),
|
|
log_filter=_ComponentFilter(COMPONENT_PREFIXES["gui"]),
|
|
)
|
|
|
|
# --- gui_bodies.log (opt-in HTTP/WS body capture) ------------------
|
|
# OFF by default; enabled only when config.yaml ``logging.capture_bodies``
|
|
# is true. This is the heavy diagnostic tier — request/response bodies
|
|
# and WS frames — so it is DELIBERATELY ISOLATED:
|
|
# * a dedicated logger (BODY_CAPTURE_LOGGER, propagate=False) that is
|
|
# NOT under any COMPONENT_PREFIXES, so bodies never leak into
|
|
# gui.log / agent.log;
|
|
# * a dedicated file that is NOT registered in hermes_cli/logs.py
|
|
# LOG_FILES and NOT in the debug-share snapshot set, so it can
|
|
# never be auto-uploaded by ``hermes debug share`` (see #22016).
|
|
# Still redacted via RedactingFormatter as defence-in-depth.
|
|
body_logger = logging.getLogger(BODY_CAPTURE_LOGGER)
|
|
body_logger.handlers.clear()
|
|
body_logger.propagate = False
|
|
if _read_capture_bodies_config():
|
|
body_logger.setLevel(logging.INFO)
|
|
_add_rotating_handler(
|
|
body_logger,
|
|
log_dir / "gui_bodies.log",
|
|
level=logging.INFO,
|
|
max_bytes=10 * 1024 * 1024,
|
|
backup_count=3,
|
|
formatter=RedactingFormatter(_LOG_FORMAT),
|
|
)
|
|
else:
|
|
# Disabled: swallow any emit so a stray call is a cheap no-op.
|
|
body_logger.setLevel(logging.CRITICAL + 1)
|
|
body_logger.addHandler(logging.NullHandler())
|
|
|
|
if _logging_initialized and not force:
|
|
return log_dir
|
|
|
|
# Ensure root logger level is low enough for the handlers to fire.
|
|
if root.level == logging.NOTSET or root.level > level:
|
|
root.setLevel(level)
|
|
|
|
# Suppress noisy third-party loggers.
|
|
for name in _NOISY_LOGGERS:
|
|
logging.getLogger(name).setLevel(logging.WARNING)
|
|
|
|
_logging_initialized = True
|
|
return log_dir
|
|
|
|
|
|
def setup_verbose_logging() -> None:
|
|
"""Enable DEBUG-level console logging for ``--verbose`` / ``-v`` mode.
|
|
|
|
Called by ``AIAgent.__init__()`` when ``verbose_logging=True``.
|
|
"""
|
|
from agent.redact import RedactingFormatter
|
|
|
|
root = logging.getLogger()
|
|
|
|
# Avoid adding duplicate stream handlers.
|
|
for h in root.handlers:
|
|
if isinstance(h, logging.StreamHandler) and not isinstance(h, RotatingFileHandler):
|
|
if getattr(h, "_hermes_verbose", False):
|
|
return
|
|
|
|
handler = logging.StreamHandler(_safe_stderr())
|
|
handler.setLevel(logging.DEBUG)
|
|
handler.setFormatter(RedactingFormatter(_LOG_FORMAT_VERBOSE, datefmt="%H:%M:%S"))
|
|
handler._hermes_verbose = True # type: ignore[attr-defined]
|
|
root.addHandler(handler)
|
|
|
|
# Lower root logger level so DEBUG records reach all handlers.
|
|
if root.level > logging.DEBUG:
|
|
root.setLevel(logging.DEBUG)
|
|
|
|
# Keep third-party libraries at WARNING to reduce noise.
|
|
for name in _NOISY_LOGGERS:
|
|
logging.getLogger(name).setLevel(logging.WARNING)
|
|
# rex-deploy at INFO for sandbox status.
|
|
logging.getLogger("rex-deploy").setLevel(logging.INFO)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _ManagedRotatingFileHandler(RotatingFileHandler):
|
|
"""RotatingFileHandler that ensures group-writable perms in managed mode
|
|
AND survives external rotation.
|
|
|
|
Two responsibilities:
|
|
|
|
1. In managed mode (NixOS), the stateDir uses setgid (2770) so new files
|
|
inherit the hermes group. However, both ``_open()`` (initial creation)
|
|
and ``doRollover()`` create files via ``open()``, which uses the
|
|
process umask — typically 0022, producing 0644. This subclass applies
|
|
``chmod 0660`` after both operations so the gateway and interactive
|
|
users can share log files.
|
|
|
|
2. ``RotatingFileHandler`` keeps an open file descriptor. If anything
|
|
rotates the file *externally* (``logrotate``, manual ``mv``,
|
|
another process rotating under us, a transient unlink), our fd
|
|
keeps pointing at the renamed/unlinked inode and every subsequent
|
|
write goes to ``gateway.log.1`` instead of ``gateway.log`` — silent
|
|
log loss for the file every operator expects to read. Before each
|
|
emit we ``stat`` ``baseFilename`` and compare it against the open
|
|
stream's inode; on mismatch we reopen. This is the same pattern
|
|
as stdlib ``WatchedFileHandler.reopenIfNeeded()``, adapted for
|
|
rotating handlers.
|
|
"""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
from hermes_cli.config import is_managed
|
|
self._managed = is_managed()
|
|
super().__init__(*args, **kwargs)
|
|
# Snapshot the inode of the currently open stream so emit() can
|
|
# detect external rotation without an extra fstat per write.
|
|
self._stat_dev: Optional[int] = None
|
|
self._stat_ino: Optional[int] = None
|
|
self._record_stream_stat()
|
|
|
|
def _chmod_if_managed(self):
|
|
if self._managed:
|
|
try:
|
|
os.chmod(self.baseFilename, 0o660)
|
|
except OSError:
|
|
pass
|
|
|
|
def _record_stream_stat(self) -> None:
|
|
"""Snapshot dev/ino of ``baseFilename`` so we can detect external rotation."""
|
|
try:
|
|
st = os.stat(self.baseFilename)
|
|
self._stat_dev, self._stat_ino = st.st_dev, st.st_ino
|
|
except OSError:
|
|
self._stat_dev, self._stat_ino = None, None
|
|
|
|
def _reopen_if_externally_rotated(self) -> None:
|
|
"""Reopen the stream when ``baseFilename`` no longer matches our fd.
|
|
|
|
Triggered when ``baseFilename`` was renamed (logrotate), unlinked,
|
|
or replaced by a different inode. Silent + best-effort: any error
|
|
falls back to the existing (possibly stale) stream so logging keeps
|
|
working instead of dying on a stat failure.
|
|
"""
|
|
try:
|
|
st = os.stat(self.baseFilename)
|
|
except FileNotFoundError:
|
|
# File was rotated/unlinked underneath us. Close + reopen so a
|
|
# fresh inode is created at the expected path.
|
|
try:
|
|
if self.stream is not None:
|
|
self.stream.close()
|
|
except Exception:
|
|
pass
|
|
self.stream = None # type: ignore[assignment]
|
|
try:
|
|
self.stream = self._open()
|
|
self._record_stream_stat()
|
|
except Exception:
|
|
# Couldn't reopen — leave stream=None; next emit will
|
|
# bail rather than write to a stale inode.
|
|
pass
|
|
return
|
|
except OSError:
|
|
return # transient — try again on the next emit
|
|
|
|
if self._stat_dev is None or self._stat_ino is None:
|
|
self._stat_dev, self._stat_ino = st.st_dev, st.st_ino
|
|
return
|
|
|
|
if (st.st_dev, st.st_ino) != (self._stat_dev, self._stat_ino):
|
|
# baseFilename now points at a DIFFERENT inode than the one we
|
|
# hold open. Close the old stream and open the new file.
|
|
try:
|
|
if self.stream is not None:
|
|
self.stream.close()
|
|
except Exception:
|
|
pass
|
|
self.stream = None # type: ignore[assignment]
|
|
try:
|
|
self.stream = self._open()
|
|
self._stat_dev, self._stat_ino = st.st_dev, st.st_ino
|
|
except Exception:
|
|
pass
|
|
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
# Cheap-ish stat-per-record check; the kernel caches inode metadata
|
|
# so the syscall is sub-microsecond on a hot file.
|
|
if self.stream is not None or os.path.exists(self.baseFilename):
|
|
self._reopen_if_externally_rotated()
|
|
super().emit(record)
|
|
|
|
def _open(self):
|
|
stream = super()._open()
|
|
self._chmod_if_managed()
|
|
return stream
|
|
|
|
def doRollover(self):
|
|
super().doRollover()
|
|
self._chmod_if_managed()
|
|
# Our own rollover writes a new baseFilename; refresh the snapshot
|
|
# so the next emit doesn't mistake it for external rotation.
|
|
self._record_stream_stat()
|
|
|
|
|
|
def _add_rotating_handler(
|
|
logger: logging.Logger,
|
|
path: Path,
|
|
*,
|
|
level: int,
|
|
max_bytes: int,
|
|
backup_count: int,
|
|
formatter: logging.Formatter,
|
|
log_filter: Optional[logging.Filter] = None,
|
|
) -> None:
|
|
"""Add a ``RotatingFileHandler`` to *logger*, skipping if one already
|
|
exists for the same resolved file path (idempotent).
|
|
|
|
Parameters
|
|
----------
|
|
log_filter
|
|
Optional filter to attach to the handler (e.g. ``_ComponentFilter``
|
|
for gateway.log).
|
|
"""
|
|
resolved = path.resolve()
|
|
for existing in logger.handlers:
|
|
if (
|
|
isinstance(existing, RotatingFileHandler)
|
|
and Path(getattr(existing, "baseFilename", "")).resolve() == resolved
|
|
):
|
|
return # already attached
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
handler = _ManagedRotatingFileHandler(
|
|
str(path), maxBytes=max_bytes, backupCount=backup_count,
|
|
encoding="utf-8",
|
|
)
|
|
handler.setLevel(level)
|
|
handler.setFormatter(formatter)
|
|
if log_filter is not None:
|
|
handler.addFilter(log_filter)
|
|
logger.addHandler(handler)
|
|
|
|
|
|
def _read_logging_config():
|
|
"""Best-effort read of ``logging.*`` from config.yaml.
|
|
|
|
Returns ``(level, max_size_mb, backup_count)`` — any may be ``None``.
|
|
"""
|
|
try:
|
|
import yaml
|
|
config_path = get_config_path()
|
|
if config_path.exists():
|
|
with open(config_path, "r", encoding="utf-8") as f:
|
|
cfg = yaml.safe_load(f) or {}
|
|
log_cfg = cfg.get("logging", {})
|
|
if isinstance(log_cfg, dict):
|
|
return (
|
|
log_cfg.get("level"),
|
|
log_cfg.get("max_size_mb"),
|
|
log_cfg.get("backup_count"),
|
|
)
|
|
except Exception:
|
|
pass
|
|
return (None, None, None)
|
|
|
|
|
|
def _read_capture_bodies_config() -> bool:
|
|
"""Best-effort read of ``logging.capture_bodies`` from config.yaml.
|
|
|
|
Defaults to ``False`` — body capture is opt-in. This is the gate that keeps
|
|
the heavy diagnostic tier off unless an operator explicitly enables it
|
|
(``hermes config set logging.capture_bodies true`` or the dashboard toggle).
|
|
"""
|
|
try:
|
|
import yaml
|
|
config_path = get_config_path()
|
|
if config_path.exists():
|
|
with open(config_path, "r", encoding="utf-8") as f:
|
|
cfg = yaml.safe_load(f) or {}
|
|
log_cfg = cfg.get("logging", {})
|
|
if isinstance(log_cfg, dict):
|
|
return bool(log_cfg.get("capture_bodies", False))
|
|
except Exception:
|
|
pass
|
|
return False
|