2026-02-02 08:26:42 -08:00
"""
Cron job scheduler - executes due jobs .
2026-02-21 16:21:19 -08:00
Provides tick ( ) which checks for due jobs and runs them . The gateway
calls this every 60 seconds from a background thread .
Uses a file - based lock ( ~ / . hermes / cron / . tick . lock ) so only one tick
runs at a time if multiple processes overlap .
2026-02-02 08:26:42 -08:00
"""
2026-02-22 17:14:44 -08:00
import asyncio
2026-04-02 23:41:38 +05:30
import concurrent . futures
2026-03-14 12:21:50 -07:00
import json
2026-02-21 03:11:11 -08:00
import logging
2026-02-02 08:26:42 -08:00
import os
feat(cron): add script field for pre-run data collection (#5082)
Add an optional 'script' parameter to cron jobs that references a Python
script. The script runs before each agent turn, and its stdout is injected
into the prompt as context. This enables stateful monitoring — the script
handles data collection and change detection, the LLM analyzes and reports.
- cron/jobs.py: add script field to create_job(), stored in job dict
- cron/scheduler.py: add _run_job_script() executor with timeout handling,
inject script output/errors into _build_job_prompt()
- tools/cronjob_tools.py: add script to tool schema, create/update handlers,
_format_job display
- hermes_cli/cron.py: add --script to create/edit, display in list/edit output
- hermes_cli/main.py: add --script argparse for cron create/edit subcommands
- tests/cron/test_cron_script.py: 20 tests covering job CRUD, script
execution, path resolution, error handling, prompt injection, tool API
Script paths can be absolute or relative (resolved against ~/.hermes/scripts/).
Scripts run with a 120s timeout. Failures are injected as error context so
the LLM can report the problem. Empty string clears an attached script.
2026-04-04 10:43:39 -07:00
import subprocess
2026-02-02 08:26:42 -08:00
import sys
2026-02-25 16:27:40 -08:00
# fcntl is Unix-only; on Windows use msvcrt for file locking
try :
import fcntl
except ImportError :
fcntl = None
try :
import msvcrt
except ImportError :
msvcrt = None
2026-04-05 23:49:42 -07:00
import time
2026-02-02 08:26:42 -08:00
from pathlib import Path
from typing import Optional
2026-04-05 23:49:42 -07:00
# Add parent directory to path for imports BEFORE repo-level imports.
# Without this, standalone invocations (e.g. after `hermes update` reloads
# the module) fail with ModuleNotFoundError for hermes_time et al.
sys . path . insert ( 0 , str ( Path ( __file__ ) . parent . parent ) )
from hermes_constants import get_hermes_home
from hermes_cli . config import load_config
2026-03-03 11:57:18 +05:30
from hermes_time import now as _hermes_now
2026-02-21 03:11:11 -08:00
logger = logging . getLogger ( __name__ )
2026-04-05 11:41:38 -07:00
# Valid delivery platforms — used to validate user-supplied platform names
# in cron delivery targets, preventing env var enumeration via crafted names.
_KNOWN_DELIVERY_PLATFORMS = frozenset ( {
" telegram " , " discord " , " slack " , " whatsapp " , " signal " ,
" matrix " , " mattermost " , " homeassistant " , " dingtalk " , " feishu " ,
" wecom " , " sms " , " email " , " webhook " ,
} )
2026-03-27 08:02:58 -07:00
from cron . jobs import get_due_jobs , mark_job_run , save_job_output , advance_next_run
2026-02-02 08:26:42 -08:00
2026-03-17 16:06:49 -07:00
# Sentinel: when a cron agent has nothing new to report, it can start its
# response with this marker to suppress delivery. Output is still saved
# locally for audit.
SILENT_MARKER = " [SILENT] "
2026-02-26 18:51:46 +11:00
# Resolve Hermes home directory (respects HERMES_HOME override)
refactor: consolidate get_hermes_home() and parse_reasoning_effort() (#3062)
Centralizes two widely-duplicated patterns into hermes_constants.py:
1. get_hermes_home() — Path resolution for ~/.hermes (HERMES_HOME env var)
- Was copy-pasted inline across 30+ files as:
Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- Now defined once in hermes_constants.py (zero-dependency module)
- hermes_cli/config.py re-exports it for backward compatibility
- Removed local wrapper functions in honcho_integration/client.py,
tools/website_policy.py, tools/tirith_security.py, hermes_cli/uninstall.py
2. parse_reasoning_effort() — Reasoning effort string validation
- Was copy-pasted in cli.py, gateway/run.py, cron/scheduler.py
- Same validation logic: check against (xhigh, high, medium, low, minimal, none)
- Now defined once in hermes_constants.py, called from all 3 locations
- Warning log for unknown values kept at call sites (context-specific)
31 files changed, net +31 lines (125 insertions, 94 deletions)
Full test suite: 6179 passed, 0 failed
2026-03-25 15:54:28 -07:00
_hermes_home = get_hermes_home ( )
2026-02-26 18:51:46 +11:00
2026-02-21 16:21:19 -08:00
# File-based lock prevents concurrent ticks from gateway + daemon + systemd timer
2026-02-26 18:51:46 +11:00
_LOCK_DIR = _hermes_home / " cron "
2026-02-21 16:21:19 -08:00
_LOCK_FILE = _LOCK_DIR / " .tick.lock "
2026-02-02 08:26:42 -08:00
2026-02-22 17:14:44 -08:00
def _resolve_origin ( job : dict ) - > Optional [ dict ] :
2026-03-11 09:15:34 +01:00
""" Extract origin info from a job, preserving any extra routing metadata. """
2026-02-22 17:14:44 -08:00
origin = job . get ( " origin " )
if not origin :
return None
platform = origin . get ( " platform " )
chat_id = origin . get ( " chat_id " )
if platform and chat_id :
return origin
return None
2026-03-14 19:07:50 -07:00
def _resolve_delivery_target ( job : dict ) - > Optional [ dict ] :
""" Resolve the concrete auto-delivery target for a cron job, if any. """
deliver = job . get ( " deliver " , " local " )
origin = _resolve_origin ( job )
if deliver == " local " :
return None
if deliver == " origin " :
2026-04-05 10:52:29 -07:00
if origin :
return {
" platform " : origin [ " platform " ] ,
" chat_id " : str ( origin [ " chat_id " ] ) ,
" thread_id " : origin . get ( " thread_id " ) ,
}
# Origin missing (e.g. job created via API/script) — try each
# platform's home channel as a fallback instead of silently dropping.
for platform_name in ( " matrix " , " telegram " , " discord " , " slack " ) :
chat_id = os . getenv ( f " { platform_name . upper ( ) } _HOME_CHANNEL " , " " )
if chat_id :
logger . info (
" Job ' %s ' has deliver=origin but no origin; falling back to %s home channel " ,
job . get ( " name " , job . get ( " id " , " ? " ) ) ,
platform_name ,
)
return {
" platform " : platform_name ,
" chat_id " : chat_id ,
" thread_id " : None ,
}
return None
2026-03-14 19:07:50 -07:00
if " : " in deliver :
2026-03-22 04:18:28 -07:00
platform_name , rest = deliver . split ( " : " , 1 )
2026-04-05 02:45:24 +02:00
platform_key = platform_name . lower ( )
from tools . send_message_tool import _parse_target_ref
parsed_chat_id , parsed_thread_id , is_explicit = _parse_target_ref ( platform_key , rest )
if is_explicit :
chat_id , thread_id = parsed_chat_id , parsed_thread_id
2026-03-22 04:18:28 -07:00
else :
chat_id , thread_id = rest , None
2026-03-29 21:24:17 -07:00
# Resolve human-friendly labels like "Alice (dm)" to real IDs.
try :
from gateway . channel_directory import resolve_channel_name
2026-04-05 02:45:24 +02:00
resolved = resolve_channel_name ( platform_key , chat_id )
2026-03-29 21:24:17 -07:00
if resolved :
2026-04-05 02:45:24 +02:00
parsed_chat_id , parsed_thread_id , resolved_is_explicit = _parse_target_ref ( platform_key , resolved )
if resolved_is_explicit :
chat_id , thread_id = parsed_chat_id , parsed_thread_id
else :
chat_id = resolved
2026-03-29 21:24:17 -07:00
except Exception :
pass
2026-03-14 19:07:50 -07:00
return {
" platform " : platform_name ,
" chat_id " : chat_id ,
2026-03-22 04:18:28 -07:00
" thread_id " : thread_id ,
2026-03-14 19:07:50 -07:00
}
platform_name = deliver
if origin and origin . get ( " platform " ) == platform_name :
return {
" platform " : platform_name ,
" chat_id " : str ( origin [ " chat_id " ] ) ,
" thread_id " : origin . get ( " thread_id " ) ,
}
2026-04-05 15:06:06 +03:00
if platform_name . lower ( ) not in _KNOWN_DELIVERY_PLATFORMS :
return None
chat_id = os . getenv ( f " { platform_name . upper ( ) } _HOME_CHANNEL " , " " )
2026-03-14 19:07:50 -07:00
if not chat_id :
return None
return {
" platform " : platform_name ,
" chat_id " : chat_id ,
" thread_id " : None ,
}
2026-04-05 10:52:29 -07:00
def _deliver_result ( job : dict , content : str , adapters = None , loop = None ) - > None :
2026-02-22 17:14:44 -08:00
"""
Deliver job output to the configured target ( origin chat , specific platform , etc . ) .
2026-04-05 10:52:29 -07:00
When ` ` adapters ` ` and ` ` loop ` ` are provided ( gateway is running ) , tries to
use the live adapter first — this supports E2EE rooms ( e . g . Matrix ) where
the standalone HTTP path cannot encrypt . Falls back to standalone send if
the adapter path fails or is unavailable .
2026-02-22 17:14:44 -08:00
"""
2026-03-14 19:07:50 -07:00
target = _resolve_delivery_target ( job )
if not target :
if job . get ( " deliver " , " local " ) != " local " :
logger . warning (
" Job ' %s ' deliver= %s but no concrete delivery target could be resolved " ,
job [ " id " ] ,
job . get ( " deliver " , " local " ) ,
)
2026-02-22 17:14:44 -08:00
return
2026-03-14 19:07:50 -07:00
platform_name = target [ " platform " ]
chat_id = target [ " chat_id " ]
thread_id = target . get ( " thread_id " )
2026-02-22 17:14:44 -08:00
from tools . send_message_tool import _send_to_platform
from gateway . config import load_gateway_config , Platform
platform_map = {
" telegram " : Platform . TELEGRAM ,
" discord " : Platform . DISCORD ,
" slack " : Platform . SLACK ,
" whatsapp " : Platform . WHATSAPP ,
fix: Signal adapter parity pass — integration gaps, clawdbot features, env var simplification
Integration gaps fixed (7 files missing Signal):
- cron/scheduler.py: Signal in platform_map (cron delivery was broken)
- agent/prompt_builder.py: PLATFORM_HINTS for Signal (agent knows it's on Signal)
- toolsets.py: hermes-signal toolset + added to hermes-gateway composite
- hermes_cli/status.py: Signal + Slack in platform status display
- tools/send_message_tool.py: Signal example in target description
- tools/cronjob_tools.py: Signal in delivery option docs + schema
- gateway/channel_directory.py: Signal in session-based channel discovery
Clawdbot parity features added to signal.py:
- Self-message filtering: prevents reply loops by checking sender != account
- SyncMessage filtering: ignores sync envelopes (sent transcripts, read receipts)
- Edit message support: reads dataMessage from editMessage envelope
- Mention rendering: replaces \uFFFC placeholders with @identifier text
- Jitter in SSE reconnection backoff (20% randomization, prevents thundering herd)
Env var simplification (7 → 4):
- Removed SIGNAL_DM_POLICY (DM auth follows standard platform pattern via
SIGNAL_ALLOWED_USERS + DM pairing, same as Telegram/Discord)
- Removed SIGNAL_GROUP_POLICY (derived from SIGNAL_GROUP_ALLOWED_USERS:
not set = disabled, set with IDs = allowlist, set with * = open)
- Removed SIGNAL_DEBUG (was setting root logger, removed entirely)
- Remaining: SIGNAL_HTTP_URL, SIGNAL_ACCOUNT (required),
SIGNAL_ALLOWED_USERS, SIGNAL_GROUP_ALLOWED_USERS (optional)
Updated all docs (website, AGENTS.md, signal.md) to match.
2026-03-08 21:00:21 -07:00
" signal " : Platform . SIGNAL ,
2026-03-20 08:33:46 -05:00
" matrix " : Platform . MATRIX ,
2026-03-20 08:52:21 -07:00
" mattermost " : Platform . MATTERMOST ,
" homeassistant " : Platform . HOMEASSISTANT ,
" dingtalk " : Platform . DINGTALK ,
feat(gateway): add Feishu/Lark platform support (#3817)
Adds Feishu (ByteDance's enterprise messaging platform) as a gateway
platform adapter with full feature parity: WebSocket + webhook transports,
message batching, dedup, rate limiting, rich post/card content parsing,
media handling (images/audio/files/video), group @mention gating,
reaction routing, and interactive card button support.
Cherry-picked from PR #1793 by penwyp with:
- Moved to current main (PR was 458 commits behind)
- Fixed _send_with_retry shadowing BasePlatformAdapter method (renamed to
_feishu_send_with_retry to avoid signature mismatch crash)
- Fixed import structure: aiohttp/websockets imported independently of
lark_oapi so they remain available when SDK is missing
- Fixed get_hermes_home import (hermes_constants, not hermes_cli.config)
- Added skip decorators for tests requiring lark_oapi SDK
- All 16 integration points added surgically to current main
New dependency: lark-oapi>=1.5.3,<2 (optional, pip install hermes-agent[feishu])
Fixes #1788
Co-authored-by: penwyp <penwyp@users.noreply.github.com>
2026-03-29 18:17:42 -07:00
" feishu " : Platform . FEISHU ,
2026-03-29 21:29:13 -07:00
" wecom " : Platform . WECOM ,
feat: add email gateway platform (IMAP/SMTP)
Allow users to interact with Hermes by sending and receiving emails.
Uses IMAP polling for incoming messages and SMTP for replies with
proper threading (In-Reply-To, References headers).
Integrates with all 14 gateway extension points: config, adapter
factory, authorization, send_message tool, cron delivery, toolsets,
prompt hints, channel directory, setup wizard, status display, and
env example.
65 tests covering config, parsing, dispatch, threading, IMAP fetch,
SMTP send, attachments, and all integration points.
2026-03-10 03:15:38 +03:00
" email " : Platform . EMAIL ,
feat: add SMS (Twilio) platform adapter
Add SMS as a first-class messaging platform via the Twilio API.
Shares credentials with the existing telephony skill — same
TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER env vars.
Adapter (gateway/platforms/sms.py):
- aiohttp webhook server for inbound (Twilio form-encoded POSTs)
- Twilio REST API with Basic auth for outbound
- Markdown stripping, smart chunking at 1600 chars
- Echo loop prevention, phone number redaction in logs
Integration (13 files):
- gateway config, run, channel_directory
- agent prompt_builder (SMS platform hint)
- cron scheduler, cronjob tools
- send_message_tool (_send_sms via Twilio API)
- toolsets (hermes-sms + hermes-gateway)
- gateway setup wizard, status display
- pyproject.toml (sms optional extra)
- 21 tests
Docs:
- website/docs/user-guide/messaging/sms.md (full setup guide)
- Updated messaging index (architecture, toolsets, security, links)
- Updated environment-variables.md reference
Inspired by PR #1575 (@sunsakis), rewritten for Twilio.
2026-03-17 03:14:53 -07:00
" sms " : Platform . SMS ,
2026-02-22 17:14:44 -08:00
}
platform = platform_map . get ( platform_name . lower ( ) )
if not platform :
logger . warning ( " Job ' %s ' : unknown platform ' %s ' for delivery " , job [ " id " ] , platform_name )
return
try :
config = load_gateway_config ( )
except Exception as e :
logger . error ( " Job ' %s ' : failed to load gateway config for delivery: %s " , job [ " id " ] , e )
return
pconfig = config . platforms . get ( platform )
if not pconfig or not pconfig . enabled :
logger . warning ( " Job ' %s ' : platform ' %s ' not configured/enabled " , job [ " id " ] , platform_name )
return
2026-03-29 16:31:01 -07:00
# Optionally wrap the content with a header/footer so the user knows this
# is a cron delivery. Wrapping is on by default; set cron.wrap_response: false
# in config.yaml for clean output.
wrap_response = True
try :
user_cfg = load_config ( )
wrap_response = user_cfg . get ( " cron " , { } ) . get ( " wrap_response " , True )
except Exception :
pass
if wrap_response :
task_name = job . get ( " name " , job [ " id " ] )
delivery_content = (
f " Cronjob Response: { task_name } \n "
f " ------------- \n \n "
f " { content } \n \n "
f " Note: The agent cannot see this message, and therefore cannot respond to it. "
)
else :
delivery_content = content
2026-03-21 07:18:36 -07:00
2026-04-06 11:42:44 -07:00
# Extract MEDIA: tags so attachments are forwarded as files, not raw text
from gateway . platforms . base import BasePlatformAdapter
media_files , cleaned_delivery_content = BasePlatformAdapter . extract_media ( delivery_content )
2026-04-05 10:52:29 -07:00
# Prefer the live adapter when the gateway is running — this supports E2EE
# rooms (e.g. Matrix) where the standalone HTTP path cannot encrypt.
runtime_adapter = ( adapters or { } ) . get ( platform )
if runtime_adapter is not None and loop is not None and getattr ( loop , " is_running " , lambda : False ) ( ) :
send_metadata = { " thread_id " : thread_id } if thread_id else None
try :
future = asyncio . run_coroutine_threadsafe (
runtime_adapter . send ( chat_id , delivery_content , metadata = send_metadata ) ,
loop ,
)
send_result = future . result ( timeout = 60 )
if send_result and not getattr ( send_result , " success " , True ) :
err = getattr ( send_result , " error " , " unknown " )
logger . warning (
" Job ' %s ' : live adapter send to %s : %s failed ( %s ), falling back to standalone " ,
job [ " id " ] , platform_name , chat_id , err ,
)
else :
logger . info ( " Job ' %s ' : delivered to %s : %s via live adapter " , job [ " id " ] , platform_name , chat_id )
return
except Exception as e :
logger . warning (
" Job ' %s ' : live adapter delivery to %s : %s failed ( %s ), falling back to standalone " ,
job [ " id " ] , platform_name , chat_id , e ,
)
# Standalone path: run the async send in a fresh event loop (safe from any thread)
2026-04-06 11:42:44 -07:00
coro = _send_to_platform ( platform , pconfig , chat_id , cleaned_delivery_content , thread_id = thread_id , media_files = media_files )
2026-02-22 17:14:44 -08:00
try :
2026-03-21 07:20:58 -07:00
result = asyncio . run ( coro )
2026-02-22 17:14:44 -08:00
except RuntimeError :
2026-03-21 07:20:58 -07:00
# asyncio.run() checks for a running loop before awaiting the coroutine;
# when it raises, the original coro was never started — close it to
# prevent "coroutine was never awaited" RuntimeWarning, then retry in a
# fresh thread that has no running loop.
coro . close ( )
2026-02-22 17:14:44 -08:00
import concurrent . futures
with concurrent . futures . ThreadPoolExecutor ( max_workers = 1 ) as pool :
2026-04-06 11:42:44 -07:00
future = pool . submit ( asyncio . run , _send_to_platform ( platform , pconfig , chat_id , cleaned_delivery_content , thread_id = thread_id , media_files = media_files ) )
2026-02-22 17:14:44 -08:00
result = future . result ( timeout = 30 )
except Exception as e :
logger . error ( " Job ' %s ' : delivery to %s : %s failed: %s " , job [ " id " ] , platform_name , chat_id , e )
return
if result and result . get ( " error " ) :
logger . error ( " Job ' %s ' : delivery error: %s " , job [ " id " ] , result [ " error " ] )
else :
logger . info ( " Job ' %s ' : delivered to %s : %s " , job [ " id " ] , platform_name , chat_id )
feat(cron): add script field for pre-run data collection (#5082)
Add an optional 'script' parameter to cron jobs that references a Python
script. The script runs before each agent turn, and its stdout is injected
into the prompt as context. This enables stateful monitoring — the script
handles data collection and change detection, the LLM analyzes and reports.
- cron/jobs.py: add script field to create_job(), stored in job dict
- cron/scheduler.py: add _run_job_script() executor with timeout handling,
inject script output/errors into _build_job_prompt()
- tools/cronjob_tools.py: add script to tool schema, create/update handlers,
_format_job display
- hermes_cli/cron.py: add --script to create/edit, display in list/edit output
- hermes_cli/main.py: add --script argparse for cron create/edit subcommands
- tests/cron/test_cron_script.py: 20 tests covering job CRUD, script
execution, path resolution, error handling, prompt injection, tool API
Script paths can be absolute or relative (resolved against ~/.hermes/scripts/).
Scripts run with a 120s timeout. Failures are injected as error context so
the LLM can report the problem. Empty string clears an attached script.
2026-04-04 10:43:39 -07:00
_SCRIPT_TIMEOUT = 120 # seconds
def _run_job_script ( script_path : str ) - > tuple [ bool , str ] :
""" Execute a cron job ' s data-collection script and capture its output.
2026-04-06 12:12:45 -07:00
Scripts must reside within HERMES_HOME / scripts / . Both relative and
absolute paths are resolved and validated against this directory to
prevent arbitrary script execution via path traversal or absolute
path injection .
feat(cron): add script field for pre-run data collection (#5082)
Add an optional 'script' parameter to cron jobs that references a Python
script. The script runs before each agent turn, and its stdout is injected
into the prompt as context. This enables stateful monitoring — the script
handles data collection and change detection, the LLM analyzes and reports.
- cron/jobs.py: add script field to create_job(), stored in job dict
- cron/scheduler.py: add _run_job_script() executor with timeout handling,
inject script output/errors into _build_job_prompt()
- tools/cronjob_tools.py: add script to tool schema, create/update handlers,
_format_job display
- hermes_cli/cron.py: add --script to create/edit, display in list/edit output
- hermes_cli/main.py: add --script argparse for cron create/edit subcommands
- tests/cron/test_cron_script.py: 20 tests covering job CRUD, script
execution, path resolution, error handling, prompt injection, tool API
Script paths can be absolute or relative (resolved against ~/.hermes/scripts/).
Scripts run with a 120s timeout. Failures are injected as error context so
the LLM can report the problem. Empty string clears an attached script.
2026-04-04 10:43:39 -07:00
Args :
2026-04-06 12:12:45 -07:00
script_path : Path to a Python script . Relative paths are resolved
against HERMES_HOME / scripts / . Absolute and ~ - prefixed paths
are also validated to ensure they stay within the scripts dir .
feat(cron): add script field for pre-run data collection (#5082)
Add an optional 'script' parameter to cron jobs that references a Python
script. The script runs before each agent turn, and its stdout is injected
into the prompt as context. This enables stateful monitoring — the script
handles data collection and change detection, the LLM analyzes and reports.
- cron/jobs.py: add script field to create_job(), stored in job dict
- cron/scheduler.py: add _run_job_script() executor with timeout handling,
inject script output/errors into _build_job_prompt()
- tools/cronjob_tools.py: add script to tool schema, create/update handlers,
_format_job display
- hermes_cli/cron.py: add --script to create/edit, display in list/edit output
- hermes_cli/main.py: add --script argparse for cron create/edit subcommands
- tests/cron/test_cron_script.py: 20 tests covering job CRUD, script
execution, path resolution, error handling, prompt injection, tool API
Script paths can be absolute or relative (resolved against ~/.hermes/scripts/).
Scripts run with a 120s timeout. Failures are injected as error context so
the LLM can report the problem. Empty string clears an attached script.
2026-04-04 10:43:39 -07:00
Returns :
( success , output ) — on failure * output * contains the error message so the
LLM can report the problem to the user .
"""
from hermes_constants import get_hermes_home
2026-04-06 12:12:45 -07:00
scripts_dir = get_hermes_home ( ) / " scripts "
scripts_dir . mkdir ( parents = True , exist_ok = True )
scripts_dir_resolved = scripts_dir . resolve ( )
raw = Path ( script_path ) . expanduser ( )
if raw . is_absolute ( ) :
path = raw . resolve ( )
else :
path = ( scripts_dir / raw ) . resolve ( )
# Guard against path traversal, absolute path injection, and symlink
# escape — scripts MUST reside within HERMES_HOME/scripts/.
try :
path . relative_to ( scripts_dir_resolved )
except ValueError :
return False , (
f " Blocked: script path resolves outside the scripts directory "
f " ( { scripts_dir_resolved } ): { script_path !r} "
)
feat(cron): add script field for pre-run data collection (#5082)
Add an optional 'script' parameter to cron jobs that references a Python
script. The script runs before each agent turn, and its stdout is injected
into the prompt as context. This enables stateful monitoring — the script
handles data collection and change detection, the LLM analyzes and reports.
- cron/jobs.py: add script field to create_job(), stored in job dict
- cron/scheduler.py: add _run_job_script() executor with timeout handling,
inject script output/errors into _build_job_prompt()
- tools/cronjob_tools.py: add script to tool schema, create/update handlers,
_format_job display
- hermes_cli/cron.py: add --script to create/edit, display in list/edit output
- hermes_cli/main.py: add --script argparse for cron create/edit subcommands
- tests/cron/test_cron_script.py: 20 tests covering job CRUD, script
execution, path resolution, error handling, prompt injection, tool API
Script paths can be absolute or relative (resolved against ~/.hermes/scripts/).
Scripts run with a 120s timeout. Failures are injected as error context so
the LLM can report the problem. Empty string clears an attached script.
2026-04-04 10:43:39 -07:00
if not path . exists ( ) :
return False , f " Script not found: { path } "
if not path . is_file ( ) :
return False , f " Script path is not a file: { path } "
try :
result = subprocess . run (
[ sys . executable , str ( path ) ] ,
capture_output = True ,
text = True ,
timeout = _SCRIPT_TIMEOUT ,
cwd = str ( path . parent ) ,
)
stdout = ( result . stdout or " " ) . strip ( )
stderr = ( result . stderr or " " ) . strip ( )
if result . returncode != 0 :
parts = [ f " Script exited with code { result . returncode } " ]
if stderr :
parts . append ( f " stderr: \n { stderr } " )
if stdout :
parts . append ( f " stdout: \n { stdout } " )
return False , " \n " . join ( parts )
2026-04-04 16:58:15 -07:00
# Redact any secrets that may appear in script output before
# they are injected into the LLM prompt context.
try :
from agent . redact import redact_sensitive_text
stdout = redact_sensitive_text ( stdout )
except Exception :
pass
feat(cron): add script field for pre-run data collection (#5082)
Add an optional 'script' parameter to cron jobs that references a Python
script. The script runs before each agent turn, and its stdout is injected
into the prompt as context. This enables stateful monitoring — the script
handles data collection and change detection, the LLM analyzes and reports.
- cron/jobs.py: add script field to create_job(), stored in job dict
- cron/scheduler.py: add _run_job_script() executor with timeout handling,
inject script output/errors into _build_job_prompt()
- tools/cronjob_tools.py: add script to tool schema, create/update handlers,
_format_job display
- hermes_cli/cron.py: add --script to create/edit, display in list/edit output
- hermes_cli/main.py: add --script argparse for cron create/edit subcommands
- tests/cron/test_cron_script.py: 20 tests covering job CRUD, script
execution, path resolution, error handling, prompt injection, tool API
Script paths can be absolute or relative (resolved against ~/.hermes/scripts/).
Scripts run with a 120s timeout. Failures are injected as error context so
the LLM can report the problem. Empty string clears an attached script.
2026-04-04 10:43:39 -07:00
return True , stdout
except subprocess . TimeoutExpired :
return False , f " Script timed out after { _SCRIPT_TIMEOUT } s: { path } "
except Exception as exc :
return False , f " Script execution failed: { exc } "
2026-03-14 12:21:50 -07:00
def _build_job_prompt ( job : dict ) - > str :
2026-03-14 19:18:10 -07:00
""" Build the effective prompt for a cron job, optionally loading one or more skills first. """
2026-03-14 12:21:50 -07:00
prompt = job . get ( " prompt " , " " )
2026-03-14 19:18:10 -07:00
skills = job . get ( " skills " )
2026-03-17 16:06:49 -07:00
feat(cron): add script field for pre-run data collection (#5082)
Add an optional 'script' parameter to cron jobs that references a Python
script. The script runs before each agent turn, and its stdout is injected
into the prompt as context. This enables stateful monitoring — the script
handles data collection and change detection, the LLM analyzes and reports.
- cron/jobs.py: add script field to create_job(), stored in job dict
- cron/scheduler.py: add _run_job_script() executor with timeout handling,
inject script output/errors into _build_job_prompt()
- tools/cronjob_tools.py: add script to tool schema, create/update handlers,
_format_job display
- hermes_cli/cron.py: add --script to create/edit, display in list/edit output
- hermes_cli/main.py: add --script argparse for cron create/edit subcommands
- tests/cron/test_cron_script.py: 20 tests covering job CRUD, script
execution, path resolution, error handling, prompt injection, tool API
Script paths can be absolute or relative (resolved against ~/.hermes/scripts/).
Scripts run with a 120s timeout. Failures are injected as error context so
the LLM can report the problem. Empty string clears an attached script.
2026-04-04 10:43:39 -07:00
# Run data-collection script if configured, inject output as context.
script_path = job . get ( " script " )
if script_path :
success , script_output = _run_job_script ( script_path )
if success :
if script_output :
prompt = (
" ## Script Output \n "
" The following data was collected by a pre-run script. "
" Use it as context for your analysis. \n \n "
f " ``` \n { script_output } \n ``` \n \n "
f " { prompt } "
)
else :
prompt = (
" [Script ran successfully but produced no output.] \n \n "
f " { prompt } "
)
else :
prompt = (
" ## Script Error \n "
" The data-collection script failed. Report this to the user. \n \n "
f " ``` \n { script_output } \n ``` \n \n "
f " { prompt } "
)
2026-04-05 23:58:45 -07:00
# Always prepend cron execution guidance so the agent knows how
# delivery works and can suppress delivery when appropriate.
cron_hint = (
" [SYSTEM: You are running as a scheduled cron job. "
" DELIVERY: Your final response will be automatically delivered "
" to the user — do NOT use send_message or try to deliver "
" the output yourself. Just produce your report/output as your "
" final response and the system handles the rest. "
" SILENT: If there is genuinely nothing new to report, respond "
" with exactly \" [SILENT] \" (nothing else) to suppress delivery. "
2026-03-30 00:11:00 -07:00
" Never combine [SILENT] with content — either report your "
" findings normally, or say [SILENT] and nothing more.] \n \n "
2026-03-17 16:06:49 -07:00
)
2026-04-05 23:58:45 -07:00
prompt = cron_hint + prompt
2026-03-14 19:18:10 -07:00
if skills is None :
legacy = job . get ( " skill " )
skills = [ legacy ] if legacy else [ ]
skill_names = [ str ( name ) . strip ( ) for name in skills if str ( name ) . strip ( ) ]
if not skill_names :
2026-03-14 12:21:50 -07:00
return prompt
from tools . skills_tool import skill_view
2026-03-14 19:18:10 -07:00
parts = [ ]
2026-03-19 09:56:16 -07:00
skipped : list [ str ] = [ ]
2026-03-14 19:18:10 -07:00
for skill_name in skill_names :
loaded = json . loads ( skill_view ( skill_name ) )
if not loaded . get ( " success " ) :
error = loaded . get ( " error " ) or f " Failed to load skill ' { skill_name } ' "
2026-03-19 09:56:16 -07:00
logger . warning ( " Cron job ' %s ' : skill not found, skipping — %s " , job . get ( " name " , job . get ( " id " ) ) , error )
skipped . append ( skill_name )
continue
2026-03-14 19:18:10 -07:00
content = str ( loaded . get ( " content " ) or " " ) . strip ( )
if parts :
parts . append ( " " )
parts . extend (
[
f ' [SYSTEM: The user has invoked the " { skill_name } " skill, indicating they want you to follow its instructions. The full skill content is loaded below.] ' ,
" " ,
content ,
]
)
2026-03-19 09:56:16 -07:00
if skipped :
notice = (
f " [SYSTEM: The following skill(s) were listed for this job but could not be found "
f " and were skipped: { ' , ' . join ( skipped ) } . "
f " Start your response with a brief notice so the user is aware, e.g.: "
f " ' ⚠️ Skill(s) not found and skipped: { ' , ' . join ( skipped ) } ' ] "
)
parts . insert ( 0 , notice )
2026-03-14 12:21:50 -07:00
if prompt :
parts . extend ( [ " " , f " The user has provided the following instruction alongside the skill invocation: { prompt } " ] )
return " \n " . join ( parts )
2026-02-22 17:14:44 -08:00
def run_job ( job : dict ) - > tuple [ bool , str , str , Optional [ str ] ] :
2026-02-02 08:26:42 -08:00
"""
Execute a single cron job .
Returns :
2026-02-22 17:14:44 -08:00
Tuple of ( success , full_output_doc , final_response , error_message )
2026-02-02 08:26:42 -08:00
"""
from run_agent import AIAgent
2026-03-11 13:11:45 +03:00
# Initialize SQLite session store so cron job messages are persisted
# and discoverable via session_search (same pattern as gateway/run.py).
_session_db = None
try :
from hermes_state import SessionDB
_session_db = SessionDB ( )
except Exception as e :
logger . debug ( " Job ' %s ' : SQLite session store not available: %s " , job . get ( " id " , " ? " ) , e )
2026-02-02 08:26:42 -08:00
job_id = job [ " id " ]
job_name = job [ " name " ]
2026-03-14 12:21:50 -07:00
prompt = _build_job_prompt ( job )
2026-02-22 17:14:44 -08:00
origin = _resolve_origin ( job )
2026-03-25 11:13:21 -07:00
_cron_session_id = f " cron_ { job_id } _ { _hermes_now ( ) . strftime ( ' % Y % m %d _ % H % M % S ' ) } "
2026-03-14 12:21:50 -07:00
2026-02-21 03:11:11 -08:00
logger . info ( " Running job ' %s ' (ID: %s ) " , job_name , job_id )
logger . info ( " Prompt: %s " , prompt [ : 100 ] )
2026-02-22 17:14:44 -08:00
2026-02-02 08:26:42 -08:00
try :
2026-04-06 12:12:45 -07:00
# Inject origin context so the agent's send_message tool knows the chat.
# Must be INSIDE the try block so the finally cleanup always runs.
if origin :
os . environ [ " HERMES_SESSION_PLATFORM " ] = origin [ " platform " ]
os . environ [ " HERMES_SESSION_CHAT_ID " ] = str ( origin [ " chat_id " ] )
if origin . get ( " chat_name " ) :
os . environ [ " HERMES_SESSION_CHAT_NAME " ] = origin [ " chat_name " ]
2026-02-25 02:54:11 -08:00
# Re-read .env and config.yaml fresh every run so provider/key
# changes take effect without a gateway restart.
from dotenv import load_dotenv
2026-02-25 15:20:42 -08:00
try :
2026-02-26 18:51:46 +11:00
load_dotenv ( str ( _hermes_home / " .env " ) , override = True , encoding = " utf-8 " )
2026-02-25 15:20:42 -08:00
except UnicodeDecodeError :
2026-02-26 18:51:46 +11:00
load_dotenv ( str ( _hermes_home / " .env " ) , override = True , encoding = " latin-1 " )
2026-02-25 02:54:11 -08:00
2026-03-14 20:41:58 -07:00
delivery_target = _resolve_delivery_target ( job )
if delivery_target :
os . environ [ " HERMES_CRON_AUTO_DELIVER_PLATFORM " ] = delivery_target [ " platform " ]
os . environ [ " HERMES_CRON_AUTO_DELIVER_CHAT_ID " ] = str ( delivery_target [ " chat_id " ] )
if delivery_target . get ( " thread_id " ) is not None :
os . environ [ " HERMES_CRON_AUTO_DELIVER_THREAD_ID " ] = str ( delivery_target [ " thread_id " ] )
2026-03-29 21:06:35 -07:00
model = job . get ( " model " ) or os . getenv ( " HERMES_MODEL " ) or " "
2026-02-25 02:54:11 -08:00
2026-03-07 11:37:16 -08:00
# Load config.yaml for model, reasoning, prefill, toolsets, provider routing
_cfg = { }
2026-02-25 02:54:11 -08:00
try :
import yaml
2026-02-26 18:51:46 +11:00
_cfg_path = str ( _hermes_home / " config.yaml " )
2026-02-25 02:54:11 -08:00
if os . path . exists ( _cfg_path ) :
with open ( _cfg_path ) as _f :
_cfg = yaml . safe_load ( _f ) or { }
_model_cfg = _cfg . get ( " model " , { } )
2026-03-14 22:22:31 -07:00
if not job . get ( " model " ) :
if isinstance ( _model_cfg , str ) :
model = _model_cfg
elif isinstance ( _model_cfg , dict ) :
model = _model_cfg . get ( " default " , model )
2026-03-09 00:06:34 +03:00
except Exception as e :
logger . warning ( " Job ' %s ' : failed to load config.yaml, using defaults: %s " , job_id , e )
2026-02-25 02:54:11 -08:00
2026-03-07 11:37:16 -08:00
# Reasoning config from env or config.yaml
refactor: consolidate get_hermes_home() and parse_reasoning_effort() (#3062)
Centralizes two widely-duplicated patterns into hermes_constants.py:
1. get_hermes_home() — Path resolution for ~/.hermes (HERMES_HOME env var)
- Was copy-pasted inline across 30+ files as:
Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- Now defined once in hermes_constants.py (zero-dependency module)
- hermes_cli/config.py re-exports it for backward compatibility
- Removed local wrapper functions in honcho_integration/client.py,
tools/website_policy.py, tools/tirith_security.py, hermes_cli/uninstall.py
2. parse_reasoning_effort() — Reasoning effort string validation
- Was copy-pasted in cli.py, gateway/run.py, cron/scheduler.py
- Same validation logic: check against (xhigh, high, medium, low, minimal, none)
- Now defined once in hermes_constants.py, called from all 3 locations
- Warning log for unknown values kept at call sites (context-specific)
31 files changed, net +31 lines (125 insertions, 94 deletions)
Full test suite: 6179 passed, 0 failed
2026-03-25 15:54:28 -07:00
from hermes_constants import parse_reasoning_effort
2026-03-07 11:37:16 -08:00
effort = os . getenv ( " HERMES_REASONING_EFFORT " , " " )
if not effort :
effort = str ( _cfg . get ( " agent " , { } ) . get ( " reasoning_effort " , " " ) ) . strip ( )
refactor: consolidate get_hermes_home() and parse_reasoning_effort() (#3062)
Centralizes two widely-duplicated patterns into hermes_constants.py:
1. get_hermes_home() — Path resolution for ~/.hermes (HERMES_HOME env var)
- Was copy-pasted inline across 30+ files as:
Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- Now defined once in hermes_constants.py (zero-dependency module)
- hermes_cli/config.py re-exports it for backward compatibility
- Removed local wrapper functions in honcho_integration/client.py,
tools/website_policy.py, tools/tirith_security.py, hermes_cli/uninstall.py
2. parse_reasoning_effort() — Reasoning effort string validation
- Was copy-pasted in cli.py, gateway/run.py, cron/scheduler.py
- Same validation logic: check against (xhigh, high, medium, low, minimal, none)
- Now defined once in hermes_constants.py, called from all 3 locations
- Warning log for unknown values kept at call sites (context-specific)
31 files changed, net +31 lines (125 insertions, 94 deletions)
Full test suite: 6179 passed, 0 failed
2026-03-25 15:54:28 -07:00
reasoning_config = parse_reasoning_effort ( effort )
2026-03-07 11:37:16 -08:00
# Prefill messages from env or config.yaml
prefill_messages = None
prefill_file = os . getenv ( " HERMES_PREFILL_MESSAGES_FILE " , " " ) or _cfg . get ( " prefill_messages_file " , " " )
if prefill_file :
import json as _json
pfpath = Path ( prefill_file ) . expanduser ( )
if not pfpath . is_absolute ( ) :
pfpath = _hermes_home / pfpath
if pfpath . exists ( ) :
try :
with open ( pfpath , " r " , encoding = " utf-8 " ) as _pf :
prefill_messages = _json . load ( _pf )
if not isinstance ( prefill_messages , list ) :
prefill_messages = None
2026-03-10 17:10:01 -07:00
except Exception as e :
logger . warning ( " Job ' %s ' : failed to parse prefill messages file ' %s ' : %s " , job_id , pfpath , e )
2026-03-07 11:37:16 -08:00
prefill_messages = None
# Max iterations
max_iterations = _cfg . get ( " agent " , { } ) . get ( " max_turns " ) or _cfg . get ( " max_turns " ) or 90
# Provider routing
pr = _cfg . get ( " provider_routing " , { } )
fix: hermes update causes dual gateways on macOS (launchd) (#1567)
* feat: add optional smart model routing
Add a conservative cheap-vs-strong routing option that can send very short/simple turns to a cheaper model across providers while keeping the primary model for complex work. Wire it through CLI, gateway, and cron, and document the config.yaml workflow.
* fix(gateway): remove recursive ExecStop from systemd units, extend TimeoutStopSec to 60s
* fix(gateway): avoid recursive ExecStop in user systemd unit
* fix: extend ExecStop removal and TimeoutStopSec=60 to system unit
The cherry-picked PR #1448 fix only covered the user systemd unit.
The system unit had the same TimeoutStopSec=15 and could benefit
from the same 60s timeout for clean shutdown. Also adds a regression
test for the system unit.
---------
Co-authored-by: Ninja <ninja@local>
* feat(skills): add blender-mcp optional skill for 3D modeling
Control a running Blender instance from Hermes via socket connection
to the blender-mcp addon (port 9876). Supports creating 3D objects,
materials, animations, and running arbitrary bpy code.
Placed in optional-skills/ since it requires Blender 4.3+ desktop
with a third-party addon manually started each session.
* feat(acp): support slash commands in ACP adapter (#1532)
Adds /help, /model, /tools, /context, /reset, /compact, /version
to the ACP adapter (VS Code, Zed, JetBrains). Commands are handled
directly in the server without instantiating the TUI — each command
queries agent/session state and returns plain text.
Unrecognized /commands fall through to the LLM as normal messages.
/model uses detect_provider_for_model() for auto-detection when
switching models, matching the CLI and gateway behavior.
Fixes #1402
* fix(logging): improve error logging in session search tool (#1533)
* fix(gateway): restart on retryable startup failures (#1517)
* feat(email): add skip_attachments option via config.yaml
* feat(email): add skip_attachments option via config.yaml
Adds a config.yaml-driven option to skip email attachments in the
gateway email adapter. Useful for malware protection and bandwidth
savings.
Configure in config.yaml:
platforms:
email:
skip_attachments: true
Based on PR #1521 by @an420eth, changed from env var to config.yaml
(via PlatformConfig.extra) to match the project's config-first pattern.
* docs: document skip_attachments option for email adapter
* fix(telegram): retry on transient TLS failures during connect and send
Add exponential-backoff retry (3 attempts) around initialize() to
handle transient TLS resets during gateway startup. Also catches
TimedOut and OSError in addition to NetworkError.
Add exponential-backoff retry (3 attempts) around send_message() for
NetworkError during message delivery, wrapping the existing Markdown
fallback logic.
Both imports are guarded with try/except ImportError for test
environments where telegram is mocked.
Based on PR #1527 by cmd8. Closes #1526.
* feat: permissive block_anchor thresholds and unicode normalization (#1539)
Salvaged from PR #1528 by an420eth. Closes #517.
Improves _strategy_block_anchor in fuzzy_match.py:
- Add unicode normalization (smart quotes, em/en-dashes, ellipsis,
non-breaking spaces → ASCII) so LLM-produced unicode artifacts
don't break anchor line matching
- Lower thresholds: 0.10 for unique matches (was 0.70), 0.30 for
multiple candidates — if first/last lines match exactly, the
block is almost certainly correct
- Use original (non-normalized) content for offset calculation to
preserve correct character positions
Tested: 3 new scenarios fixed (em-dash anchors, non-breaking space
anchors, very-low-similarity unique matches), zero regressions on
all 9 existing fuzzy match tests.
Co-authored-by: an420eth <an420eth@users.noreply.github.com>
* feat(cli): add file path autocomplete in the input prompt (#1545)
When typing a path-like token (./ ../ ~/ / or containing /),
the CLI now shows filesystem completions in the dropdown menu.
Directories show a trailing slash and 'dir' label; files show
their size. Completions are case-insensitive and capped at 30
entries.
Triggered by tokens like:
edit ./src/ma → shows ./src/main.py, ./src/manifest.json, ...
check ~/doc → shows ~/docs/, ~/documents/, ...
read /etc/hos → shows /etc/hosts, /etc/hostname, ...
open tools/reg → shows tools/registry.py
Slash command autocomplete (/help, /model, etc.) is unaffected —
it still triggers when the input starts with /.
Inspired by OpenCode PR #145 (file path completion menu).
Implementation:
- hermes_cli/commands.py: _extract_path_word() detects path-like
tokens, _path_completions() yields filesystem Completions with
size labels, get_completions() routes to paths vs slash commands
- tests/hermes_cli/test_path_completion.py: 26 tests covering
path extraction, prefix filtering, directory markers, home
expansion, case-insensitivity, integration with slash commands
* feat(privacy): redact PII from LLM context when privacy.redact_pii is enabled
Add privacy.redact_pii config option (boolean, default false). When
enabled, the gateway redacts personally identifiable information from
the system prompt before sending it to the LLM provider:
- Phone numbers (user IDs on WhatsApp/Signal) → hashed to user_<sha256>
- User IDs → hashed to user_<sha256>
- Chat IDs → numeric portion hashed, platform prefix preserved
- Home channel IDs → hashed
- Names/usernames → NOT affected (user-chosen, publicly visible)
Hashes are deterministic (same user → same hash) so the model can
still distinguish users in group chats. Routing and delivery use
the original values internally — redaction only affects LLM context.
Inspired by OpenClaw PR #47959.
* fix(privacy): skip PII redaction on Discord/Slack (mentions need real IDs)
Discord uses <@user_id> for mentions and Slack uses <@U12345> — the LLM
needs the real ID to tag users. Redaction now only applies to WhatsApp,
Signal, and Telegram where IDs are pure routing metadata.
Add 4 platform-specific tests covering Discord, WhatsApp, Signal, Slack.
* feat: smart approvals + /stop command (inspired by OpenAI Codex)
* feat: smart approvals — LLM-based risk assessment for dangerous commands
Adds a 'smart' approval mode that uses the auxiliary LLM to assess
whether a flagged command is genuinely dangerous or a false positive,
auto-approving low-risk commands without prompting the user.
Inspired by OpenAI Codex's Smart Approvals guardian subagent
(openai/codex#13860).
Config (config.yaml):
approvals:
mode: manual # manual (default), smart, off
Modes:
- manual — current behavior, always prompt the user
- smart — aux LLM evaluates risk: APPROVE (auto-allow), DENY (block),
or ESCALATE (fall through to manual prompt)
- off — skip all approval prompts (equivalent to --yolo)
When smart mode auto-approves, the pattern gets session-level approval
so subsequent uses of the same pattern don't trigger another LLM call.
When it denies, the command is blocked without user prompt. When
uncertain, it escalates to the normal manual approval flow.
The LLM prompt is carefully scoped: it sees only the command text and
the flagged reason, assesses actual risk vs false positive, and returns
a single-word verdict.
* feat: make smart approval model configurable via config.yaml
Adds auxiliary.approval section to config.yaml with the same
provider/model/base_url/api_key pattern as other aux tasks (vision,
web_extract, compression, etc.).
Config:
auxiliary:
approval:
provider: auto
model: '' # fast/cheap model recommended
base_url: ''
api_key: ''
Bridged to env vars in both CLI and gateway paths so the aux client
picks them up automatically.
* feat: add /stop command to kill all background processes
Adds a /stop slash command that kills all running background processes
at once. Currently users have to process(list) then process(kill) for
each one individually.
Inspired by OpenAI Codex's separation of interrupt (Ctrl+C stops current
turn) from /stop (cleans up background processes). See openai/codex#14602.
Ctrl+C continues to only interrupt the active agent turn — background
dev servers, watchers, etc. are preserved. /stop is the explicit way
to clean them all up.
* feat: first-class plugin architecture + hide status bar cost by default (#1544)
The persistent status bar now shows context %, token counts, and
duration but NOT $ cost by default. Cost display is opt-in via:
display:
show_cost: true
in config.yaml, or: hermes config set display.show_cost true
The /usage command still shows full cost breakdown since the user
explicitly asked for it — this only affects the always-visible bar.
Status bar without cost:
⚕ claude-sonnet-4 │ 12K/200K │ 6% │ 15m
Status bar with show_cost: true:
⚕ claude-sonnet-4 │ 12K/200K │ 6% │ $0.06 │ 15m
* feat: improve memory prioritization + aggressive skill updates (inspired by OpenAI Codex)
* feat: improve memory prioritization — user preferences over procedural knowledge
Inspired by OpenAI Codex's memory prompt improvements (openai/codex#14493)
which focus memory writes on user preferences and recurring patterns
rather than procedural task details.
Key insight: 'Optimize for reducing future user steering — the most
valuable memory prevents the user from having to repeat themselves.'
Changes:
- MEMORY_GUIDANCE (prompt_builder.py): added prioritization hierarchy
and the core principle about reducing user steering
- MEMORY_SCHEMA (memory_tool.py): reordered WHEN TO SAVE list to put
corrections first, added explicit PRIORITY guidance
- Memory nudge (run_agent.py): now asks specifically about preferences,
corrections, and workflow patterns instead of generic 'anything'
- Memory flush (run_agent.py): now instructs to prioritize user
preferences and corrections over task-specific details
* feat: more aggressive skill creation and update prompting
Press harder on skill updates — the agent should proactively patch
skills when it encounters issues during use, not wait to be asked.
Changes:
- SKILLS_GUIDANCE: 'consider saving' → 'save'; added explicit instruction
to patch skills immediately when found outdated/wrong
- Skills header: added instruction to update loaded skills before finishing
if they had missing steps or wrong commands
- Skill nudge: more assertive ('save the approach' not 'consider saving'),
now also prompts for updating existing skills used in the task
- Skill nudge interval: lowered default from 15 to 10 iterations
- skill_manage schema: added 'patch it immediately' to update triggers
* feat: first-class plugin architecture (#1555)
Plugin system for extending Hermes with custom tools, hooks, and
integrations — no source code changes required.
Core system (hermes_cli/plugins.py):
- Plugin discovery from ~/.hermes/plugins/, .hermes/plugins/, and
pip entry_points (hermes_agent.plugins group)
- PluginContext with register_tool() and register_hook()
- 6 lifecycle hooks: pre/post tool_call, pre/post llm_call,
on_session_start/end
- Namespace package handling for relative imports in plugins
- Graceful error isolation — broken plugins never crash the agent
Integration (model_tools.py):
- Plugin discovery runs after built-in + MCP tools
- Plugin tools bypass toolset filter via get_plugin_tool_names()
- Pre/post tool call hooks fire in handle_function_call()
CLI:
- /plugins command shows loaded plugins, tool counts, status
- Added to COMMANDS dict for autocomplete
Docs:
- Getting started guide (build-a-hermes-plugin.md) — full tutorial
building a calculator plugin step by step
- Reference page (features/plugins.md) — quick overview + tables
- Covers: file structure, schemas, handlers, hooks, data files,
bundled skills, env var gating, pip distribution, common mistakes
Tests: 16 tests covering discovery, loading, hooks, tool visibility.
* fix: hermes update causes dual gateways on macOS (launchd)
Three bugs worked together to create the dual-gateway problem:
1. cmd_update only checked systemd for gateway restart, completely
ignoring launchd on macOS. After killing the PID it would print
'Restart it with: hermes gateway run' even when launchd was about
to auto-respawn the process.
2. launchd's KeepAlive.SuccessfulExit=false respawns the gateway
after SIGTERM (non-zero exit), so the user's manual restart
created a second instance.
3. The launchd plist lacked --replace (systemd had it), so the
respawned gateway didn't kill stale instances on startup.
Fixes:
- Add --replace to launchd ProgramArguments (matches systemd)
- Add launchd detection to cmd_update's auto-restart logic
- Print 'auto-restart via launchd' instead of manual restart hint
* fix: add launchd plist auto-refresh + explicit restart in cmd_update
Two integration issues with the initial fix:
1. Existing macOS users with old plist (no --replace) would never
get the fix until manual uninstall/reinstall. Added
refresh_launchd_plist_if_needed() — mirrors the existing
refresh_systemd_unit_if_needed(). Called from launchd_start(),
launchd_restart(), and cmd_update.
2. cmd_update relied on KeepAlive respawn after SIGTERM rather than
explicit launchctl stop/start. This caused races: launchd would
respawn the old process before the PID file was cleaned up.
Now does explicit stop+start (matching how systemd gets an
explicit systemctl restart), with plist refresh first so the
new --replace flag is picked up.
---------
Co-authored-by: Ninja <ninja@local>
Co-authored-by: alireza78a <alireza78a@users.noreply.github.com>
Co-authored-by: Oktay Aydin <113846926+aydnOktay@users.noreply.github.com>
Co-authored-by: JP Lew <polydegen@protonmail.com>
Co-authored-by: an420eth <an420eth@users.noreply.github.com>
2026-03-16 12:36:29 -07:00
smart_routing = _cfg . get ( " smart_model_routing " , { } ) or { }
2026-03-07 11:37:16 -08:00
2026-02-25 18:20:38 -08:00
from hermes_cli . runtime_provider import (
resolve_runtime_provider ,
format_runtime_provider_error ,
)
try :
2026-03-14 22:22:31 -07:00
runtime_kwargs = {
" requested " : job . get ( " provider " ) or os . getenv ( " HERMES_INFERENCE_PROVIDER " ) ,
}
if job . get ( " base_url " ) :
runtime_kwargs [ " explicit_base_url " ] = job . get ( " base_url " )
runtime = resolve_runtime_provider ( * * runtime_kwargs )
2026-02-25 18:20:38 -08:00
except Exception as exc :
message = format_runtime_provider_error ( exc )
raise RuntimeError ( message ) from exc
fix: hermes update causes dual gateways on macOS (launchd) (#1567)
* feat: add optional smart model routing
Add a conservative cheap-vs-strong routing option that can send very short/simple turns to a cheaper model across providers while keeping the primary model for complex work. Wire it through CLI, gateway, and cron, and document the config.yaml workflow.
* fix(gateway): remove recursive ExecStop from systemd units, extend TimeoutStopSec to 60s
* fix(gateway): avoid recursive ExecStop in user systemd unit
* fix: extend ExecStop removal and TimeoutStopSec=60 to system unit
The cherry-picked PR #1448 fix only covered the user systemd unit.
The system unit had the same TimeoutStopSec=15 and could benefit
from the same 60s timeout for clean shutdown. Also adds a regression
test for the system unit.
---------
Co-authored-by: Ninja <ninja@local>
* feat(skills): add blender-mcp optional skill for 3D modeling
Control a running Blender instance from Hermes via socket connection
to the blender-mcp addon (port 9876). Supports creating 3D objects,
materials, animations, and running arbitrary bpy code.
Placed in optional-skills/ since it requires Blender 4.3+ desktop
with a third-party addon manually started each session.
* feat(acp): support slash commands in ACP adapter (#1532)
Adds /help, /model, /tools, /context, /reset, /compact, /version
to the ACP adapter (VS Code, Zed, JetBrains). Commands are handled
directly in the server without instantiating the TUI — each command
queries agent/session state and returns plain text.
Unrecognized /commands fall through to the LLM as normal messages.
/model uses detect_provider_for_model() for auto-detection when
switching models, matching the CLI and gateway behavior.
Fixes #1402
* fix(logging): improve error logging in session search tool (#1533)
* fix(gateway): restart on retryable startup failures (#1517)
* feat(email): add skip_attachments option via config.yaml
* feat(email): add skip_attachments option via config.yaml
Adds a config.yaml-driven option to skip email attachments in the
gateway email adapter. Useful for malware protection and bandwidth
savings.
Configure in config.yaml:
platforms:
email:
skip_attachments: true
Based on PR #1521 by @an420eth, changed from env var to config.yaml
(via PlatformConfig.extra) to match the project's config-first pattern.
* docs: document skip_attachments option for email adapter
* fix(telegram): retry on transient TLS failures during connect and send
Add exponential-backoff retry (3 attempts) around initialize() to
handle transient TLS resets during gateway startup. Also catches
TimedOut and OSError in addition to NetworkError.
Add exponential-backoff retry (3 attempts) around send_message() for
NetworkError during message delivery, wrapping the existing Markdown
fallback logic.
Both imports are guarded with try/except ImportError for test
environments where telegram is mocked.
Based on PR #1527 by cmd8. Closes #1526.
* feat: permissive block_anchor thresholds and unicode normalization (#1539)
Salvaged from PR #1528 by an420eth. Closes #517.
Improves _strategy_block_anchor in fuzzy_match.py:
- Add unicode normalization (smart quotes, em/en-dashes, ellipsis,
non-breaking spaces → ASCII) so LLM-produced unicode artifacts
don't break anchor line matching
- Lower thresholds: 0.10 for unique matches (was 0.70), 0.30 for
multiple candidates — if first/last lines match exactly, the
block is almost certainly correct
- Use original (non-normalized) content for offset calculation to
preserve correct character positions
Tested: 3 new scenarios fixed (em-dash anchors, non-breaking space
anchors, very-low-similarity unique matches), zero regressions on
all 9 existing fuzzy match tests.
Co-authored-by: an420eth <an420eth@users.noreply.github.com>
* feat(cli): add file path autocomplete in the input prompt (#1545)
When typing a path-like token (./ ../ ~/ / or containing /),
the CLI now shows filesystem completions in the dropdown menu.
Directories show a trailing slash and 'dir' label; files show
their size. Completions are case-insensitive and capped at 30
entries.
Triggered by tokens like:
edit ./src/ma → shows ./src/main.py, ./src/manifest.json, ...
check ~/doc → shows ~/docs/, ~/documents/, ...
read /etc/hos → shows /etc/hosts, /etc/hostname, ...
open tools/reg → shows tools/registry.py
Slash command autocomplete (/help, /model, etc.) is unaffected —
it still triggers when the input starts with /.
Inspired by OpenCode PR #145 (file path completion menu).
Implementation:
- hermes_cli/commands.py: _extract_path_word() detects path-like
tokens, _path_completions() yields filesystem Completions with
size labels, get_completions() routes to paths vs slash commands
- tests/hermes_cli/test_path_completion.py: 26 tests covering
path extraction, prefix filtering, directory markers, home
expansion, case-insensitivity, integration with slash commands
* feat(privacy): redact PII from LLM context when privacy.redact_pii is enabled
Add privacy.redact_pii config option (boolean, default false). When
enabled, the gateway redacts personally identifiable information from
the system prompt before sending it to the LLM provider:
- Phone numbers (user IDs on WhatsApp/Signal) → hashed to user_<sha256>
- User IDs → hashed to user_<sha256>
- Chat IDs → numeric portion hashed, platform prefix preserved
- Home channel IDs → hashed
- Names/usernames → NOT affected (user-chosen, publicly visible)
Hashes are deterministic (same user → same hash) so the model can
still distinguish users in group chats. Routing and delivery use
the original values internally — redaction only affects LLM context.
Inspired by OpenClaw PR #47959.
* fix(privacy): skip PII redaction on Discord/Slack (mentions need real IDs)
Discord uses <@user_id> for mentions and Slack uses <@U12345> — the LLM
needs the real ID to tag users. Redaction now only applies to WhatsApp,
Signal, and Telegram where IDs are pure routing metadata.
Add 4 platform-specific tests covering Discord, WhatsApp, Signal, Slack.
* feat: smart approvals + /stop command (inspired by OpenAI Codex)
* feat: smart approvals — LLM-based risk assessment for dangerous commands
Adds a 'smart' approval mode that uses the auxiliary LLM to assess
whether a flagged command is genuinely dangerous or a false positive,
auto-approving low-risk commands without prompting the user.
Inspired by OpenAI Codex's Smart Approvals guardian subagent
(openai/codex#13860).
Config (config.yaml):
approvals:
mode: manual # manual (default), smart, off
Modes:
- manual — current behavior, always prompt the user
- smart — aux LLM evaluates risk: APPROVE (auto-allow), DENY (block),
or ESCALATE (fall through to manual prompt)
- off — skip all approval prompts (equivalent to --yolo)
When smart mode auto-approves, the pattern gets session-level approval
so subsequent uses of the same pattern don't trigger another LLM call.
When it denies, the command is blocked without user prompt. When
uncertain, it escalates to the normal manual approval flow.
The LLM prompt is carefully scoped: it sees only the command text and
the flagged reason, assesses actual risk vs false positive, and returns
a single-word verdict.
* feat: make smart approval model configurable via config.yaml
Adds auxiliary.approval section to config.yaml with the same
provider/model/base_url/api_key pattern as other aux tasks (vision,
web_extract, compression, etc.).
Config:
auxiliary:
approval:
provider: auto
model: '' # fast/cheap model recommended
base_url: ''
api_key: ''
Bridged to env vars in both CLI and gateway paths so the aux client
picks them up automatically.
* feat: add /stop command to kill all background processes
Adds a /stop slash command that kills all running background processes
at once. Currently users have to process(list) then process(kill) for
each one individually.
Inspired by OpenAI Codex's separation of interrupt (Ctrl+C stops current
turn) from /stop (cleans up background processes). See openai/codex#14602.
Ctrl+C continues to only interrupt the active agent turn — background
dev servers, watchers, etc. are preserved. /stop is the explicit way
to clean them all up.
* feat: first-class plugin architecture + hide status bar cost by default (#1544)
The persistent status bar now shows context %, token counts, and
duration but NOT $ cost by default. Cost display is opt-in via:
display:
show_cost: true
in config.yaml, or: hermes config set display.show_cost true
The /usage command still shows full cost breakdown since the user
explicitly asked for it — this only affects the always-visible bar.
Status bar without cost:
⚕ claude-sonnet-4 │ 12K/200K │ 6% │ 15m
Status bar with show_cost: true:
⚕ claude-sonnet-4 │ 12K/200K │ 6% │ $0.06 │ 15m
* feat: improve memory prioritization + aggressive skill updates (inspired by OpenAI Codex)
* feat: improve memory prioritization — user preferences over procedural knowledge
Inspired by OpenAI Codex's memory prompt improvements (openai/codex#14493)
which focus memory writes on user preferences and recurring patterns
rather than procedural task details.
Key insight: 'Optimize for reducing future user steering — the most
valuable memory prevents the user from having to repeat themselves.'
Changes:
- MEMORY_GUIDANCE (prompt_builder.py): added prioritization hierarchy
and the core principle about reducing user steering
- MEMORY_SCHEMA (memory_tool.py): reordered WHEN TO SAVE list to put
corrections first, added explicit PRIORITY guidance
- Memory nudge (run_agent.py): now asks specifically about preferences,
corrections, and workflow patterns instead of generic 'anything'
- Memory flush (run_agent.py): now instructs to prioritize user
preferences and corrections over task-specific details
* feat: more aggressive skill creation and update prompting
Press harder on skill updates — the agent should proactively patch
skills when it encounters issues during use, not wait to be asked.
Changes:
- SKILLS_GUIDANCE: 'consider saving' → 'save'; added explicit instruction
to patch skills immediately when found outdated/wrong
- Skills header: added instruction to update loaded skills before finishing
if they had missing steps or wrong commands
- Skill nudge: more assertive ('save the approach' not 'consider saving'),
now also prompts for updating existing skills used in the task
- Skill nudge interval: lowered default from 15 to 10 iterations
- skill_manage schema: added 'patch it immediately' to update triggers
* feat: first-class plugin architecture (#1555)
Plugin system for extending Hermes with custom tools, hooks, and
integrations — no source code changes required.
Core system (hermes_cli/plugins.py):
- Plugin discovery from ~/.hermes/plugins/, .hermes/plugins/, and
pip entry_points (hermes_agent.plugins group)
- PluginContext with register_tool() and register_hook()
- 6 lifecycle hooks: pre/post tool_call, pre/post llm_call,
on_session_start/end
- Namespace package handling for relative imports in plugins
- Graceful error isolation — broken plugins never crash the agent
Integration (model_tools.py):
- Plugin discovery runs after built-in + MCP tools
- Plugin tools bypass toolset filter via get_plugin_tool_names()
- Pre/post tool call hooks fire in handle_function_call()
CLI:
- /plugins command shows loaded plugins, tool counts, status
- Added to COMMANDS dict for autocomplete
Docs:
- Getting started guide (build-a-hermes-plugin.md) — full tutorial
building a calculator plugin step by step
- Reference page (features/plugins.md) — quick overview + tables
- Covers: file structure, schemas, handlers, hooks, data files,
bundled skills, env var gating, pip distribution, common mistakes
Tests: 16 tests covering discovery, loading, hooks, tool visibility.
* fix: hermes update causes dual gateways on macOS (launchd)
Three bugs worked together to create the dual-gateway problem:
1. cmd_update only checked systemd for gateway restart, completely
ignoring launchd on macOS. After killing the PID it would print
'Restart it with: hermes gateway run' even when launchd was about
to auto-respawn the process.
2. launchd's KeepAlive.SuccessfulExit=false respawns the gateway
after SIGTERM (non-zero exit), so the user's manual restart
created a second instance.
3. The launchd plist lacked --replace (systemd had it), so the
respawned gateway didn't kill stale instances on startup.
Fixes:
- Add --replace to launchd ProgramArguments (matches systemd)
- Add launchd detection to cmd_update's auto-restart logic
- Print 'auto-restart via launchd' instead of manual restart hint
* fix: add launchd plist auto-refresh + explicit restart in cmd_update
Two integration issues with the initial fix:
1. Existing macOS users with old plist (no --replace) would never
get the fix until manual uninstall/reinstall. Added
refresh_launchd_plist_if_needed() — mirrors the existing
refresh_systemd_unit_if_needed(). Called from launchd_start(),
launchd_restart(), and cmd_update.
2. cmd_update relied on KeepAlive respawn after SIGTERM rather than
explicit launchctl stop/start. This caused races: launchd would
respawn the old process before the PID file was cleaned up.
Now does explicit stop+start (matching how systemd gets an
explicit systemctl restart), with plist refresh first so the
new --replace flag is picked up.
---------
Co-authored-by: Ninja <ninja@local>
Co-authored-by: alireza78a <alireza78a@users.noreply.github.com>
Co-authored-by: Oktay Aydin <113846926+aydnOktay@users.noreply.github.com>
Co-authored-by: JP Lew <polydegen@protonmail.com>
Co-authored-by: an420eth <an420eth@users.noreply.github.com>
2026-03-16 12:36:29 -07:00
from agent . smart_model_routing import resolve_turn_route
turn_route = resolve_turn_route (
prompt ,
smart_routing ,
{
" model " : model ,
" api_key " : runtime . get ( " api_key " ) ,
" base_url " : runtime . get ( " base_url " ) ,
" provider " : runtime . get ( " provider " ) ,
" api_mode " : runtime . get ( " api_mode " ) ,
2026-03-17 23:40:22 -07:00
" command " : runtime . get ( " command " ) ,
" args " : list ( runtime . get ( " args " ) or [ ] ) ,
fix: hermes update causes dual gateways on macOS (launchd) (#1567)
* feat: add optional smart model routing
Add a conservative cheap-vs-strong routing option that can send very short/simple turns to a cheaper model across providers while keeping the primary model for complex work. Wire it through CLI, gateway, and cron, and document the config.yaml workflow.
* fix(gateway): remove recursive ExecStop from systemd units, extend TimeoutStopSec to 60s
* fix(gateway): avoid recursive ExecStop in user systemd unit
* fix: extend ExecStop removal and TimeoutStopSec=60 to system unit
The cherry-picked PR #1448 fix only covered the user systemd unit.
The system unit had the same TimeoutStopSec=15 and could benefit
from the same 60s timeout for clean shutdown. Also adds a regression
test for the system unit.
---------
Co-authored-by: Ninja <ninja@local>
* feat(skills): add blender-mcp optional skill for 3D modeling
Control a running Blender instance from Hermes via socket connection
to the blender-mcp addon (port 9876). Supports creating 3D objects,
materials, animations, and running arbitrary bpy code.
Placed in optional-skills/ since it requires Blender 4.3+ desktop
with a third-party addon manually started each session.
* feat(acp): support slash commands in ACP adapter (#1532)
Adds /help, /model, /tools, /context, /reset, /compact, /version
to the ACP adapter (VS Code, Zed, JetBrains). Commands are handled
directly in the server without instantiating the TUI — each command
queries agent/session state and returns plain text.
Unrecognized /commands fall through to the LLM as normal messages.
/model uses detect_provider_for_model() for auto-detection when
switching models, matching the CLI and gateway behavior.
Fixes #1402
* fix(logging): improve error logging in session search tool (#1533)
* fix(gateway): restart on retryable startup failures (#1517)
* feat(email): add skip_attachments option via config.yaml
* feat(email): add skip_attachments option via config.yaml
Adds a config.yaml-driven option to skip email attachments in the
gateway email adapter. Useful for malware protection and bandwidth
savings.
Configure in config.yaml:
platforms:
email:
skip_attachments: true
Based on PR #1521 by @an420eth, changed from env var to config.yaml
(via PlatformConfig.extra) to match the project's config-first pattern.
* docs: document skip_attachments option for email adapter
* fix(telegram): retry on transient TLS failures during connect and send
Add exponential-backoff retry (3 attempts) around initialize() to
handle transient TLS resets during gateway startup. Also catches
TimedOut and OSError in addition to NetworkError.
Add exponential-backoff retry (3 attempts) around send_message() for
NetworkError during message delivery, wrapping the existing Markdown
fallback logic.
Both imports are guarded with try/except ImportError for test
environments where telegram is mocked.
Based on PR #1527 by cmd8. Closes #1526.
* feat: permissive block_anchor thresholds and unicode normalization (#1539)
Salvaged from PR #1528 by an420eth. Closes #517.
Improves _strategy_block_anchor in fuzzy_match.py:
- Add unicode normalization (smart quotes, em/en-dashes, ellipsis,
non-breaking spaces → ASCII) so LLM-produced unicode artifacts
don't break anchor line matching
- Lower thresholds: 0.10 for unique matches (was 0.70), 0.30 for
multiple candidates — if first/last lines match exactly, the
block is almost certainly correct
- Use original (non-normalized) content for offset calculation to
preserve correct character positions
Tested: 3 new scenarios fixed (em-dash anchors, non-breaking space
anchors, very-low-similarity unique matches), zero regressions on
all 9 existing fuzzy match tests.
Co-authored-by: an420eth <an420eth@users.noreply.github.com>
* feat(cli): add file path autocomplete in the input prompt (#1545)
When typing a path-like token (./ ../ ~/ / or containing /),
the CLI now shows filesystem completions in the dropdown menu.
Directories show a trailing slash and 'dir' label; files show
their size. Completions are case-insensitive and capped at 30
entries.
Triggered by tokens like:
edit ./src/ma → shows ./src/main.py, ./src/manifest.json, ...
check ~/doc → shows ~/docs/, ~/documents/, ...
read /etc/hos → shows /etc/hosts, /etc/hostname, ...
open tools/reg → shows tools/registry.py
Slash command autocomplete (/help, /model, etc.) is unaffected —
it still triggers when the input starts with /.
Inspired by OpenCode PR #145 (file path completion menu).
Implementation:
- hermes_cli/commands.py: _extract_path_word() detects path-like
tokens, _path_completions() yields filesystem Completions with
size labels, get_completions() routes to paths vs slash commands
- tests/hermes_cli/test_path_completion.py: 26 tests covering
path extraction, prefix filtering, directory markers, home
expansion, case-insensitivity, integration with slash commands
* feat(privacy): redact PII from LLM context when privacy.redact_pii is enabled
Add privacy.redact_pii config option (boolean, default false). When
enabled, the gateway redacts personally identifiable information from
the system prompt before sending it to the LLM provider:
- Phone numbers (user IDs on WhatsApp/Signal) → hashed to user_<sha256>
- User IDs → hashed to user_<sha256>
- Chat IDs → numeric portion hashed, platform prefix preserved
- Home channel IDs → hashed
- Names/usernames → NOT affected (user-chosen, publicly visible)
Hashes are deterministic (same user → same hash) so the model can
still distinguish users in group chats. Routing and delivery use
the original values internally — redaction only affects LLM context.
Inspired by OpenClaw PR #47959.
* fix(privacy): skip PII redaction on Discord/Slack (mentions need real IDs)
Discord uses <@user_id> for mentions and Slack uses <@U12345> — the LLM
needs the real ID to tag users. Redaction now only applies to WhatsApp,
Signal, and Telegram where IDs are pure routing metadata.
Add 4 platform-specific tests covering Discord, WhatsApp, Signal, Slack.
* feat: smart approvals + /stop command (inspired by OpenAI Codex)
* feat: smart approvals — LLM-based risk assessment for dangerous commands
Adds a 'smart' approval mode that uses the auxiliary LLM to assess
whether a flagged command is genuinely dangerous or a false positive,
auto-approving low-risk commands without prompting the user.
Inspired by OpenAI Codex's Smart Approvals guardian subagent
(openai/codex#13860).
Config (config.yaml):
approvals:
mode: manual # manual (default), smart, off
Modes:
- manual — current behavior, always prompt the user
- smart — aux LLM evaluates risk: APPROVE (auto-allow), DENY (block),
or ESCALATE (fall through to manual prompt)
- off — skip all approval prompts (equivalent to --yolo)
When smart mode auto-approves, the pattern gets session-level approval
so subsequent uses of the same pattern don't trigger another LLM call.
When it denies, the command is blocked without user prompt. When
uncertain, it escalates to the normal manual approval flow.
The LLM prompt is carefully scoped: it sees only the command text and
the flagged reason, assesses actual risk vs false positive, and returns
a single-word verdict.
* feat: make smart approval model configurable via config.yaml
Adds auxiliary.approval section to config.yaml with the same
provider/model/base_url/api_key pattern as other aux tasks (vision,
web_extract, compression, etc.).
Config:
auxiliary:
approval:
provider: auto
model: '' # fast/cheap model recommended
base_url: ''
api_key: ''
Bridged to env vars in both CLI and gateway paths so the aux client
picks them up automatically.
* feat: add /stop command to kill all background processes
Adds a /stop slash command that kills all running background processes
at once. Currently users have to process(list) then process(kill) for
each one individually.
Inspired by OpenAI Codex's separation of interrupt (Ctrl+C stops current
turn) from /stop (cleans up background processes). See openai/codex#14602.
Ctrl+C continues to only interrupt the active agent turn — background
dev servers, watchers, etc. are preserved. /stop is the explicit way
to clean them all up.
* feat: first-class plugin architecture + hide status bar cost by default (#1544)
The persistent status bar now shows context %, token counts, and
duration but NOT $ cost by default. Cost display is opt-in via:
display:
show_cost: true
in config.yaml, or: hermes config set display.show_cost true
The /usage command still shows full cost breakdown since the user
explicitly asked for it — this only affects the always-visible bar.
Status bar without cost:
⚕ claude-sonnet-4 │ 12K/200K │ 6% │ 15m
Status bar with show_cost: true:
⚕ claude-sonnet-4 │ 12K/200K │ 6% │ $0.06 │ 15m
* feat: improve memory prioritization + aggressive skill updates (inspired by OpenAI Codex)
* feat: improve memory prioritization — user preferences over procedural knowledge
Inspired by OpenAI Codex's memory prompt improvements (openai/codex#14493)
which focus memory writes on user preferences and recurring patterns
rather than procedural task details.
Key insight: 'Optimize for reducing future user steering — the most
valuable memory prevents the user from having to repeat themselves.'
Changes:
- MEMORY_GUIDANCE (prompt_builder.py): added prioritization hierarchy
and the core principle about reducing user steering
- MEMORY_SCHEMA (memory_tool.py): reordered WHEN TO SAVE list to put
corrections first, added explicit PRIORITY guidance
- Memory nudge (run_agent.py): now asks specifically about preferences,
corrections, and workflow patterns instead of generic 'anything'
- Memory flush (run_agent.py): now instructs to prioritize user
preferences and corrections over task-specific details
* feat: more aggressive skill creation and update prompting
Press harder on skill updates — the agent should proactively patch
skills when it encounters issues during use, not wait to be asked.
Changes:
- SKILLS_GUIDANCE: 'consider saving' → 'save'; added explicit instruction
to patch skills immediately when found outdated/wrong
- Skills header: added instruction to update loaded skills before finishing
if they had missing steps or wrong commands
- Skill nudge: more assertive ('save the approach' not 'consider saving'),
now also prompts for updating existing skills used in the task
- Skill nudge interval: lowered default from 15 to 10 iterations
- skill_manage schema: added 'patch it immediately' to update triggers
* feat: first-class plugin architecture (#1555)
Plugin system for extending Hermes with custom tools, hooks, and
integrations — no source code changes required.
Core system (hermes_cli/plugins.py):
- Plugin discovery from ~/.hermes/plugins/, .hermes/plugins/, and
pip entry_points (hermes_agent.plugins group)
- PluginContext with register_tool() and register_hook()
- 6 lifecycle hooks: pre/post tool_call, pre/post llm_call,
on_session_start/end
- Namespace package handling for relative imports in plugins
- Graceful error isolation — broken plugins never crash the agent
Integration (model_tools.py):
- Plugin discovery runs after built-in + MCP tools
- Plugin tools bypass toolset filter via get_plugin_tool_names()
- Pre/post tool call hooks fire in handle_function_call()
CLI:
- /plugins command shows loaded plugins, tool counts, status
- Added to COMMANDS dict for autocomplete
Docs:
- Getting started guide (build-a-hermes-plugin.md) — full tutorial
building a calculator plugin step by step
- Reference page (features/plugins.md) — quick overview + tables
- Covers: file structure, schemas, handlers, hooks, data files,
bundled skills, env var gating, pip distribution, common mistakes
Tests: 16 tests covering discovery, loading, hooks, tool visibility.
* fix: hermes update causes dual gateways on macOS (launchd)
Three bugs worked together to create the dual-gateway problem:
1. cmd_update only checked systemd for gateway restart, completely
ignoring launchd on macOS. After killing the PID it would print
'Restart it with: hermes gateway run' even when launchd was about
to auto-respawn the process.
2. launchd's KeepAlive.SuccessfulExit=false respawns the gateway
after SIGTERM (non-zero exit), so the user's manual restart
created a second instance.
3. The launchd plist lacked --replace (systemd had it), so the
respawned gateway didn't kill stale instances on startup.
Fixes:
- Add --replace to launchd ProgramArguments (matches systemd)
- Add launchd detection to cmd_update's auto-restart logic
- Print 'auto-restart via launchd' instead of manual restart hint
* fix: add launchd plist auto-refresh + explicit restart in cmd_update
Two integration issues with the initial fix:
1. Existing macOS users with old plist (no --replace) would never
get the fix until manual uninstall/reinstall. Added
refresh_launchd_plist_if_needed() — mirrors the existing
refresh_systemd_unit_if_needed(). Called from launchd_start(),
launchd_restart(), and cmd_update.
2. cmd_update relied on KeepAlive respawn after SIGTERM rather than
explicit launchctl stop/start. This caused races: launchd would
respawn the old process before the PID file was cleaned up.
Now does explicit stop+start (matching how systemd gets an
explicit systemctl restart), with plist refresh first so the
new --replace flag is picked up.
---------
Co-authored-by: Ninja <ninja@local>
Co-authored-by: alireza78a <alireza78a@users.noreply.github.com>
Co-authored-by: Oktay Aydin <113846926+aydnOktay@users.noreply.github.com>
Co-authored-by: JP Lew <polydegen@protonmail.com>
Co-authored-by: an420eth <an420eth@users.noreply.github.com>
2026-03-16 12:36:29 -07:00
} ,
)
2026-02-02 08:26:42 -08:00
agent = AIAgent (
fix: hermes update causes dual gateways on macOS (launchd) (#1567)
* feat: add optional smart model routing
Add a conservative cheap-vs-strong routing option that can send very short/simple turns to a cheaper model across providers while keeping the primary model for complex work. Wire it through CLI, gateway, and cron, and document the config.yaml workflow.
* fix(gateway): remove recursive ExecStop from systemd units, extend TimeoutStopSec to 60s
* fix(gateway): avoid recursive ExecStop in user systemd unit
* fix: extend ExecStop removal and TimeoutStopSec=60 to system unit
The cherry-picked PR #1448 fix only covered the user systemd unit.
The system unit had the same TimeoutStopSec=15 and could benefit
from the same 60s timeout for clean shutdown. Also adds a regression
test for the system unit.
---------
Co-authored-by: Ninja <ninja@local>
* feat(skills): add blender-mcp optional skill for 3D modeling
Control a running Blender instance from Hermes via socket connection
to the blender-mcp addon (port 9876). Supports creating 3D objects,
materials, animations, and running arbitrary bpy code.
Placed in optional-skills/ since it requires Blender 4.3+ desktop
with a third-party addon manually started each session.
* feat(acp): support slash commands in ACP adapter (#1532)
Adds /help, /model, /tools, /context, /reset, /compact, /version
to the ACP adapter (VS Code, Zed, JetBrains). Commands are handled
directly in the server without instantiating the TUI — each command
queries agent/session state and returns plain text.
Unrecognized /commands fall through to the LLM as normal messages.
/model uses detect_provider_for_model() for auto-detection when
switching models, matching the CLI and gateway behavior.
Fixes #1402
* fix(logging): improve error logging in session search tool (#1533)
* fix(gateway): restart on retryable startup failures (#1517)
* feat(email): add skip_attachments option via config.yaml
* feat(email): add skip_attachments option via config.yaml
Adds a config.yaml-driven option to skip email attachments in the
gateway email adapter. Useful for malware protection and bandwidth
savings.
Configure in config.yaml:
platforms:
email:
skip_attachments: true
Based on PR #1521 by @an420eth, changed from env var to config.yaml
(via PlatformConfig.extra) to match the project's config-first pattern.
* docs: document skip_attachments option for email adapter
* fix(telegram): retry on transient TLS failures during connect and send
Add exponential-backoff retry (3 attempts) around initialize() to
handle transient TLS resets during gateway startup. Also catches
TimedOut and OSError in addition to NetworkError.
Add exponential-backoff retry (3 attempts) around send_message() for
NetworkError during message delivery, wrapping the existing Markdown
fallback logic.
Both imports are guarded with try/except ImportError for test
environments where telegram is mocked.
Based on PR #1527 by cmd8. Closes #1526.
* feat: permissive block_anchor thresholds and unicode normalization (#1539)
Salvaged from PR #1528 by an420eth. Closes #517.
Improves _strategy_block_anchor in fuzzy_match.py:
- Add unicode normalization (smart quotes, em/en-dashes, ellipsis,
non-breaking spaces → ASCII) so LLM-produced unicode artifacts
don't break anchor line matching
- Lower thresholds: 0.10 for unique matches (was 0.70), 0.30 for
multiple candidates — if first/last lines match exactly, the
block is almost certainly correct
- Use original (non-normalized) content for offset calculation to
preserve correct character positions
Tested: 3 new scenarios fixed (em-dash anchors, non-breaking space
anchors, very-low-similarity unique matches), zero regressions on
all 9 existing fuzzy match tests.
Co-authored-by: an420eth <an420eth@users.noreply.github.com>
* feat(cli): add file path autocomplete in the input prompt (#1545)
When typing a path-like token (./ ../ ~/ / or containing /),
the CLI now shows filesystem completions in the dropdown menu.
Directories show a trailing slash and 'dir' label; files show
their size. Completions are case-insensitive and capped at 30
entries.
Triggered by tokens like:
edit ./src/ma → shows ./src/main.py, ./src/manifest.json, ...
check ~/doc → shows ~/docs/, ~/documents/, ...
read /etc/hos → shows /etc/hosts, /etc/hostname, ...
open tools/reg → shows tools/registry.py
Slash command autocomplete (/help, /model, etc.) is unaffected —
it still triggers when the input starts with /.
Inspired by OpenCode PR #145 (file path completion menu).
Implementation:
- hermes_cli/commands.py: _extract_path_word() detects path-like
tokens, _path_completions() yields filesystem Completions with
size labels, get_completions() routes to paths vs slash commands
- tests/hermes_cli/test_path_completion.py: 26 tests covering
path extraction, prefix filtering, directory markers, home
expansion, case-insensitivity, integration with slash commands
* feat(privacy): redact PII from LLM context when privacy.redact_pii is enabled
Add privacy.redact_pii config option (boolean, default false). When
enabled, the gateway redacts personally identifiable information from
the system prompt before sending it to the LLM provider:
- Phone numbers (user IDs on WhatsApp/Signal) → hashed to user_<sha256>
- User IDs → hashed to user_<sha256>
- Chat IDs → numeric portion hashed, platform prefix preserved
- Home channel IDs → hashed
- Names/usernames → NOT affected (user-chosen, publicly visible)
Hashes are deterministic (same user → same hash) so the model can
still distinguish users in group chats. Routing and delivery use
the original values internally — redaction only affects LLM context.
Inspired by OpenClaw PR #47959.
* fix(privacy): skip PII redaction on Discord/Slack (mentions need real IDs)
Discord uses <@user_id> for mentions and Slack uses <@U12345> — the LLM
needs the real ID to tag users. Redaction now only applies to WhatsApp,
Signal, and Telegram where IDs are pure routing metadata.
Add 4 platform-specific tests covering Discord, WhatsApp, Signal, Slack.
* feat: smart approvals + /stop command (inspired by OpenAI Codex)
* feat: smart approvals — LLM-based risk assessment for dangerous commands
Adds a 'smart' approval mode that uses the auxiliary LLM to assess
whether a flagged command is genuinely dangerous or a false positive,
auto-approving low-risk commands without prompting the user.
Inspired by OpenAI Codex's Smart Approvals guardian subagent
(openai/codex#13860).
Config (config.yaml):
approvals:
mode: manual # manual (default), smart, off
Modes:
- manual — current behavior, always prompt the user
- smart — aux LLM evaluates risk: APPROVE (auto-allow), DENY (block),
or ESCALATE (fall through to manual prompt)
- off — skip all approval prompts (equivalent to --yolo)
When smart mode auto-approves, the pattern gets session-level approval
so subsequent uses of the same pattern don't trigger another LLM call.
When it denies, the command is blocked without user prompt. When
uncertain, it escalates to the normal manual approval flow.
The LLM prompt is carefully scoped: it sees only the command text and
the flagged reason, assesses actual risk vs false positive, and returns
a single-word verdict.
* feat: make smart approval model configurable via config.yaml
Adds auxiliary.approval section to config.yaml with the same
provider/model/base_url/api_key pattern as other aux tasks (vision,
web_extract, compression, etc.).
Config:
auxiliary:
approval:
provider: auto
model: '' # fast/cheap model recommended
base_url: ''
api_key: ''
Bridged to env vars in both CLI and gateway paths so the aux client
picks them up automatically.
* feat: add /stop command to kill all background processes
Adds a /stop slash command that kills all running background processes
at once. Currently users have to process(list) then process(kill) for
each one individually.
Inspired by OpenAI Codex's separation of interrupt (Ctrl+C stops current
turn) from /stop (cleans up background processes). See openai/codex#14602.
Ctrl+C continues to only interrupt the active agent turn — background
dev servers, watchers, etc. are preserved. /stop is the explicit way
to clean them all up.
* feat: first-class plugin architecture + hide status bar cost by default (#1544)
The persistent status bar now shows context %, token counts, and
duration but NOT $ cost by default. Cost display is opt-in via:
display:
show_cost: true
in config.yaml, or: hermes config set display.show_cost true
The /usage command still shows full cost breakdown since the user
explicitly asked for it — this only affects the always-visible bar.
Status bar without cost:
⚕ claude-sonnet-4 │ 12K/200K │ 6% │ 15m
Status bar with show_cost: true:
⚕ claude-sonnet-4 │ 12K/200K │ 6% │ $0.06 │ 15m
* feat: improve memory prioritization + aggressive skill updates (inspired by OpenAI Codex)
* feat: improve memory prioritization — user preferences over procedural knowledge
Inspired by OpenAI Codex's memory prompt improvements (openai/codex#14493)
which focus memory writes on user preferences and recurring patterns
rather than procedural task details.
Key insight: 'Optimize for reducing future user steering — the most
valuable memory prevents the user from having to repeat themselves.'
Changes:
- MEMORY_GUIDANCE (prompt_builder.py): added prioritization hierarchy
and the core principle about reducing user steering
- MEMORY_SCHEMA (memory_tool.py): reordered WHEN TO SAVE list to put
corrections first, added explicit PRIORITY guidance
- Memory nudge (run_agent.py): now asks specifically about preferences,
corrections, and workflow patterns instead of generic 'anything'
- Memory flush (run_agent.py): now instructs to prioritize user
preferences and corrections over task-specific details
* feat: more aggressive skill creation and update prompting
Press harder on skill updates — the agent should proactively patch
skills when it encounters issues during use, not wait to be asked.
Changes:
- SKILLS_GUIDANCE: 'consider saving' → 'save'; added explicit instruction
to patch skills immediately when found outdated/wrong
- Skills header: added instruction to update loaded skills before finishing
if they had missing steps or wrong commands
- Skill nudge: more assertive ('save the approach' not 'consider saving'),
now also prompts for updating existing skills used in the task
- Skill nudge interval: lowered default from 15 to 10 iterations
- skill_manage schema: added 'patch it immediately' to update triggers
* feat: first-class plugin architecture (#1555)
Plugin system for extending Hermes with custom tools, hooks, and
integrations — no source code changes required.
Core system (hermes_cli/plugins.py):
- Plugin discovery from ~/.hermes/plugins/, .hermes/plugins/, and
pip entry_points (hermes_agent.plugins group)
- PluginContext with register_tool() and register_hook()
- 6 lifecycle hooks: pre/post tool_call, pre/post llm_call,
on_session_start/end
- Namespace package handling for relative imports in plugins
- Graceful error isolation — broken plugins never crash the agent
Integration (model_tools.py):
- Plugin discovery runs after built-in + MCP tools
- Plugin tools bypass toolset filter via get_plugin_tool_names()
- Pre/post tool call hooks fire in handle_function_call()
CLI:
- /plugins command shows loaded plugins, tool counts, status
- Added to COMMANDS dict for autocomplete
Docs:
- Getting started guide (build-a-hermes-plugin.md) — full tutorial
building a calculator plugin step by step
- Reference page (features/plugins.md) — quick overview + tables
- Covers: file structure, schemas, handlers, hooks, data files,
bundled skills, env var gating, pip distribution, common mistakes
Tests: 16 tests covering discovery, loading, hooks, tool visibility.
* fix: hermes update causes dual gateways on macOS (launchd)
Three bugs worked together to create the dual-gateway problem:
1. cmd_update only checked systemd for gateway restart, completely
ignoring launchd on macOS. After killing the PID it would print
'Restart it with: hermes gateway run' even when launchd was about
to auto-respawn the process.
2. launchd's KeepAlive.SuccessfulExit=false respawns the gateway
after SIGTERM (non-zero exit), so the user's manual restart
created a second instance.
3. The launchd plist lacked --replace (systemd had it), so the
respawned gateway didn't kill stale instances on startup.
Fixes:
- Add --replace to launchd ProgramArguments (matches systemd)
- Add launchd detection to cmd_update's auto-restart logic
- Print 'auto-restart via launchd' instead of manual restart hint
* fix: add launchd plist auto-refresh + explicit restart in cmd_update
Two integration issues with the initial fix:
1. Existing macOS users with old plist (no --replace) would never
get the fix until manual uninstall/reinstall. Added
refresh_launchd_plist_if_needed() — mirrors the existing
refresh_systemd_unit_if_needed(). Called from launchd_start(),
launchd_restart(), and cmd_update.
2. cmd_update relied on KeepAlive respawn after SIGTERM rather than
explicit launchctl stop/start. This caused races: launchd would
respawn the old process before the PID file was cleaned up.
Now does explicit stop+start (matching how systemd gets an
explicit systemctl restart), with plist refresh first so the
new --replace flag is picked up.
---------
Co-authored-by: Ninja <ninja@local>
Co-authored-by: alireza78a <alireza78a@users.noreply.github.com>
Co-authored-by: Oktay Aydin <113846926+aydnOktay@users.noreply.github.com>
Co-authored-by: JP Lew <polydegen@protonmail.com>
Co-authored-by: an420eth <an420eth@users.noreply.github.com>
2026-03-16 12:36:29 -07:00
model = turn_route [ " model " ] ,
api_key = turn_route [ " runtime " ] . get ( " api_key " ) ,
base_url = turn_route [ " runtime " ] . get ( " base_url " ) ,
provider = turn_route [ " runtime " ] . get ( " provider " ) ,
api_mode = turn_route [ " runtime " ] . get ( " api_mode " ) ,
2026-03-17 23:40:22 -07:00
acp_command = turn_route [ " runtime " ] . get ( " command " ) ,
acp_args = turn_route [ " runtime " ] . get ( " args " ) ,
2026-03-07 11:37:16 -08:00
max_iterations = max_iterations ,
reasoning_config = reasoning_config ,
prefill_messages = prefill_messages ,
providers_allowed = pr . get ( " only " ) ,
providers_ignored = pr . get ( " ignore " ) ,
providers_order = pr . get ( " order " ) ,
provider_sort = pr . get ( " sort " ) ,
2026-03-20 05:18:05 -07:00
disabled_toolsets = [ " cronjob " , " messaging " , " clarify " ] ,
2026-02-02 08:26:42 -08:00
quiet_mode = True ,
feat(memory): pluggable memory provider interface with profile isolation, review fixes, and honcho CLI restoration (#4623)
* feat(memory): add pluggable memory provider interface with profile isolation
Introduces a pluggable MemoryProvider ABC so external memory backends can
integrate with Hermes without modifying core files. Each backend becomes a
plugin implementing a standard interface, orchestrated by MemoryManager.
Key architecture:
- agent/memory_provider.py — ABC with core + optional lifecycle hooks
- agent/memory_manager.py — single integration point in the agent loop
- agent/builtin_memory_provider.py — wraps existing MEMORY.md/USER.md
Profile isolation fixes applied to all 6 shipped plugins:
- Cognitive Memory: use get_hermes_home() instead of raw env var
- Hindsight Memory: check $HERMES_HOME/hindsight/config.json first,
fall back to legacy ~/.hindsight/ for backward compat
- Hermes Memory Store: replace hardcoded ~/.hermes paths with
get_hermes_home() for config loading and DB path defaults
- Mem0 Memory: use get_hermes_home() instead of raw env var
- RetainDB Memory: auto-derive profile-scoped project name from
hermes_home path (hermes-<profile>), explicit env var overrides
- OpenViking Memory: read-only, no local state, isolation via .env
MemoryManager.initialize_all() now injects hermes_home into kwargs so
every provider can resolve profile-scoped storage without importing
get_hermes_home() themselves.
Plugin system: adds register_memory_provider() to PluginContext and
get_plugin_memory_providers() accessor.
Based on PR #3825. 46 tests (37 unit + 5 E2E + 4 plugin registration).
* refactor(memory): drop cognitive plugin, rewrite OpenViking as full provider
Remove cognitive-memory plugin (#727) — core mechanics are broken:
decay runs 24x too fast (hourly not daily), prefetch uses row ID as
timestamp, search limited by importance not similarity.
Rewrite openviking-memory plugin from a read-only search wrapper into
a full bidirectional memory provider using the complete OpenViking
session lifecycle API:
- sync_turn: records user/assistant messages to OpenViking session
(threaded, non-blocking)
- on_session_end: commits session to trigger automatic memory extraction
into 6 categories (profile, preferences, entities, events, cases,
patterns)
- prefetch: background semantic search via find() endpoint
- on_memory_write: mirrors built-in memory writes to the session
- is_available: checks env var only, no network calls (ABC compliance)
Tools expanded from 3 to 5:
- viking_search: semantic search with mode/scope/limit
- viking_read: tiered content (abstract ~100tok / overview ~2k / full)
- viking_browse: filesystem-style navigation (list/tree/stat)
- viking_remember: explicit memory storage via session
- viking_add_resource: ingest URLs/docs into knowledge base
Uses direct HTTP via httpx (no openviking SDK dependency needed).
Response truncation on viking_read to prevent context flooding.
* fix(memory): harden Mem0 plugin — thread safety, non-blocking sync, circuit breaker
- Remove redundant mem0_context tool (identical to mem0_search with
rerank=true, top_k=5 — wastes a tool slot and confuses the model)
- Thread sync_turn so it's non-blocking — Mem0's server-side LLM
extraction can take 5-10s, was stalling the agent after every turn
- Add threading.Lock around _get_client() for thread-safe lazy init
(prefetch and sync threads could race on first client creation)
- Add circuit breaker: after 5 consecutive API failures, pause calls
for 120s instead of hammering a down server every turn. Auto-resets
after cooldown. Logs a warning when tripped.
- Track success/failure in prefetch, sync_turn, and all tool calls
- Wait for previous sync to finish before starting a new one (prevents
unbounded thread accumulation on rapid turns)
- Clean up shutdown to join both prefetch and sync threads
* fix(memory): enforce single external memory provider limit
MemoryManager now rejects a second non-builtin provider with a warning.
Built-in memory (MEMORY.md/USER.md) is always accepted. Only ONE
external plugin provider is allowed at a time. This prevents tool
schema bloat (some providers add 3-5 tools each) and conflicting
memory backends.
The warning message directs users to configure memory.provider in
config.yaml to select which provider to activate.
Updated all 47 tests to use builtin + one external pattern instead
of multiple externals. Added test_second_external_rejected to verify
the enforcement.
* feat(memory): add ByteRover memory provider plugin
Implements the ByteRover integration (from PR #3499 by hieuntg81) as a
MemoryProvider plugin instead of direct run_agent.py modifications.
ByteRover provides persistent memory via the brv CLI — a hierarchical
knowledge tree with tiered retrieval (fuzzy text then LLM-driven search).
Local-first with optional cloud sync.
Plugin capabilities:
- prefetch: background brv query for relevant context
- sync_turn: curate conversation turns (threaded, non-blocking)
- on_memory_write: mirror built-in memory writes to brv
- on_pre_compress: extract insights before context compression
Tools (3):
- brv_query: search the knowledge tree
- brv_curate: store facts/decisions/patterns
- brv_status: check CLI version and context tree state
Profile isolation: working directory at $HERMES_HOME/byterover/ (scoped
per profile). Binary resolution cached with thread-safe double-checked
locking. All write operations threaded to avoid blocking the agent
(curate can take 120s with LLM processing).
* fix(memory): thread remaining sync_turns, fix holographic, add config key
Plugin fixes:
- Hindsight: thread sync_turn (was blocking up to 30s via _run_in_thread)
- RetainDB: thread sync_turn (was blocking on HTTP POST)
- Both: shutdown now joins sync threads alongside prefetch threads
Holographic retrieval fixes:
- reason(): removed dead intersection_key computation (bundled but never
used in scoring). Now reuses pre-computed entity_residuals directly,
moved role_content encoding outside the inner loop.
- contradict(): added _MAX_CONTRADICT_FACTS=500 scaling guard. Above
500 facts, only checks the most recently updated ones to avoid O(n^2)
explosion (~125K comparisons at 500 is acceptable).
Config:
- Added memory.provider key to DEFAULT_CONFIG ("" = builtin only).
No version bump needed (deep_merge handles new keys automatically).
* feat(memory): extract Honcho as a MemoryProvider plugin
Creates plugins/honcho-memory/ as a thin adapter over the existing
honcho_integration/ package. All 4 Honcho tools (profile, search,
context, conclude) move from the normal tool registry to the
MemoryProvider interface.
The plugin delegates all work to HonchoSessionManager — no Honcho
logic is reimplemented. It uses the existing config chain:
$HERMES_HOME/honcho.json -> ~/.honcho/config.json -> env vars.
Lifecycle hooks:
- initialize: creates HonchoSessionManager via existing client factory
- prefetch: background dialectic query
- sync_turn: records messages + flushes to API (threaded)
- on_memory_write: mirrors user profile writes as conclusions
- on_session_end: flushes all pending messages
This is a prerequisite for the MemoryManager wiring in run_agent.py.
Once wired, Honcho goes through the same provider interface as all
other memory plugins, and the scattered Honcho code in run_agent.py
can be consolidated into the single MemoryManager integration point.
* feat(memory): wire MemoryManager into run_agent.py
Adds 8 integration points for the external memory provider plugin,
all purely additive (zero existing code modified):
1. Init (~L1130): Create MemoryManager, find matching plugin provider
from memory.provider config, initialize with session context
2. Tool injection (~L1160): Append provider tool schemas to self.tools
and self.valid_tool_names after memory_manager init
3. System prompt (~L2705): Add external provider's system_prompt_block
alongside existing MEMORY.md/USER.md blocks
4. Tool routing (~L5362): Route provider tool calls through
memory_manager.handle_tool_call() before the catchall handler
5. Memory write bridge (~L5353): Notify external provider via
on_memory_write() when the built-in memory tool writes
6. Pre-compress (~L5233): Call on_pre_compress() before context
compression discards messages
7. Prefetch (~L6421): Inject provider prefetch results into the
current-turn user message (same pattern as Honcho turn context)
8. Turn sync + session end (~L8161, ~L8172): sync_all() after each
completed turn, queue_prefetch_all() for next turn, on_session_end()
+ shutdown_all() at conversation end
All hooks are wrapped in try/except — a failing provider never breaks
the agent. The existing memory system, Honcho integration, and all
other code paths are completely untouched.
Full suite: 7222 passed, 4 pre-existing failures.
* refactor(memory): remove legacy Honcho integration from core
Extracts all Honcho-specific code from run_agent.py, model_tools.py,
toolsets.py, and gateway/run.py. Honcho is now exclusively available
as a memory provider plugin (plugins/honcho-memory/).
Removed from run_agent.py (-457 lines):
- Honcho init block (session manager creation, activation, config)
- 8 Honcho methods: _honcho_should_activate, _strip_honcho_tools,
_activate_honcho, _register_honcho_exit_hook, _queue_honcho_prefetch,
_honcho_prefetch, _honcho_save_user_observation, _honcho_sync
- _inject_honcho_turn_context module-level function
- Honcho system prompt block (tool descriptions, CLI commands)
- Honcho context injection in api_messages building
- Honcho params from __init__ (honcho_session_key, honcho_manager,
honcho_config)
- HONCHO_TOOL_NAMES constant
- All honcho-specific tool dispatch forwarding
Removed from other files:
- model_tools.py: honcho_tools import, honcho params from handle_function_call
- toolsets.py: honcho toolset definition, honcho tools from core tools list
- gateway/run.py: honcho params from AIAgent constructor calls
Removed tests (-339 lines):
- 9 Honcho-specific test methods from test_run_agent.py
- TestHonchoAtexitFlush class from test_exit_cleanup_interrupt.py
Restored two regex constants (_SURROGATE_RE, _BUDGET_WARNING_RE) that
were accidentally removed during the honcho function extraction.
The honcho_integration/ package is kept intact — the plugin delegates
to it. tools/honcho_tools.py registry entries are now dead code (import
commented out in model_tools.py) but the file is preserved for reference.
Full suite: 7207 passed, 4 pre-existing failures. Zero regressions.
* refactor(memory): restructure plugins, add CLI, clean gateway, migration notice
Plugin restructure:
- Move all memory plugins from plugins/<name>-memory/ to plugins/memory/<name>/
(byterover, hindsight, holographic, honcho, mem0, openviking, retaindb)
- New plugins/memory/__init__.py discovery module that scans the directory
directly, loading providers by name without the general plugin system
- run_agent.py uses load_memory_provider() instead of get_plugin_memory_providers()
CLI wiring:
- hermes memory setup — interactive curses picker + config wizard
- hermes memory status — show active provider, config, availability
- hermes memory off — disable external provider (built-in only)
- hermes honcho — now shows migration notice pointing to hermes memory setup
Gateway cleanup:
- Remove _get_or_create_gateway_honcho (already removed in prev commit)
- Remove _shutdown_gateway_honcho and _shutdown_all_gateway_honcho methods
- Remove all calls to shutdown methods (4 call sites)
- Remove _honcho_managers/_honcho_configs dict references
Dead code removal:
- Delete tools/honcho_tools.py (279 lines, import was already commented out)
- Delete tests/gateway/test_honcho_lifecycle.py (131 lines, tested removed methods)
- Remove if False placeholder from run_agent.py
Migration:
- Honcho migration notice on startup: detects existing honcho.json or
~/.honcho/config.json, prints guidance to run hermes memory setup.
Only fires when memory.provider is not set and not in quiet mode.
Full suite: 7203 passed, 4 pre-existing failures. Zero regressions.
* feat(memory): standardize plugin config + add per-plugin documentation
Config architecture:
- Add save_config(values, hermes_home) to MemoryProvider ABC
- Honcho: writes to $HERMES_HOME/honcho.json (SDK native)
- Mem0: writes to $HERMES_HOME/mem0.json
- Hindsight: writes to $HERMES_HOME/hindsight/config.json
- Holographic: writes to config.yaml under plugins.hermes-memory-store
- OpenViking/RetainDB/ByteRover: env-var only (default no-op)
Setup wizard (hermes memory setup):
- Now calls provider.save_config() for non-secret config
- Secrets still go to .env via env vars
- Only memory.provider activation key goes to config.yaml
Documentation:
- README.md for each of the 7 providers in plugins/memory/<name>/
- Requirements, setup (wizard + manual), config reference, tools table
- Consistent format across all providers
The contract for new memory plugins:
- get_config_schema() declares all fields (REQUIRED)
- save_config() writes native config (REQUIRED if not env-var-only)
- Secrets use env_var field in schema, written to .env by wizard
- README.md in the plugin directory
* docs: add memory providers user guide + developer guide
New pages:
- user-guide/features/memory-providers.md — comprehensive guide covering
all 7 shipped providers (Honcho, OpenViking, Mem0, Hindsight,
Holographic, RetainDB, ByteRover). Each with setup, config, tools,
cost, and unique features. Includes comparison table and profile
isolation notes.
- developer-guide/memory-provider-plugin.md — how to build a new memory
provider plugin. Covers ABC, required methods, config schema,
save_config, threading contract, profile isolation, testing.
Updated pages:
- user-guide/features/memory.md — replaced Honcho section with link to
new Memory Providers page
- user-guide/features/honcho.md — replaced with migration redirect to
the new Memory Providers page
- sidebars.ts — added both new pages to navigation
* fix(memory): auto-migrate Honcho users to memory provider plugin
When honcho.json or ~/.honcho/config.json exists but memory.provider
is not set, automatically set memory.provider: honcho in config.yaml
and activate the plugin. The plugin reads the same config files, so
all data and credentials are preserved. Zero user action needed.
Persists the migration to config.yaml so it only fires once. Prints
a one-line confirmation in non-quiet mode.
* fix(memory): only auto-migrate Honcho when enabled + credentialed
Check HonchoClientConfig.enabled AND (api_key OR base_url) before
auto-migrating — not just file existence. Prevents false activation
for users who disabled Honcho, stopped using it (config lingers),
or have ~/.honcho/ from a different tool.
* feat(memory): auto-install pip dependencies during hermes memory setup
Reads pip_dependencies from plugin.yaml, checks which are missing,
installs them via pip before config walkthrough. Also shows install
guidance for external_dependencies (e.g. brv CLI for ByteRover).
Updated all 7 plugin.yaml files with pip_dependencies:
- honcho: honcho-ai
- mem0: mem0ai
- openviking: httpx
- hindsight: hindsight-client
- holographic: (none)
- retaindb: requests
- byterover: (external_dependencies for brv CLI)
* fix: remove remaining Honcho crash risks from cli.py and gateway
cli.py: removed Honcho session re-mapping block (would crash importing
deleted tools/honcho_tools.py), Honcho flush on compress, Honcho
session display on startup, Honcho shutdown on exit, honcho_session_key
AIAgent param.
gateway/run.py: removed honcho_session_key params from helper methods,
sync_honcho param, _honcho.shutdown() block.
tests: fixed test_cron_session_with_honcho_key_skipped (was passing
removed honcho_key param to _flush_memories_for_session).
* fix: include plugins/ in pyproject.toml package list
Without this, plugins/memory/ wouldn't be included in non-editable
installs. Hermes always runs from the repo checkout so this is belt-
and-suspenders, but prevents breakage if the install method changes.
* fix(memory): correct pip-to-import name mapping for dep checks
The heuristic dep.replace('-', '_') fails for packages where the pip
name differs from the import name: honcho-ai→honcho, mem0ai→mem0,
hindsight-client→hindsight_client. Added explicit mapping table so
hermes memory setup doesn't try to reinstall already-installed packages.
* chore: remove dead code from old plugin memory registration path
- hermes_cli/plugins.py: removed register_memory_provider(),
_memory_providers list, get_plugin_memory_providers() — memory
providers now use plugins/memory/ discovery, not the general plugin system
- hermes_cli/main.py: stripped 74 lines of dead honcho argparse
subparsers (setup, status, sessions, map, peer, mode, tokens,
identity, migrate) — kept only the migration redirect
- agent/memory_provider.py: updated docstring to reflect new
registration path
- tests: replaced TestPluginMemoryProviderRegistration with
TestPluginMemoryDiscovery that tests the actual plugins/memory/
discovery system. Added 3 new tests (discover, load, nonexistent).
* chore: delete dead honcho_integration/cli.py and its tests
cli.py (794 lines) was the old 'hermes honcho' command handler — nobody
calls it since cmd_honcho was replaced with a migration redirect.
Deleted tests that imported from removed code:
- tests/honcho_integration/test_cli.py (tested _resolve_api_key)
- tests/honcho_integration/test_config_isolation.py (tested CLI config paths)
- tests/tools/test_honcho_tools.py (tested the deleted tools/honcho_tools.py)
Remaining honcho_integration/ files (actively used by the plugin):
- client.py (445 lines) — config loading, SDK client creation
- session.py (991 lines) — session management, queries, flush
* refactor: move honcho_integration/ into the honcho plugin
Moves client.py (445 lines) and session.py (991 lines) from the
top-level honcho_integration/ package into plugins/memory/honcho/.
No Honcho code remains in the main codebase.
- plugins/memory/honcho/client.py — config loading, SDK client creation
- plugins/memory/honcho/session.py — session management, queries, flush
- Updated all imports: run_agent.py (auto-migration), hermes_cli/doctor.py,
plugin __init__.py, session.py cross-import, all tests
- Removed honcho_integration/ package and pyproject.toml entry
- Renamed tests/honcho_integration/ → tests/honcho_plugin/
* docs: update architecture + gateway-internals for memory provider system
- architecture.md: replaced honcho_integration/ with plugins/memory/
- gateway-internals.md: replaced Honcho-specific session routing and
flush lifecycle docs with generic memory provider interface docs
* fix: update stale mock path for resolve_active_host after honcho plugin migration
* fix(memory): address review feedback — P0 lifecycle, ABC contract, honcho CLI restore
Review feedback from Honcho devs (erosika):
P0 — Provider lifecycle:
- Remove on_session_end() + shutdown_all() from run_conversation() tail
(was killing providers after every turn in multi-turn sessions)
- Add shutdown_memory_provider() method on AIAgent for callers
- Wire shutdown into CLI atexit, reset_conversation, gateway stop/expiry
Bug fixes:
- Remove sync_honcho=False kwarg from /btw callsites (TypeError crash)
- Fix doctor.py references to dead 'hermes honcho setup' command
- Cache prefetch_all() before tool loop (was re-calling every iteration)
ABC contract hardening (all backwards-compatible):
- Add session_id kwarg to prefetch/sync_turn/queue_prefetch
- Make on_pre_compress() return str (provider insights in compression)
- Add **kwargs to on_turn_start() for runtime context
- Add on_delegation() hook for parent-side subagent observation
- Document agent_context/agent_identity/agent_workspace kwargs on
initialize() (prevents cron corruption, enables profile scoping)
- Fix docstring: single external provider, not multiple
Honcho CLI restoration:
- Add plugins/memory/honcho/cli.py (from main's honcho_integration/cli.py
with imports adapted to plugin path)
- Restore full hermes honcho command with all subcommands (status, peer,
mode, tokens, identity, enable/disable, sync, peers, --target-profile)
- Restore auto-clone on profile creation + sync on hermes update
- hermes honcho setup now redirects to hermes memory setup
* fix(memory): wire on_delegation, skip_memory for cron/flush, fix ByteRover return type
- Wire on_delegation() in delegate_tool.py — parent's memory provider
is notified with task+result after each subagent completes
- Add skip_memory=True to cron scheduler (prevents cron system prompts
from corrupting user representations — closes #4052)
- Add skip_memory=True to gateway flush agent (throwaway agent shouldn't
activate memory provider)
- Fix ByteRover on_pre_compress() return type: None -> str
* fix(honcho): port profile isolation fixes from PR #4632
Ports 5 bug fixes found during profile testing (erosika's PR #4632):
1. 3-tier config resolution — resolve_config_path() now checks
$HERMES_HOME/honcho.json → ~/.hermes/honcho.json → ~/.honcho/config.json
(non-default profiles couldn't find shared host blocks)
2. Thread host=_host_key() through from_global_config() in cmd_setup,
cmd_status, cmd_identity (--target-profile was being ignored)
3. Use bare profile name as aiPeer (not host key with dots) — Honcho's
peer ID pattern is ^[a-zA-Z0-9_-]+$, dots are invalid
4. Wrap add_peers() in try/except — was fatal on new AI peers, killed
all message uploads for the session
5. Gate Honcho clone behind --clone/--clone-all on profile create
(bare create should be blank-slate)
Also: sanitize assistant_peer_id via _sanitize_id()
* fix(tests): add module cleanup fixture to test_cli_provider_resolution
test_cli_provider_resolution._import_cli() wipes tools.*, cli, and
run_agent from sys.modules to force fresh imports, but had no cleanup.
This poisoned all subsequent tests on the same xdist worker — mocks
targeting tools.file_tools, tools.send_message_tool, etc. patched the
NEW module object while already-imported functions still referenced
the OLD one. Caused ~25 cascade failures: send_message KeyError,
process_registry FileNotFoundError, file_read_guards timeouts,
read_loop_detection file-not-found, mcp_oauth None port, and
provider_parity/codex_execution stale tool lists.
Fix: autouse fixture saves all affected modules before each test and
restores them after, matching the pattern in
test_managed_browserbase_and_modal.py.
2026-04-02 15:33:51 -07:00
skip_memory = True , # Cron system prompts would corrupt user representations
2026-03-14 00:12:34 -07:00
platform = " cron " ,
2026-03-25 11:13:21 -07:00
session_id = _cron_session_id ,
2026-03-11 13:11:45 +03:00
session_db = _session_db ,
2026-02-02 08:26:42 -08:00
)
2026-04-05 23:49:42 -07:00
# Run the agent with an *inactivity*-based timeout: the job can run
# for hours if it's actively calling tools / receiving stream tokens,
# but a hung API call or stuck tool with no activity for the configured
# duration is caught and killed. Default 600s (10 min inactivity);
# override via HERMES_CRON_TIMEOUT env var. 0 = unlimited.
#
# Uses the agent's built-in activity tracker (updated by
# _touch_activity() on every tool call, API call, and stream delta).
2026-04-02 22:52:52 +05:30
_cron_timeout = float ( os . getenv ( " HERMES_CRON_TIMEOUT " , 600 ) )
2026-04-05 23:49:42 -07:00
_cron_inactivity_limit = _cron_timeout if _cron_timeout > 0 else None
_POLL_INTERVAL = 5.0
2026-04-02 23:41:38 +05:30
_cron_pool = concurrent . futures . ThreadPoolExecutor ( max_workers = 1 )
_cron_future = _cron_pool . submit ( agent . run_conversation , prompt )
2026-04-05 23:49:42 -07:00
_inactivity_timeout = False
2026-04-02 23:41:38 +05:30
try :
2026-04-05 23:49:42 -07:00
if _cron_inactivity_limit is None :
# Unlimited — just wait for the result.
result = _cron_future . result ( )
else :
result = None
while True :
done , _ = concurrent . futures . wait (
{ _cron_future } , timeout = _POLL_INTERVAL ,
)
if done :
result = _cron_future . result ( )
break
# Agent still running — check inactivity.
_idle_secs = 0.0
if hasattr ( agent , " get_activity_summary " ) :
try :
_act = agent . get_activity_summary ( )
_idle_secs = _act . get ( " seconds_since_activity " , 0.0 )
except Exception :
pass
if _idle_secs > = _cron_inactivity_limit :
_inactivity_timeout = True
break
except Exception :
_cron_pool . shutdown ( wait = False , cancel_futures = True )
raise
finally :
_cron_pool . shutdown ( wait = False )
if _inactivity_timeout :
# Build diagnostic summary from the agent's activity tracker.
_activity = { }
if hasattr ( agent , " get_activity_summary " ) :
try :
_activity = agent . get_activity_summary ( )
except Exception :
pass
_last_desc = _activity . get ( " last_activity_desc " , " unknown " )
_secs_ago = _activity . get ( " seconds_since_activity " , 0 )
_cur_tool = _activity . get ( " current_tool " )
_iter_n = _activity . get ( " api_call_count " , 0 )
_iter_max = _activity . get ( " max_iterations " , 0 )
2026-04-02 23:41:38 +05:30
logger . error (
2026-04-05 23:49:42 -07:00
" Job ' %s ' idle for %.0f s (inactivity limit %.0f s) "
" | last_activity= %s | iteration= %s / %s | tool= %s " ,
job_name , _secs_ago , _cron_inactivity_limit ,
_last_desc , _iter_n , _iter_max ,
_cur_tool or " none " ,
2026-04-02 23:41:38 +05:30
)
if hasattr ( agent , " interrupt " ) :
2026-04-05 23:49:42 -07:00
agent . interrupt ( " Cron job timed out (inactivity) " )
2026-04-02 23:41:38 +05:30
raise TimeoutError (
2026-04-05 23:49:42 -07:00
f " Cron job ' { job_name } ' idle for "
f " { int ( _secs_ago ) } s (limit { int ( _cron_inactivity_limit ) } s) "
f " — last activity: { _last_desc } "
2026-04-02 23:41:38 +05:30
)
2026-04-02 22:52:52 +05:30
2026-03-22 03:50:27 -07:00
final_response = result . get ( " final_response " , " " ) or " "
# Use a separate variable for log display; keep final_response clean
# for delivery logic (empty response = no delivery).
logged_response = final_response if final_response else " (No response generated) "
2026-02-02 08:26:42 -08:00
output = f """ # Cron Job: { job_name }
* * Job ID : * * { job_id }
2026-03-03 11:57:18 +05:30
* * Run Time : * * { _hermes_now ( ) . strftime ( ' % Y- % m- %d % H: % M: % S ' ) }
2026-02-02 08:26:42 -08:00
* * Schedule : * * { job . get ( ' schedule_display ' , ' N/A ' ) }
## Prompt
{ prompt }
## Response
2026-03-22 03:50:27 -07:00
{ logged_response }
2026-02-02 08:26:42 -08:00
"""
2026-02-21 03:11:11 -08:00
logger . info ( " Job ' %s ' completed successfully " , job_name )
2026-02-22 17:14:44 -08:00
return True , output , final_response , None
2026-02-02 08:26:42 -08:00
except Exception as e :
error_msg = f " { type ( e ) . __name__ } : { str ( e ) } "
2026-04-05 14:51:13 +03:00
logger . exception ( " Job ' %s ' failed: %s " , job_name , error_msg )
2026-02-02 08:26:42 -08:00
output = f """ # Cron Job: { job_name } (FAILED)
* * Job ID : * * { job_id }
2026-03-03 11:57:18 +05:30
* * Run Time : * * { _hermes_now ( ) . strftime ( ' % Y- % m- %d % H: % M: % S ' ) }
2026-02-02 08:26:42 -08:00
* * Schedule : * * { job . get ( ' schedule_display ' , ' N/A ' ) }
## Prompt
{ prompt }
## Error
` ` `
{ error_msg }
` ` `
"""
2026-02-22 17:14:44 -08:00
return False , output , " " , error_msg
finally :
# Clean up injected env vars so they don't leak to other jobs
2026-03-14 19:07:50 -07:00
for key in (
" HERMES_SESSION_PLATFORM " ,
" HERMES_SESSION_CHAT_ID " ,
" HERMES_SESSION_CHAT_NAME " ,
" HERMES_CRON_AUTO_DELIVER_PLATFORM " ,
" HERMES_CRON_AUTO_DELIVER_CHAT_ID " ,
" HERMES_CRON_AUTO_DELIVER_THREAD_ID " ,
) :
2026-02-22 17:14:44 -08:00
os . environ . pop ( key , None )
2026-03-14 00:12:34 -07:00
if _session_db :
2026-03-25 11:13:21 -07:00
try :
_session_db . end_session ( _cron_session_id , " cron_complete " )
2026-03-26 14:34:31 -07:00
except ( Exception , KeyboardInterrupt ) as e :
2026-03-25 11:13:21 -07:00
logger . debug ( " Job ' %s ' : failed to end session: %s " , job_id , e )
2026-03-14 00:12:34 -07:00
try :
_session_db . close ( )
2026-03-26 14:34:31 -07:00
except ( Exception , KeyboardInterrupt ) as e :
2026-03-14 00:12:34 -07:00
logger . debug ( " Job ' %s ' : failed to close SQLite session store: %s " , job_id , e )
2026-02-02 08:26:42 -08:00
2026-04-05 10:52:29 -07:00
def tick ( verbose : bool = True , adapters = None , loop = None ) - > int :
2026-02-02 08:26:42 -08:00
"""
Check and run all due jobs .
2026-02-21 16:21:19 -08:00
Uses a file lock so only one tick runs at a time , even if the gateway ' s
in - process ticker and a standalone daemon or manual tick overlap .
2026-02-02 08:26:42 -08:00
Args :
verbose : Whether to print status messages
2026-04-05 10:52:29 -07:00
adapters : Optional dict mapping Platform → live adapter ( from gateway )
loop : Optional asyncio event loop ( from gateway ) for live adapter sends
2026-02-02 08:26:42 -08:00
Returns :
2026-02-21 16:21:19 -08:00
Number of jobs executed ( 0 if another tick is already running )
2026-02-02 08:26:42 -08:00
"""
2026-02-21 16:21:19 -08:00
_LOCK_DIR . mkdir ( parents = True , exist_ok = True )
2026-02-02 08:26:42 -08:00
2026-02-25 16:27:40 -08:00
# Cross-platform file locking: fcntl on Unix, msvcrt on Windows
2026-03-03 02:09:56 +03:30
lock_fd = None
2026-02-21 16:21:19 -08:00
try :
lock_fd = open ( _LOCK_FILE , " w " )
2026-02-25 16:27:40 -08:00
if fcntl :
fcntl . flock ( lock_fd , fcntl . LOCK_EX | fcntl . LOCK_NB )
elif msvcrt :
msvcrt . locking ( lock_fd . fileno ( ) , msvcrt . LK_NBLCK , 1 )
2026-02-21 16:21:19 -08:00
except ( OSError , IOError ) :
logger . debug ( " Tick skipped — another instance holds the lock " )
2026-03-03 02:09:56 +03:30
if lock_fd is not None :
lock_fd . close ( )
2026-02-21 16:21:19 -08:00
return 0
2026-02-02 08:26:42 -08:00
try :
2026-02-21 16:21:19 -08:00
due_jobs = get_due_jobs ( )
if verbose and not due_jobs :
2026-03-03 11:57:18 +05:30
logger . info ( " %s - No jobs due " , _hermes_now ( ) . strftime ( ' % H: % M: % S ' ) )
2026-02-21 16:21:19 -08:00
return 0
if verbose :
2026-03-03 11:57:18 +05:30
logger . info ( " %s - %s job(s) due " , _hermes_now ( ) . strftime ( ' % H: % M: % S ' ) , len ( due_jobs ) )
2026-02-21 16:21:19 -08:00
executed = 0
for job in due_jobs :
2026-02-02 08:26:42 -08:00
try :
2026-03-27 08:02:58 -07:00
# For recurring jobs (cron/interval), advance next_run_at to the
# next future occurrence BEFORE execution. This way, if the
# process crashes mid-run, the job won't re-fire on restart.
# One-shot jobs are left alone so they can retry on restart.
advance_next_run ( job [ " id " ] )
2026-02-22 17:14:44 -08:00
success , output , final_response , error = run_job ( job )
2026-02-21 16:21:19 -08:00
output_file = save_job_output ( job [ " id " ] , output )
if verbose :
logger . info ( " Output saved to: %s " , output_file )
2026-03-17 16:06:49 -07:00
# Deliver the final response to the origin/target chat.
# If the agent responded with [SILENT], skip delivery (but
# output is already saved above). Failed jobs always deliver.
2026-02-22 17:14:44 -08:00
deliver_content = final_response if success else f " ⚠️ Cron job ' { job . get ( ' name ' , job [ ' id ' ] ) } ' failed: \n { error } "
2026-03-17 16:06:49 -07:00
should_deliver = bool ( deliver_content )
2026-04-07 07:43:30 +09:00
if should_deliver and success and SILENT_MARKER in deliver_content . strip ( ) . upper ( ) :
2026-03-17 16:06:49 -07:00
logger . info ( " Job ' %s ' : agent returned %s — skipping delivery " , job [ " id " ] , SILENT_MARKER )
should_deliver = False
if should_deliver :
2026-02-22 17:14:44 -08:00
try :
2026-04-05 10:52:29 -07:00
_deliver_result ( job , deliver_content , adapters = adapters , loop = loop )
2026-02-22 17:14:44 -08:00
except Exception as de :
logger . error ( " Delivery failed for job %s : %s " , job [ " id " ] , de )
2026-02-21 16:21:19 -08:00
mark_job_run ( job [ " id " ] , success , error )
executed + = 1
2026-02-02 08:26:42 -08:00
except Exception as e :
2026-02-21 16:21:19 -08:00
logger . error ( " Error processing job %s : %s " , job [ ' id ' ] , e )
mark_job_run ( job [ " id " ] , False , str ( e ) )
return executed
finally :
2026-02-25 16:27:40 -08:00
if fcntl :
fcntl . flock ( lock_fd , fcntl . LOCK_UN )
elif msvcrt :
try :
msvcrt . locking ( lock_fd . fileno ( ) , msvcrt . LK_UNLCK , 1 )
except ( OSError , IOError ) :
pass
2026-02-21 16:21:19 -08:00
lock_fd . close ( )
2026-02-02 08:26:42 -08:00
if __name__ == " __main__ " :
2026-02-21 16:21:19 -08:00
tick ( verbose = True )