diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 504d1a08fe08..8a0e37fa7edc 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -1022,6 +1022,7 @@ display: # new: Show a tool indicator only when the tool changes (skip repeats) # all: Show every tool call with a short preview (default) # verbose: Full args, results, and debug logs (same as /verbose) + # log: Silent in chat; append every tool call to ~/.hermes/logs/tool_calls.log (gateway only) # Toggle at runtime with /verbose in the CLI tool_progress: all diff --git a/gateway/run.py b/gateway/run.py index 37c84eca13c3..63f3f406266c 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -16161,7 +16161,11 @@ def _run_still_current() -> bool: # Disable tool progress for webhooks - they don't support message editing, # so each progress line would be sent as a separate message. from gateway.config import Platform - tool_progress_enabled = progress_mode != "off" and source.platform != Platform.WEBHOOK + tool_progress_enabled = progress_mode not in {"off", "log"} and source.platform != Platform.WEBHOOK + # "log" mode: tool calls are written to ~/.hermes/logs/tool_calls.log + # instead of the chat (#3459 / #3458). Gateway-only by design. + log_mode_enabled = progress_mode == "log" and source.platform != Platform.WEBHOOK + log_queue: "queue.Queue | None" = queue.Queue() if log_mode_enabled else None # Natural assistant status messages are intentionally independent from # tool progress and token streaming. Users can keep tool_progress quiet # in chat platforms while opting into concise mid-turn updates. @@ -16266,6 +16270,16 @@ def voice_ack_callback(call_id, tool_name, args): def progress_callback(event_type: str, tool_name: str = None, preview: str = None, args: dict = None, **kwargs): """Callback invoked by agent on tool lifecycle events.""" + # "log" mode: append tool.started lines to the log queue and stay + # silent in chat. Handled before the progress_queue guard because + # log mode runs without a chat progress queue. + if log_queue is not None: + if event_type == "tool.started" and tool_name and tool_name != "_thinking": + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + preview_str = f' "{preview}"' if preview else "" + log_queue.put(f"{ts} {tool_name}:{preview_str}".rstrip()) + if not progress_queue: + return if not progress_queue or not _run_still_current(): return @@ -16493,6 +16507,61 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non else None ) + async def write_tool_log(): + """Drain log_queue and append tool-call lines to tool_calls.log. + + Only active when ``display.tool_progress`` is ``log``. Uses a + RotatingFileHandler (5MB × 3 backups) so the audit log can't grow + unbounded, and the shared RedactingFormatter so secrets never land + on disk. + """ + if log_queue is None: + return + from logging.handlers import RotatingFileHandler + + from agent.redact import RedactingFormatter + + log_dir = _hermes_home / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + file_handler = RotatingFileHandler( + log_dir / "tool_calls.log", + maxBytes=5 * 1024 * 1024, + backupCount=3, + encoding="utf-8", + ) + file_handler.setFormatter(RedactingFormatter("%(message)s")) + tool_logger = logging.getLogger(f"hermes.tool_calls.{id(log_queue)}") + tool_logger.setLevel(logging.INFO) + tool_logger.propagate = False + tool_logger.addHandler(file_handler) + try: + while True: + try: + tool_logger.info("%s", log_queue.get_nowait()) + except queue.Empty: + await asyncio.sleep(0.3) + except Exception as e: + logger.error("write_tool_log error: %s", e) + await asyncio.sleep(1) + except asyncio.CancelledError: + pass + finally: + # Drain remaining entries before closing so late tool calls + # from the final iteration aren't lost. + while True: + try: + tool_logger.info("%s", log_queue.get_nowait()) + except queue.Empty: + break + except Exception: + break + tool_logger.removeHandler(file_handler) + try: + file_handler.flush() + file_handler.close() + except Exception: + pass + async def send_progress_messages(): if not progress_queue: return @@ -17287,7 +17356,9 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: # who set thinking_progress:true but kept tool_progress:off got a # None callback — so _thinking scratch bubbles never relayed even # though the progress queue was created for them. - agent.tool_progress_callback = progress_callback if needs_progress_queue else None + agent.tool_progress_callback = ( + progress_callback if (needs_progress_queue or log_mode_enabled) else None + ) # Discord voice verbal-ack hook (fires once per turn on first tool # call; armed only when in a voice channel with the mixer running). agent.tool_start_callback = ( @@ -18120,6 +18191,11 @@ def _title_failure_cb(task: str, exc: BaseException) -> None: if needs_progress_queue: progress_task = asyncio.create_task(send_progress_messages()) + # Start the tool-call log writer when tool_progress == "log". + log_task = None + if log_mode_enabled: + log_task = asyncio.create_task(write_tool_log()) + # Start stream consumer task — polls for consumer creation since it # happens inside run_sync (thread pool) after the agent is constructed. stream_task = None @@ -18871,6 +18947,8 @@ def _stream_confirmed_final_delivery( # Stop progress sender, interrupt monitor, and notification task if progress_task: progress_task.cancel() + if log_task: + log_task.cancel() interrupt_monitor.cancel() _notify_task.cancel() @@ -18917,7 +18995,7 @@ def _stream_confirmed_final_delivery( self._update_runtime_status("draining") # Wait for cancelled tasks - for task in [progress_task, interrupt_monitor, tracking_task, _notify_task]: + for task in [progress_task, log_task, interrupt_monitor, tracking_task, _notify_task]: if task: try: await task diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 22c11e59e9de..199e792940ba 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2899,12 +2899,13 @@ async def _handle_verbose_command(self, event: MessageEvent) -> str: return t("gateway.verbose.not_enabled") # --- cycle mode (per-platform) ---------------------------------------- - cycle = ["off", "new", "all", "verbose"] + cycle = ["off", "new", "all", "verbose", "log"] descriptions = { "off": t("gateway.verbose.mode_off"), "new": t("gateway.verbose.mode_new"), "all": t("gateway.verbose.mode_all"), "verbose": t("gateway.verbose.mode_verbose"), + "log": t("gateway.verbose.mode_log"), } # Read current effective mode for this platform via the resolver diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 8e69f640d0f1..fc8c0a1da3c7 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -144,7 +144,7 @@ class CommandDef: CommandDef("timestamps", "Toggle [HH:MM] timestamps on messages and /history", "Configuration", cli_only=True, args_hint="[on|off|status]", subcommands=("on", "off", "status"), aliases=("ts",)), - CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose", + CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose -> log", "Configuration", cli_only=True, gateway_config_gate="display.tool_progress_command"), CommandDef("footer", "Toggle gateway runtime-metadata footer on final replies", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 4dc42e702ba1..cd3587468a7a 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -4258,7 +4258,7 @@ def _ensure_hermes_home_managed(home: Path): "category": "setting", }, # HERMES_TOOL_PROGRESS and HERMES_TOOL_PROGRESS_MODE are deprecated — - # now configured via display.tool_progress in config.yaml (off|new|all|verbose). + # now configured via display.tool_progress in config.yaml (off|new|all|verbose|log). # The gateway still falls back to these env vars for backward compatibility, # so they live in _EXTRA_ENV_KEYS (known to .env sanitization/reload) but # are intentionally NOT listed here: OPTIONAL_ENV_VARS feeds user-facing diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 701c53d7a71d..e09a410fb3b6 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -93,6 +93,11 @@ def _supports_same_provider_pool_setup(provider: str) -> bool: "gemini-3.1-pro-preview", "gemini-3-pro-preview", "gemini-3-flash-preview", "gemini-3.1-flash-lite-preview", ], + "vertex": [ + "google/gemini-3.1-pro-preview", "google/gemini-3-pro-preview", + "google/gemini-3-flash-preview", "google/gemini-3.1-flash-lite-preview", + "google/gemini-2.5-pro", "google/gemini-2.5-flash", + ], "zai": ["glm-5.2", "glm-5.1", "glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"], "kimi-coding": ["kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"], "kimi-coding-cn": ["kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"], @@ -668,7 +673,7 @@ def _print_setup_summary(config: dict, hermes_home): def _prompt_container_resources(config: dict): - """Prompt for container resource settings (Docker, Singularity, Modal, Daytona, Tenki).""" + """Prompt for container resource settings (Docker, Singularity, Modal, Daytona).""" terminal = config.setdefault("terminal", {}) print() @@ -1178,12 +1183,11 @@ def setup_terminal_backend(config: dict): "Modal - serverless cloud sandbox", "SSH - run on a remote machine", "Daytona - persistent cloud development environment", - "Tenki Agent - Tenki cloud sandbox", ] - idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona", 5: "tenki"} - backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4, "tenki": 5} + idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona"} + backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4} - next_idx = 6 + next_idx = 5 if is_linux: terminal_choices.append("Singularity/Apptainer - HPC-friendly container") idx_to_backend[next_idx] = "singularity" @@ -1400,78 +1404,6 @@ def setup_terminal_backend(config: dict): "daytona_image", "nikolaik/python-nodejs:python3.11-nodejs20" ) - elif selected_backend == "tenki": - print_success("Terminal backend: Tenki Agent") - print_info("Cloud sandboxes are created on demand and terminated by default.") - print_info("Requires Tenki CLI login or TENKI_AUTH_TOKEN/TENKI_API_KEY.") - - try: - __import__("tenki_sandbox") - except ImportError: - print_info("Installing Tenki SDK...") - import subprocess - - uv_bin = shutil.which("uv") - package = "tenki-sandbox==0.1.1" - if uv_bin: - result = subprocess.run( - [uv_bin, "pip", "install", "--python", sys.executable, package], - capture_output=True, - text=True, - ) - else: - result = subprocess.run( - [sys.executable, "-m", "pip", "install", package], - capture_output=True, - text=True, - ) - if result.returncode == 0: - print_success("Tenki SDK installed") - else: - print_warning("Install failed — run manually: pip install tenki-sandbox==0.1.1") - if result.stderr: - print_info(f" Error: {result.stderr.strip().splitlines()[-1]}") - - from tools.tenki_config import ( - has_tenki_auth, - resolve_tenki_api_endpoint, - resolve_tenki_project_id, - resolve_tenki_workspace_id, - ) - - terminal = config.setdefault("terminal", {}) - endpoint = resolve_tenki_api_endpoint(terminal.get("tenki_api_endpoint", "")) - workspace_id = resolve_tenki_workspace_id(terminal.get("tenki_workspace_id", "")) - project_id = resolve_tenki_project_id(terminal.get("tenki_project_id", "")) - - terminal["tenki_api_endpoint"] = endpoint - if workspace_id: - terminal["tenki_workspace_id"] = workspace_id - if project_id: - terminal["tenki_project_id"] = project_id - terminal.setdefault("tenki_image", "") - terminal.setdefault("tenki_name_prefix", "hermes") - terminal["tenki_allow_inbound"] = False - terminal["tenki_allow_outbound"] = True - terminal.setdefault("tenki_max_duration", 3600) - terminal.setdefault("tenki_idle_timeout", 0) - terminal.setdefault("tenki_pause_retention", 0) - terminal.setdefault("tenki_sync_hermes_home", False) - terminal["container_persistent"] = False - terminal["cwd"] = "/home/tenki" - - print_info(f" Endpoint: {endpoint}") - print_info(f" Workspace: {workspace_id or '(not found; run tenki login)'}") - print_info(f" Project: {project_id or '(not found; run tenki login)'}") - if has_tenki_auth(): - print_info(" Tenki auth: already configured") - else: - print_warning(" Tenki auth not found") - token = prompt(" Tenki token/API key (optional; leave blank to run tenki login)", password=True) - if token: - save_env_value("TENKI_API_KEY", token) - print_success(" Configured") - elif selected_backend == "ssh": print_success("Terminal backend: SSH") print_info("Run commands on a remote machine via SSH.") @@ -1604,10 +1536,11 @@ def setup_agent_settings(config: dict): print_info(" new — Show tool name only when it changes (less noise)") print_info(" all — Show every tool call with a short preview") print_info(" verbose — Full args, results, and debug logs") + print_info(" log — Silent in chat; write every tool call to ~/.hermes/logs/tool_calls.log (gateway only)") current_mode = cfg_get(config, "display", "tool_progress", default="all") mode = prompt("Tool progress mode", current_mode) - if mode.lower() in {"off", "new", "all", "verbose"}: + if mode.lower() in {"off", "new", "all", "verbose", "log"}: if "display" not in config: config["display"] = {} config["display"]["tool_progress"] = mode.lower() diff --git a/locales/af.yaml b/locales/af.yaml index 4d37faa2f031..a29f97dbc7a1 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -367,6 +367,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ Gereedskap-vordering: **NUUT** — vertoon wanneer gereedskap verander (voorskoulengte: `display.tool_preview_length`, verstek 40)." mode_all: "⚙️ Gereedskap-vordering: **ALMAL** — elke gereedskaps-oproep vertoon (voorskoulengte: `display.tool_preview_length`, verstek 40)." mode_verbose: "⚙️ Gereedskap-vordering: **OMSLAGTIG** — elke gereedskaps-oproep met volle argumente." + mode_log: "⚙️ Gereedskap-vordering: **LOG** — stil in die klets; gereedskaps-oproepe word na ~/.hermes/logs/tool_calls.log geskryf." saved_suffix: "_(gestoor vir **{platform}** — neem effek by die volgende boodskap)_" save_failed: "_(kon nie in konfigurasie stoor nie: {error})_" diff --git a/locales/de.yaml b/locales/de.yaml index 4b402a077d75..db6174f42a64 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -367,6 +367,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ Tool-Fortschritt: **NEW** — angezeigt bei Tool-Wechsel (Vorschaulänge: `display.tool_preview_length`, Standard 40)." mode_all: "⚙️ Tool-Fortschritt: **ALL** — jeder Tool-Aufruf wird angezeigt (Vorschaulänge: `display.tool_preview_length`, Standard 40)." mode_verbose: "⚙️ Tool-Fortschritt: **VERBOSE** — jeder Tool-Aufruf mit vollständigen Argumenten." + mode_log: "⚙️ Tool-Fortschritt: **LOG** — still im Chat; Tool-Aufrufe werden in ~/.hermes/logs/tool_calls.log geschrieben." saved_suffix: "_(für **{platform}** gespeichert — wird ab nächster Nachricht wirksam)_" save_failed: "_(konnte nicht in der Konfiguration gespeichert werden: {error})_" diff --git a/locales/en.yaml b/locales/en.yaml index 02730f27475f..9a9bf2067c2c 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -379,6 +379,7 @@ gateway: mode_new: "⚙️ Tool progress: **NEW** — shown when tool changes (preview length: `display.tool_preview_length`, default 40)." mode_all: "⚙️ Tool progress: **ALL** — every tool call shown (preview length: `display.tool_preview_length`, default 40)." mode_verbose: "⚙️ Tool progress: **VERBOSE** — every tool call with full arguments." + mode_log: "⚙️ Tool progress: **LOG** — silent in chat; tool calls written to ~/.hermes/logs/tool_calls.log." saved_suffix: "_(saved for **{platform}** — takes effect on next message)_" save_failed: "_(could not save to config: {error})_" diff --git a/locales/es.yaml b/locales/es.yaml index 348f575ac597..e23e53f56e76 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -364,6 +364,7 @@ gateway: mode_new: "⚙️ Progreso de herramientas: **NEW** — se muestra al cambiar de herramienta (longitud de vista previa: `display.tool_preview_length`, por defecto 40)." mode_all: "⚙️ Progreso de herramientas: **ALL** — se muestra cada llamada a herramienta (longitud de vista previa: `display.tool_preview_length`, por defecto 40)." mode_verbose: "⚙️ Progreso de herramientas: **VERBOSE** — cada llamada a herramienta con sus argumentos completos." + mode_log: "⚙️ Progreso de herramientas: **LOG** — silencioso en el chat; las llamadas a herramientas se escriben en ~/.hermes/logs/tool_calls.log." saved_suffix: "_(guardado para **{platform}** — se aplica en el próximo mensaje)_" save_failed: "_(no se pudo guardar en la configuración: {error})_" diff --git a/locales/fr.yaml b/locales/fr.yaml index 98392cbc6662..7fb48aaa6f1c 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -367,6 +367,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ Progression des outils : **NEW** — affichée lors d'un changement d'outil (longueur d'aperçu : `display.tool_preview_length`, par défaut 40)." mode_all: "⚙️ Progression des outils : **ALL** — chaque appel d'outil est affiché (longueur d'aperçu : `display.tool_preview_length`, par défaut 40)." mode_verbose: "⚙️ Progression des outils : **VERBOSE** — chaque appel d'outil avec ses arguments complets." + mode_log: "⚙️ Progression des outils : **LOG** — silencieux dans le chat ; les appels d'outils sont écrits dans ~/.hermes/logs/tool_calls.log." saved_suffix: "_(enregistré pour **{platform}** — prend effet au prochain message)_" save_failed: "_(impossible d'enregistrer dans la configuration : {error})_" diff --git a/locales/ga.yaml b/locales/ga.yaml index 1d0c65611229..5ca740fcaefd 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -371,6 +371,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ Dul chun cinn uirlise: **NUA** — taispeánta nuair a athraíonn an uirlis (fad réamhamhairc: `display.tool_preview_length`, réamhshocrú 40)." mode_all: "⚙️ Dul chun cinn uirlise: **GACH CEANN** — taispeántar gach glao uirlise (fad réamhamhairc: `display.tool_preview_length`, réamhshocrú 40)." mode_verbose: "⚙️ Dul chun cinn uirlise: **BÉALSCAOILTE** — gach glao uirlise le hargóintí iomlána." + mode_log: "⚙️ Dul chun cinn uirlise: **LOG** — ciúin sa chomhrá; scríobhtar glaonna uirlise chuig ~/.hermes/logs/tool_calls.log." saved_suffix: "_(sábháilte do **{platform}** — éifeachtach ón gcéad teachtaireacht eile)_" save_failed: "_(níorbh fhéidir sábháil sa chumraíocht: {error})_" diff --git a/locales/hu.yaml b/locales/hu.yaml index 028a863a9ff4..02a877724862 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -367,6 +367,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ Eszközfolyamat: **NEW** — eszközváltáskor jelenik meg (előnézet hossza: `display.tool_preview_length`, alapértelmezetten 40)." mode_all: "⚙️ Eszközfolyamat: **ALL** — minden eszközhívás megjelenik (előnézet hossza: `display.tool_preview_length`, alapértelmezetten 40)." mode_verbose: "⚙️ Eszközfolyamat: **VERBOSE** — minden eszközhívás teljes argumentumokkal." + mode_log: "⚙️ Eszközfolyamat: **LOG** — csendes a csevegésben; az eszközhívások a ~/.hermes/logs/tool_calls.log fájlba íródnak." saved_suffix: "_(elmentve ehhez: **{platform}** — a következő üzenettől lép életbe)_" save_failed: "_(nem sikerült menteni a konfigurációba: {error})_" diff --git a/locales/it.yaml b/locales/it.yaml index 9d7281637360..d3bff53b53e0 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -367,6 +367,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ Progresso strumenti: **NEW** — mostrato quando lo strumento cambia (lunghezza anteprima: `display.tool_preview_length`, predefinito 40)." mode_all: "⚙️ Progresso strumenti: **ALL** — ogni chiamata a uno strumento viene mostrata (lunghezza anteprima: `display.tool_preview_length`, predefinito 40)." mode_verbose: "⚙️ Progresso strumenti: **VERBOSE** — ogni chiamata a uno strumento con argomenti completi." + mode_log: "⚙️ Progresso strumenti: **LOG** — silenzioso in chat; le chiamate agli strumenti vengono scritte in ~/.hermes/logs/tool_calls.log." saved_suffix: "_(salvato per **{platform}** — verrà applicato al prossimo messaggio)_" save_failed: "_(impossibile salvare nella configurazione: {error})_" diff --git a/locales/ja.yaml b/locales/ja.yaml index a1f9549edc1d..7bce7e41eb9a 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -367,6 +367,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ ツール進捗: **NEW** — ツールが変わったときに表示 (プレビュー長: `display.tool_preview_length`、デフォルト 40)。" mode_all: "⚙️ ツール進捗: **ALL** — すべてのツール呼び出しを表示 (プレビュー長: `display.tool_preview_length`、デフォルト 40)。" mode_verbose: "⚙️ ツール進捗: **VERBOSE** — すべてのツール呼び出しを完全な引数とともに表示。" + mode_log: "⚙️ ツール進捗: **LOG** — チャットには表示せず、ツール呼び出しを ~/.hermes/logs/tool_calls.log に記録します。" saved_suffix: "_(**{platform}** に保存しました — 次のメッセージから有効)_" save_failed: "_(設定に保存できませんでした: {error})_" diff --git a/locales/ko.yaml b/locales/ko.yaml index 97a6fb1554dd..9fcce0d9a688 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -367,6 +367,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ 도구 진행 상황: **NEW** — 도구가 변경될 때 표시됩니다 (미리보기 길이: `display.tool_preview_length`, 기본 40)." mode_all: "⚙️ 도구 진행 상황: **ALL** — 모든 도구 호출이 표시됩니다 (미리보기 길이: `display.tool_preview_length`, 기본 40)." mode_verbose: "⚙️ 도구 진행 상황: **VERBOSE** — 모든 도구 호출이 전체 인수와 함께 표시됩니다." + mode_log: "⚙️ 도구 진행 상황: **LOG** — 채팅에는 표시되지 않으며 도구 호출이 ~/.hermes/logs/tool_calls.log에 기록됩니다." saved_suffix: "_(**{platform}**에 저장됨 — 다음 메시지부터 적용됩니다)_" save_failed: "_(설정에 저장할 수 없습니다: {error})_" diff --git a/locales/pt.yaml b/locales/pt.yaml index 1bb6d8df5fa6..a406e3de7695 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -367,6 +367,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ Progresso de ferramentas: **NEW** — mostrado quando a ferramenta muda (comprimento da pré-visualização: `display.tool_preview_length`, predefinição 40)." mode_all: "⚙️ Progresso de ferramentas: **ALL** — cada chamada de ferramenta é mostrada (comprimento da pré-visualização: `display.tool_preview_length`, predefinição 40)." mode_verbose: "⚙️ Progresso de ferramentas: **VERBOSE** — cada chamada de ferramenta com os argumentos completos." + mode_log: "⚙️ Progresso de ferramentas: **LOG** — silencioso no chat; as chamadas de ferramentas são gravadas em ~/.hermes/logs/tool_calls.log." saved_suffix: "_(guardado para **{platform}** — produz efeito na próxima mensagem)_" save_failed: "_(não foi possível guardar na configuração: {error})_" diff --git a/locales/ru.yaml b/locales/ru.yaml index ebdb0fac90f4..995335b2804b 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -367,6 +367,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ Прогресс инструментов: **NEW** — показывается при смене инструмента (длина предпросмотра: `display.tool_preview_length`, по умолчанию 40)." mode_all: "⚙️ Прогресс инструментов: **ALL** — показывается каждый вызов инструмента (длина предпросмотра: `display.tool_preview_length`, по умолчанию 40)." mode_verbose: "⚙️ Прогресс инструментов: **VERBOSE** — каждый вызов инструмента с полными аргументами." + mode_log: "⚙️ Прогресс инструментов: **LOG** — тихо в чате; вызовы инструментов записываются в ~/.hermes/logs/tool_calls.log." saved_suffix: "_(сохранено для **{platform}** — вступит в силу со следующего сообщения)_" save_failed: "_(не удалось сохранить в конфигурацию: {error})_" diff --git a/locales/tr.yaml b/locales/tr.yaml index 47da18f21605..5e67a951eec1 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -367,6 +367,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ Araç ilerlemesi: **NEW** — araç değiştiğinde gösterilir (önizleme uzunluğu: `display.tool_preview_length`, varsayılan 40)." mode_all: "⚙️ Araç ilerlemesi: **ALL** — her araç çağrısı gösterilir (önizleme uzunluğu: `display.tool_preview_length`, varsayılan 40)." mode_verbose: "⚙️ Araç ilerlemesi: **VERBOSE** — her araç çağrısı tüm argümanlarıyla gösterilir." + mode_log: "⚙️ Araç ilerlemesi: **LOG** — sohbette sessiz; araç çağrıları ~/.hermes/logs/tool_calls.log dosyasına yazılır." saved_suffix: "_(**{platform}** için kaydedildi — sonraki mesajda geçerli olur)_" save_failed: "_(yapılandırmaya kaydedilemedi: {error})_" diff --git a/locales/uk.yaml b/locales/uk.yaml index 7a84c9c2daff..8e9123450756 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -367,6 +367,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ Прогрес інструментів: **NEW** — показується при зміні інструмента (довжина попереднього перегляду: `display.tool_preview_length`, за замовчуванням 40)." mode_all: "⚙️ Прогрес інструментів: **ALL** — показується кожен виклик інструмента (довжина попереднього перегляду: `display.tool_preview_length`, за замовчуванням 40)." mode_verbose: "⚙️ Прогрес інструментів: **VERBOSE** — кожен виклик інструмента з повними аргументами." + mode_log: "⚙️ Прогрес інструментів: **LOG** — тихо в чаті; виклики інструментів записуються до ~/.hermes/logs/tool_calls.log." saved_suffix: "_(збережено для **{platform}** — набуде чинності з наступного повідомлення)_" save_failed: "_(не вдалося зберегти у конфігурацію: {error})_" diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index 7df78f5b4e42..1190e23d000a 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -367,6 +367,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ 工具進度:**NEW** — 工具變更時顯示(預覽長度:`display.tool_preview_length`,預設 40)。" mode_all: "⚙️ 工具進度:**ALL** — 顯示每次工具呼叫(預覽長度:`display.tool_preview_length`,預設 40)。" mode_verbose: "⚙️ 工具進度:**VERBOSE** — 顯示每次工具呼叫及完整參數。" + mode_log: "⚙️ 工具進度:**LOG** — 聊天中保持靜默;工具呼叫寫入 ~/.hermes/logs/tool_calls.log。" saved_suffix: "_(已為 **{platform}** 儲存 — 下一則訊息生效)_" save_failed: "_(無法儲存到設定:{error})_" diff --git a/locales/zh.yaml b/locales/zh.yaml index 958d8779f51f..e8444394a529 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -367,6 +367,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ 工具进度:**NEW** — 工具变化时显示(预览长度:`display.tool_preview_length`,默认 40)。" mode_all: "⚙️ 工具进度:**ALL** — 显示每次工具调用(预览长度:`display.tool_preview_length`,默认 40)。" mode_verbose: "⚙️ 工具进度:**VERBOSE** — 显示每次工具调用及完整参数。" + mode_log: "⚙️ 工具进度:**LOG** — 聊天中保持静默;工具调用写入 ~/.hermes/logs/tool_calls.log。" saved_suffix: "_(已为 **{platform}** 保存 — 下一条消息生效)_" save_failed: "_(无法保存到配置:{error})_" diff --git a/scripts/release.py b/scripts/release.py index 20a1b80de701..0c5a481e9e45 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -46,6 +46,9 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { "ai-lab@foxmail.com": "CrazyBoyM", # PR #55828 salvage (image_gen openai-codex: wire image-to-image / reference-image editing via Codex Responses input_image parts; magic-byte + read-guard + 25MB-cap + clamp-to-16 hardening) + "r0gersm1th@users.noreply.github.com": "r0gersm1th", # PR #3219 salvage (whatsapp bridge: resolve LID sender IDs to phone numbers in the message payload so phone-based allowlists match; commit authored by collaborator r0gersm1th, PR by @ajmeese7) + "louis@letsfive.io": "Mibayy", # PR #3296 salvage (status: provider label honors config.yaml model.base_url, not just OPENAI_BASE_URL env) + "me@keslerm.com": "keslerm", # PR #3459 salvage (gateway: 'log' tool_progress mode — silent in chat, tool calls appended to ~/.hermes/logs/tool_calls.log via rotating handler; duplicate of #3458 by @dlkakbs who submitted 4 min earlier — both credited) "tarunravi@gmail.com": "tarunravi", # PR #2696 salvage (api-server: inline MEDIA: image tags as base64 data URLs in final responses so remote OpenAI-compatible frontends can render server-local screenshots; the PR's tool-progress-streaming and SSE-sentinel pieces were independently superseded on main) "aqdrgg19@gmail.com": "VolodymyrBg", # PR #2861 salvage (webhook: drop the unused full request payload from retained _delivery_info entries — up to ~1MB dead weight per delivery for the 1h idempotency TTL) "ohyes9711@gmail.com": "CharmingGroot", # PR #2794 salvage (email: guard msg_data[0][1] against malformed IMAP fetch structures so one bad response can't abort the batch and permanently lose seen-marked messages; Message-ID domain falls back to localhost when EMAIL_ADDRESS lacks '@') diff --git a/tests/gateway/test_tool_log_mode.py b/tests/gateway/test_tool_log_mode.py new file mode 100644 index 000000000000..0848718e33fb --- /dev/null +++ b/tests/gateway/test_tool_log_mode.py @@ -0,0 +1,107 @@ +"""Tests for the `log` tool_progress mode (salvage of #3459 / #3458). + +`display.tool_progress: log` keeps the chat silent and appends tool-call +lines to ~/.hermes/logs/tool_calls.log via write_tool_log's rotating handler. +These tests exercise the mode's building blocks without spinning up a full +gateway run: the callback log-branch semantics and the writer coroutine. +""" + +import asyncio +import queue +from datetime import datetime + +import pytest + + +def _log_branch(log_queue, progress_queue, event_type, tool_name, preview=None): + """Replica of the log-mode branch in gateway/run.py progress_callback.""" + if log_queue is not None: + if event_type == "tool.started" and tool_name and tool_name != "_thinking": + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + preview_str = f' "{preview}"' if preview else "" + log_queue.put(f"{ts} {tool_name}:{preview_str}".rstrip()) + if not progress_queue: + return "returned" + return "fell-through" + + +class TestLogBranchSemantics: + def test_tool_started_enqueued(self): + q = queue.Queue() + assert _log_branch(q, None, "tool.started", "terminal", "ls -la") == "returned" + line = q.get_nowait() + assert "terminal" in line and "ls -la" in line + + def test_tool_completed_not_enqueued(self): + q = queue.Queue() + _log_branch(q, None, "tool.completed", "terminal") + assert q.empty() + + def test_thinking_not_enqueued(self): + q = queue.Queue() + _log_branch(q, None, "tool.started", "_thinking", "pondering") + assert q.empty() + + def test_no_preview_line_has_no_quotes(self): + q = queue.Queue() + _log_branch(q, None, "tool.started", "todo") + line = q.get_nowait() + assert line.endswith("todo:") + assert '"' not in line + + def test_log_none_falls_through(self): + assert _log_branch(None, None, "tool.started", "terminal") == "fell-through" + + +@pytest.mark.asyncio +async def test_write_tool_log_writes_and_rotates_handler(tmp_path, monkeypatch): + """The writer coroutine drains the queue into logs/tool_calls.log.""" + import gateway.run as gateway_run + + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + log_queue: queue.Queue = queue.Queue() + log_queue.put("2026-07-02 10:00:00 terminal: \"echo hi\"") + log_queue.put("2026-07-02 10:00:01 read_file: \"foo.py\"") + + # Minimal inline copy of write_tool_log wiring (the real coroutine is a + # closure inside _run_agent); exercise the same handler configuration. + import logging + from logging.handlers import RotatingFileHandler + + from agent.redact import RedactingFormatter + + log_dir = tmp_path / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + handler = RotatingFileHandler( + log_dir / "tool_calls.log", maxBytes=5 * 1024 * 1024, backupCount=3, + encoding="utf-8", + ) + handler.setFormatter(RedactingFormatter("%(message)s")) + tool_logger = logging.getLogger(f"hermes.tool_calls.test.{id(log_queue)}") + tool_logger.setLevel(logging.INFO) + tool_logger.propagate = False + tool_logger.addHandler(handler) + try: + while True: + try: + tool_logger.info("%s", log_queue.get_nowait()) + except queue.Empty: + break + finally: + tool_logger.removeHandler(handler) + handler.flush() + handler.close() + + content = (log_dir / "tool_calls.log").read_text(encoding="utf-8") + assert "terminal" in content + assert "read_file" in content + assert content.count("\n") == 2 + await asyncio.sleep(0) # keep the asyncio marker honest + + +def test_log_mode_disables_chat_progress(): + """tool_progress_enabled must be False in log mode (silent in chat).""" + for mode, expected in [("all", True), ("log", False), ("off", False)]: + enabled = mode not in {"off", "log"} + assert enabled is expected diff --git a/tests/gateway/test_verbose_command.py b/tests/gateway/test_verbose_command.py index 04399b1da508..a5da0e114381 100644 --- a/tests/gateway/test_verbose_command.py +++ b/tests/gateway/test_verbose_command.py @@ -117,8 +117,8 @@ async def test_cycles_through_all_modes(self, tmp_path, monkeypatch): monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) runner = _make_runner() - # off -> new -> all -> verbose -> off - expected = ["new", "all", "verbose", "off"] + # off -> new -> all -> verbose -> log -> off + expected = ["new", "all", "verbose", "log", "off"] for mode in expected: result = await runner._handle_verbose_command(_make_event()) saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))