feat(gateway): add 'log' option to display.tool_progress

Salvage of #3459 by @keslerm, reimplemented against the restructured
progress-callback block in gateway/run.py (resolve_display_setting,
needs_progress_queue, thinking-relay). Duplicate PR #3458 by @dlkakbs was
submitted 4 minutes earlier with the same feature — both credited.

Co-authored-by: Dilee <uzmpsk.dilekakbas@gmail.com>

tool_progress: log keeps the chat silent and appends timestamped tool-call
lines to ~/.hermes/logs/tool_calls.log via a dedicated queue drained by an
async writer (RotatingFileHandler 5MB x 3, RedactingFormatter so secrets
never land on disk). Gateway-only by design; thinking_progress relaying and
the webhook gate are unaffected. /verbose now cycles
off -> new -> all -> verbose -> log.
This commit is contained in:
Morgan K 2026-07-02 04:26:55 -07:00 committed by Teknium
parent 070ac2a719
commit 39bff67957
10 changed files with 199 additions and 9 deletions

View file

@ -16161,7 +16161,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# 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 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
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 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
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 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# 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 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
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 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# 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 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
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

View file

@ -2899,12 +2899,13 @@ class GatewaySlashCommandsMixin:
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