feat: centralized logging to ~/.hermes/logs/
Add hermes_logging.py — single setup_logging() entry point used by CLI, gateway, and cron. All Hermes processes now write to two log files: agent.log — INFO+, captures all agent/tool/session activity errors.log — WARNING+, quick triage of errors and warnings Both use RotatingFileHandler with RedactingFormatter (secrets never hit disk). The setup is idempotent, so gateway mode (new AIAgent per message) doesn't duplicate handlers. Changes: - New hermes_logging.py with setup_logging(), setup_verbose_logging(), and _add_rotating_handler() (all idempotent) - Add logging: section to DEFAULT_CONFIG (level, max_size_mb, backup_count) with config.yaml override support - run_agent.py: replace 50-line inline logging setup with setup_logging() call - gateway/run.py: use centralized setup + keep gateway.log as gateway-specific - cli.py: call setup_logging() at module load (before AIAgent exists) - hermes_cli/main.py: call setup_logging() early for all subcommands - Fix debug_helpers.py: use get_hermes_home()/logs instead of ./logs/ - 20 new tests covering handler creation, idempotency, config reading, log level filtering, and verbose mode
This commit is contained in:
@@ -453,6 +453,14 @@ def load_cli_config() -> Dict[str, Any]:
|
||||
# Load configuration at module startup
|
||||
CLI_CONFIG = load_cli_config()
|
||||
|
||||
# Initialize centralized logging early — agent.log + errors.log in ~/.hermes/logs/.
|
||||
# This ensures CLI sessions produce a log trail even before AIAgent is instantiated.
|
||||
try:
|
||||
from hermes_logging import setup_logging
|
||||
setup_logging(mode="cli")
|
||||
except Exception:
|
||||
pass # Logging setup is best-effort — don't crash the CLI
|
||||
|
||||
# Initialize the skin engine from config
|
||||
try:
|
||||
from hermes_cli.skin_engine import init_skin_from_config
|
||||
|
||||
+16
-22
@@ -25,7 +25,6 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional, Any, List
|
||||
@@ -7187,18 +7186,23 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Configure rotating file log so gateway output is persisted for debugging
|
||||
log_dir = _hermes_home / 'logs'
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = RotatingFileHandler(
|
||||
log_dir / 'gateway.log',
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
)
|
||||
# Centralized logging — agent.log (INFO+) and errors.log (WARNING+).
|
||||
# Idempotent, so repeated calls from AIAgent.__init__ won't duplicate.
|
||||
from hermes_logging import setup_logging
|
||||
log_dir = setup_logging(hermes_home=_hermes_home, mode="gateway")
|
||||
|
||||
# Gateway-specific rotating log — captures all gateway-level messages
|
||||
# (session management, platform adapters, slash commands, etc.).
|
||||
from agent.redact import RedactingFormatter
|
||||
file_handler.setFormatter(RedactingFormatter('%(asctime)s %(levelname)s %(name)s: %(message)s'))
|
||||
logging.getLogger().addHandler(file_handler)
|
||||
logging.getLogger().setLevel(logging.INFO)
|
||||
from hermes_logging import _add_rotating_handler
|
||||
_add_rotating_handler(
|
||||
logging.getLogger(),
|
||||
log_dir / 'gateway.log',
|
||||
level=logging.INFO,
|
||||
max_bytes=5 * 1024 * 1024,
|
||||
backup_count=3,
|
||||
formatter=RedactingFormatter('%(asctime)s %(levelname)s %(name)s: %(message)s'),
|
||||
)
|
||||
|
||||
# Optional stderr handler — level driven by -v/-q flags on the CLI.
|
||||
# verbosity=None (-q/--quiet): no stderr output
|
||||
@@ -7215,16 +7219,6 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
if _stderr_level < logging.getLogger().level:
|
||||
logging.getLogger().setLevel(_stderr_level)
|
||||
|
||||
# Separate errors-only log for easy debugging
|
||||
error_handler = RotatingFileHandler(
|
||||
log_dir / 'errors.log',
|
||||
maxBytes=2 * 1024 * 1024,
|
||||
backupCount=2,
|
||||
)
|
||||
error_handler.setLevel(logging.WARNING)
|
||||
error_handler.setFormatter(RedactingFormatter('%(asctime)s %(levelname)s %(name)s: %(message)s'))
|
||||
logging.getLogger().addHandler(error_handler)
|
||||
|
||||
runner = GatewayRunner(config)
|
||||
|
||||
# Set up signal handlers
|
||||
|
||||
@@ -536,6 +536,14 @@ DEFAULT_CONFIG = {
|
||||
"wrap_response": True,
|
||||
},
|
||||
|
||||
# Logging — controls file logging to ~/.hermes/logs/.
|
||||
# agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.
|
||||
"logging": {
|
||||
"level": "INFO", # Minimum level for agent.log: DEBUG, INFO, WARNING
|
||||
"max_size_mb": 5, # Max size per log file before rotation
|
||||
"backup_count": 3, # Number of rotated backup files to keep
|
||||
},
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 12,
|
||||
}
|
||||
|
||||
@@ -142,6 +142,13 @@ from hermes_cli.config import get_hermes_home
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
load_hermes_dotenv(project_env=PROJECT_ROOT / '.env')
|
||||
|
||||
# Initialize centralized file logging early — all `hermes` subcommands
|
||||
# (chat, setup, gateway, config, etc.) write to agent.log + errors.log.
|
||||
try:
|
||||
from hermes_logging import setup_logging as _setup_logging
|
||||
_setup_logging(mode="cli")
|
||||
except Exception:
|
||||
pass # best-effort — don't crash the CLI if logging setup fails
|
||||
|
||||
import logging
|
||||
import time as _time
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""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)
|
||||
|
||||
Both files use ``RotatingFileHandler`` with ``RedactingFormatter`` so
|
||||
secrets are never written to disk.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_constants import 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
|
||||
|
||||
# Default log format — includes timestamp, level, logger name, and message.
|
||||
_LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s"
|
||||
_LOG_FORMAT_VERBOSE = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
|
||||
# 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",
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
Hint for the caller context: ``"cli"``, ``"gateway"``, ``"cron"``.
|
||||
Currently used only for log format tuning (gateway includes PID).
|
||||
force
|
||||
Re-run setup even if it has already been called.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Path
|
||||
The ``logs/`` directory where files are written.
|
||||
"""
|
||||
global _logging_initialized
|
||||
if _logging_initialized and not force:
|
||||
home = hermes_home or get_hermes_home()
|
||||
return home / "logs"
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
# 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()
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _add_rotating_handler(
|
||||
logger: logging.Logger,
|
||||
path: Path,
|
||||
*,
|
||||
level: int,
|
||||
max_bytes: int,
|
||||
backup_count: int,
|
||||
formatter: logging.Formatter,
|
||||
) -> None:
|
||||
"""Add a ``RotatingFileHandler`` to *logger*, skipping if one already
|
||||
exists for the same resolved file path (idempotent).
|
||||
"""
|
||||
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 = RotatingFileHandler(
|
||||
str(path), maxBytes=max_bytes, backupCount=backup_count,
|
||||
)
|
||||
handler.setLevel(level)
|
||||
handler.setFormatter(formatter)
|
||||
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_hermes_home() / "config.yaml"
|
||||
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)
|
||||
+9
-63
@@ -716,77 +716,23 @@ class AIAgent:
|
||||
self._current_tool: str | None = None
|
||||
self._api_call_count: int = 0
|
||||
|
||||
# Persistent error log -- always writes WARNING+ to ~/.hermes/logs/errors.log
|
||||
# so tool failures, API errors, etc. are inspectable after the fact.
|
||||
# In gateway mode, each incoming message creates a new AIAgent instance,
|
||||
# while the root logger is process-global. Re-adding the same errors.log
|
||||
# handler would cause each warning/error line to be written multiple times.
|
||||
from logging.handlers import RotatingFileHandler
|
||||
root_logger = logging.getLogger()
|
||||
error_log_dir = _hermes_home / "logs"
|
||||
error_log_path = error_log_dir / "errors.log"
|
||||
resolved_error_log_path = error_log_path.resolve()
|
||||
has_errors_log_handler = any(
|
||||
isinstance(handler, RotatingFileHandler)
|
||||
and Path(getattr(handler, "baseFilename", "")).resolve() == resolved_error_log_path
|
||||
for handler in root_logger.handlers
|
||||
)
|
||||
from agent.redact import RedactingFormatter
|
||||
if not has_errors_log_handler:
|
||||
error_log_dir.mkdir(parents=True, exist_ok=True)
|
||||
error_file_handler = RotatingFileHandler(
|
||||
error_log_path, maxBytes=2 * 1024 * 1024, backupCount=2,
|
||||
)
|
||||
error_file_handler.setLevel(logging.WARNING)
|
||||
error_file_handler.setFormatter(RedactingFormatter(
|
||||
'%(asctime)s %(levelname)s %(name)s: %(message)s',
|
||||
))
|
||||
root_logger.addHandler(error_file_handler)
|
||||
# Centralized logging — agent.log (INFO+) and errors.log (WARNING+)
|
||||
# both live under ~/.hermes/logs/. Idempotent, so gateway mode
|
||||
# (which creates a new AIAgent per message) won't duplicate handlers.
|
||||
from hermes_logging import setup_logging, setup_verbose_logging
|
||||
setup_logging(hermes_home=_hermes_home)
|
||||
|
||||
if self.verbose_logging:
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
datefmt='%H:%M:%S'
|
||||
)
|
||||
for handler in logging.getLogger().handlers:
|
||||
handler.setFormatter(RedactingFormatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
datefmt='%H:%M:%S',
|
||||
))
|
||||
# Keep third-party libraries at WARNING level to reduce noise
|
||||
# We have our own retry and error logging that's more informative
|
||||
logging.getLogger('openai').setLevel(logging.WARNING)
|
||||
logging.getLogger('openai._base_client').setLevel(logging.WARNING)
|
||||
logging.getLogger('httpx').setLevel(logging.WARNING)
|
||||
logging.getLogger('httpcore').setLevel(logging.WARNING)
|
||||
logging.getLogger('asyncio').setLevel(logging.WARNING)
|
||||
# Suppress Modal/gRPC related debug spam
|
||||
logging.getLogger('hpack').setLevel(logging.WARNING)
|
||||
logging.getLogger('hpack.hpack').setLevel(logging.WARNING)
|
||||
logging.getLogger('grpc').setLevel(logging.WARNING)
|
||||
logging.getLogger('modal').setLevel(logging.WARNING)
|
||||
logging.getLogger('rex-deploy').setLevel(logging.INFO) # Keep INFO for sandbox status
|
||||
setup_verbose_logging()
|
||||
logger.info("Verbose logging enabled (third-party library logs suppressed)")
|
||||
else:
|
||||
# Set logging to INFO level for important messages only
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
datefmt='%H:%M:%S'
|
||||
)
|
||||
# Suppress noisy library logging
|
||||
logging.getLogger('openai').setLevel(logging.ERROR)
|
||||
logging.getLogger('openai._base_client').setLevel(logging.ERROR)
|
||||
logging.getLogger('httpx').setLevel(logging.ERROR)
|
||||
logging.getLogger('httpcore').setLevel(logging.ERROR)
|
||||
if self.quiet_mode:
|
||||
# In quiet mode (CLI default), suppress all tool/infra log
|
||||
# noise. The TUI has its own rich display for status; logger
|
||||
# INFO/WARNING messages just clutter it.
|
||||
# noise on the *console*. The TUI has its own rich display
|
||||
# for status; logger INFO/WARNING messages just clutter it.
|
||||
# File handlers (agent.log, errors.log) still capture everything.
|
||||
for quiet_logger in [
|
||||
'tools', # all tools.* (terminal, browser, web, file, etc.)
|
||||
|
||||
'run_agent', # agent runner internals
|
||||
'trajectory_compressor',
|
||||
'cron', # scheduler (only relevant in daemon mode)
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Tests for hermes_logging — centralized logging setup."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_logging
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_logging_state():
|
||||
"""Reset the module-level sentinel and clean up root logger handlers
|
||||
added by setup_logging() so tests don't leak state."""
|
||||
hermes_logging._logging_initialized = False
|
||||
root = logging.getLogger()
|
||||
original_handlers = list(root.handlers)
|
||||
yield
|
||||
# Restore — remove any handlers added during the test.
|
||||
for h in list(root.handlers):
|
||||
if h not in original_handlers:
|
||||
root.removeHandler(h)
|
||||
h.close()
|
||||
hermes_logging._logging_initialized = False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hermes_home(tmp_path, monkeypatch):
|
||||
"""Provide an isolated HERMES_HOME for logging tests.
|
||||
|
||||
Uses the same tmp_path as the autouse _isolate_hermes_home from conftest,
|
||||
reading it back from the env var to avoid double-mkdir conflicts.
|
||||
"""
|
||||
home = Path(os.environ["HERMES_HOME"])
|
||||
return home
|
||||
|
||||
|
||||
class TestSetupLogging:
|
||||
"""setup_logging() creates agent.log + errors.log with RotatingFileHandler."""
|
||||
|
||||
def test_creates_log_directory(self, hermes_home):
|
||||
log_dir = hermes_logging.setup_logging(hermes_home=hermes_home)
|
||||
assert log_dir == hermes_home / "logs"
|
||||
assert log_dir.is_dir()
|
||||
|
||||
def test_creates_agent_log_handler(self, hermes_home):
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home)
|
||||
root = logging.getLogger()
|
||||
|
||||
agent_handlers = [
|
||||
h for h in root.handlers
|
||||
if isinstance(h, RotatingFileHandler)
|
||||
and "agent.log" in getattr(h, "baseFilename", "")
|
||||
]
|
||||
assert len(agent_handlers) == 1
|
||||
assert agent_handlers[0].level == logging.INFO
|
||||
|
||||
def test_creates_errors_log_handler(self, hermes_home):
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home)
|
||||
root = logging.getLogger()
|
||||
|
||||
error_handlers = [
|
||||
h for h in root.handlers
|
||||
if isinstance(h, RotatingFileHandler)
|
||||
and "errors.log" in getattr(h, "baseFilename", "")
|
||||
]
|
||||
assert len(error_handlers) == 1
|
||||
assert error_handlers[0].level == logging.WARNING
|
||||
|
||||
def test_idempotent_no_duplicate_handlers(self, hermes_home):
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home)
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home) # second call — should be no-op
|
||||
|
||||
root = logging.getLogger()
|
||||
agent_handlers = [
|
||||
h for h in root.handlers
|
||||
if isinstance(h, RotatingFileHandler)
|
||||
and "agent.log" in getattr(h, "baseFilename", "")
|
||||
]
|
||||
assert len(agent_handlers) == 1
|
||||
|
||||
def test_force_reinitializes(self, hermes_home):
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home)
|
||||
# Force still won't add duplicate handlers because _add_rotating_handler
|
||||
# checks by resolved path.
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home, force=True)
|
||||
|
||||
root = logging.getLogger()
|
||||
agent_handlers = [
|
||||
h for h in root.handlers
|
||||
if isinstance(h, RotatingFileHandler)
|
||||
and "agent.log" in getattr(h, "baseFilename", "")
|
||||
]
|
||||
assert len(agent_handlers) == 1
|
||||
|
||||
def test_custom_log_level(self, hermes_home):
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home, log_level="DEBUG")
|
||||
|
||||
root = logging.getLogger()
|
||||
agent_handlers = [
|
||||
h for h in root.handlers
|
||||
if isinstance(h, RotatingFileHandler)
|
||||
and "agent.log" in getattr(h, "baseFilename", "")
|
||||
]
|
||||
assert agent_handlers[0].level == logging.DEBUG
|
||||
|
||||
def test_custom_max_size_and_backup(self, hermes_home):
|
||||
hermes_logging.setup_logging(
|
||||
hermes_home=hermes_home, max_size_mb=10, backup_count=5
|
||||
)
|
||||
|
||||
root = logging.getLogger()
|
||||
agent_handlers = [
|
||||
h for h in root.handlers
|
||||
if isinstance(h, RotatingFileHandler)
|
||||
and "agent.log" in getattr(h, "baseFilename", "")
|
||||
]
|
||||
assert agent_handlers[0].maxBytes == 10 * 1024 * 1024
|
||||
assert agent_handlers[0].backupCount == 5
|
||||
|
||||
def test_suppresses_noisy_loggers(self, hermes_home):
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home)
|
||||
|
||||
assert logging.getLogger("openai").level >= logging.WARNING
|
||||
assert logging.getLogger("httpx").level >= logging.WARNING
|
||||
assert logging.getLogger("httpcore").level >= logging.WARNING
|
||||
|
||||
def test_writes_to_agent_log(self, hermes_home):
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home)
|
||||
|
||||
test_logger = logging.getLogger("test_hermes_logging.write_test")
|
||||
test_logger.info("test message for agent.log")
|
||||
|
||||
# Flush handlers
|
||||
for h in logging.getLogger().handlers:
|
||||
h.flush()
|
||||
|
||||
agent_log = hermes_home / "logs" / "agent.log"
|
||||
assert agent_log.exists()
|
||||
content = agent_log.read_text()
|
||||
assert "test message for agent.log" in content
|
||||
|
||||
def test_warnings_appear_in_both_logs(self, hermes_home):
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home)
|
||||
|
||||
test_logger = logging.getLogger("test_hermes_logging.warning_test")
|
||||
test_logger.warning("this is a warning")
|
||||
|
||||
for h in logging.getLogger().handlers:
|
||||
h.flush()
|
||||
|
||||
agent_log = hermes_home / "logs" / "agent.log"
|
||||
errors_log = hermes_home / "logs" / "errors.log"
|
||||
assert "this is a warning" in agent_log.read_text()
|
||||
assert "this is a warning" in errors_log.read_text()
|
||||
|
||||
def test_info_not_in_errors_log(self, hermes_home):
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home)
|
||||
|
||||
test_logger = logging.getLogger("test_hermes_logging.info_test")
|
||||
test_logger.info("info only message")
|
||||
|
||||
for h in logging.getLogger().handlers:
|
||||
h.flush()
|
||||
|
||||
errors_log = hermes_home / "logs" / "errors.log"
|
||||
if errors_log.exists():
|
||||
assert "info only message" not in errors_log.read_text()
|
||||
|
||||
def test_reads_config_yaml(self, hermes_home):
|
||||
"""setup_logging reads logging.level from config.yaml."""
|
||||
import yaml
|
||||
config = {"logging": {"level": "DEBUG", "max_size_mb": 2, "backup_count": 1}}
|
||||
(hermes_home / "config.yaml").write_text(yaml.dump(config))
|
||||
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home)
|
||||
|
||||
root = logging.getLogger()
|
||||
agent_handlers = [
|
||||
h for h in root.handlers
|
||||
if isinstance(h, RotatingFileHandler)
|
||||
and "agent.log" in getattr(h, "baseFilename", "")
|
||||
]
|
||||
assert agent_handlers[0].level == logging.DEBUG
|
||||
assert agent_handlers[0].maxBytes == 2 * 1024 * 1024
|
||||
assert agent_handlers[0].backupCount == 1
|
||||
|
||||
def test_explicit_params_override_config(self, hermes_home):
|
||||
"""Explicit function params take precedence over config.yaml."""
|
||||
import yaml
|
||||
config = {"logging": {"level": "DEBUG"}}
|
||||
(hermes_home / "config.yaml").write_text(yaml.dump(config))
|
||||
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home, log_level="WARNING")
|
||||
|
||||
root = logging.getLogger()
|
||||
agent_handlers = [
|
||||
h for h in root.handlers
|
||||
if isinstance(h, RotatingFileHandler)
|
||||
and "agent.log" in getattr(h, "baseFilename", "")
|
||||
]
|
||||
assert agent_handlers[0].level == logging.WARNING
|
||||
|
||||
|
||||
class TestSetupVerboseLogging:
|
||||
"""setup_verbose_logging() adds a DEBUG-level console handler."""
|
||||
|
||||
def test_adds_stream_handler(self, hermes_home):
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home)
|
||||
hermes_logging.setup_verbose_logging()
|
||||
|
||||
root = logging.getLogger()
|
||||
verbose_handlers = [
|
||||
h for h in root.handlers
|
||||
if isinstance(h, logging.StreamHandler)
|
||||
and not isinstance(h, RotatingFileHandler)
|
||||
and getattr(h, "_hermes_verbose", False)
|
||||
]
|
||||
assert len(verbose_handlers) == 1
|
||||
assert verbose_handlers[0].level == logging.DEBUG
|
||||
|
||||
def test_idempotent(self, hermes_home):
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home)
|
||||
hermes_logging.setup_verbose_logging()
|
||||
hermes_logging.setup_verbose_logging() # second call
|
||||
|
||||
root = logging.getLogger()
|
||||
verbose_handlers = [
|
||||
h for h in root.handlers
|
||||
if isinstance(h, logging.StreamHandler)
|
||||
and not isinstance(h, RotatingFileHandler)
|
||||
and getattr(h, "_hermes_verbose", False)
|
||||
]
|
||||
assert len(verbose_handlers) == 1
|
||||
|
||||
|
||||
class TestAddRotatingHandler:
|
||||
"""_add_rotating_handler() is idempotent and creates the directory."""
|
||||
|
||||
def test_creates_directory(self, tmp_path):
|
||||
log_path = tmp_path / "subdir" / "test.log"
|
||||
logger = logging.getLogger("_test_rotating")
|
||||
formatter = logging.Formatter("%(message)s")
|
||||
|
||||
hermes_logging._add_rotating_handler(
|
||||
logger, log_path,
|
||||
level=logging.INFO, max_bytes=1024, backup_count=1,
|
||||
formatter=formatter,
|
||||
)
|
||||
|
||||
assert log_path.parent.is_dir()
|
||||
# Clean up
|
||||
for h in list(logger.handlers):
|
||||
if isinstance(h, RotatingFileHandler):
|
||||
logger.removeHandler(h)
|
||||
h.close()
|
||||
|
||||
def test_no_duplicate_for_same_path(self, tmp_path):
|
||||
log_path = tmp_path / "test.log"
|
||||
logger = logging.getLogger("_test_rotating_dup")
|
||||
formatter = logging.Formatter("%(message)s")
|
||||
|
||||
hermes_logging._add_rotating_handler(
|
||||
logger, log_path,
|
||||
level=logging.INFO, max_bytes=1024, backup_count=1,
|
||||
formatter=formatter,
|
||||
)
|
||||
hermes_logging._add_rotating_handler(
|
||||
logger, log_path,
|
||||
level=logging.INFO, max_bytes=1024, backup_count=1,
|
||||
formatter=formatter,
|
||||
)
|
||||
|
||||
rotating_handlers = [
|
||||
h for h in logger.handlers
|
||||
if isinstance(h, RotatingFileHandler)
|
||||
]
|
||||
assert len(rotating_handlers) == 1
|
||||
# Clean up
|
||||
for h in list(logger.handlers):
|
||||
if isinstance(h, RotatingFileHandler):
|
||||
logger.removeHandler(h)
|
||||
h.close()
|
||||
|
||||
|
||||
class TestReadLoggingConfig:
|
||||
"""_read_logging_config() reads from config.yaml."""
|
||||
|
||||
def test_returns_none_when_no_config(self, hermes_home):
|
||||
level, max_size, backup = hermes_logging._read_logging_config()
|
||||
assert level is None
|
||||
assert max_size is None
|
||||
assert backup is None
|
||||
|
||||
def test_reads_logging_section(self, hermes_home):
|
||||
import yaml
|
||||
config = {"logging": {"level": "DEBUG", "max_size_mb": 10, "backup_count": 5}}
|
||||
(hermes_home / "config.yaml").write_text(yaml.dump(config))
|
||||
|
||||
level, max_size, backup = hermes_logging._read_logging_config()
|
||||
assert level == "DEBUG"
|
||||
assert max_size == 10
|
||||
assert backup == 5
|
||||
|
||||
def test_handles_missing_logging_section(self, hermes_home):
|
||||
import yaml
|
||||
config = {"model": "test"}
|
||||
(hermes_home / "config.yaml").write_text(yaml.dump(config))
|
||||
|
||||
level, max_size, backup = hermes_logging._read_logging_config()
|
||||
assert level is None
|
||||
@@ -29,6 +29,8 @@ import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -43,12 +45,12 @@ class DebugSession:
|
||||
self.tool_name = tool_name
|
||||
self.enabled = os.getenv(env_var, "false").lower() == "true"
|
||||
self.session_id = str(uuid.uuid4()) if self.enabled else ""
|
||||
self.log_dir = Path("./logs")
|
||||
self.log_dir = get_hermes_home() / "logs"
|
||||
self._calls: list[Dict[str, Any]] = []
|
||||
self._start_time = datetime.datetime.now().isoformat() if self.enabled else ""
|
||||
|
||||
if self.enabled:
|
||||
self.log_dir.mkdir(exist_ok=True)
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.debug("%s debug mode enabled - Session ID: %s",
|
||||
tool_name, self.session_id)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user