Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
84 changes: 81 additions & 3 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 11 additions & 78 deletions hermes_cli/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions locales/af.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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})_"

Expand Down
1 change: 1 addition & 0 deletions locales/de.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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})_"

Expand Down
1 change: 1 addition & 0 deletions locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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})_"

Expand Down
1 change: 1 addition & 0 deletions locales/es.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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})_"

Expand Down
1 change: 1 addition & 0 deletions locales/fr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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})_"

Expand Down
Loading
Loading