diff --git a/cli.py b/cli.py index 66f00a1285..a982ad4c0c 100644 --- a/cli.py +++ b/cli.py @@ -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 diff --git a/gateway/run.py b/gateway/run.py index ee1de5174b..7691227976 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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 diff --git a/hermes_cli/config.py b/hermes_cli/config.py index fc48aae9b1..0faed62e05 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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, } diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 159e77138d..477c134823 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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 diff --git a/hermes_logging.py b/hermes_logging.py new file mode 100644 index 0000000000..9a720bf68d --- /dev/null +++ b/hermes_logging.py @@ -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) diff --git a/run_agent.py b/run_agent.py index 9aca26067c..d8355d7421 100644 --- a/run_agent.py +++ b/run_agent.py @@ -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) diff --git a/tests/test_hermes_logging.py b/tests/test_hermes_logging.py new file mode 100644 index 0000000000..7b4004ef68 --- /dev/null +++ b/tests/test_hermes_logging.py @@ -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 diff --git a/tools/debug_helpers.py b/tools/debug_helpers.py index f1934fd5be..0bd5f2ac56 100644 --- a/tools/debug_helpers.py +++ b/tools/debug_helpers.py @@ -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)