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-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
import sys
import traceback
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-02-02 08:26:42 -08:00
from datetime import datetime
from pathlib import Path
from typing import Optional
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-02-02 08:26:42 -08:00
# Add parent directory to path for imports
sys . path . insert ( 0 , str ( Path ( __file__ ) . parent . parent ) )
from cron . jobs import get_due_jobs , mark_job_run , save_job_output
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)
_hermes_home = Path ( os . getenv ( " HERMES_HOME " , Path . home ( ) / " .hermes " ) )
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 " :
if not origin :
return None
return {
" platform " : origin [ " platform " ] ,
" chat_id " : str ( origin [ " chat_id " ] ) ,
" thread_id " : origin . get ( " thread_id " ) ,
}
if " : " in deliver :
platform_name , chat_id = deliver . split ( " : " , 1 )
return {
" platform " : platform_name ,
" chat_id " : chat_id ,
" thread_id " : None ,
}
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 " ) ,
}
chat_id = os . getenv ( f " { platform_name . upper ( ) } _HOME_CHANNEL " , " " )
if not chat_id :
return None
return {
" platform " : platform_name ,
" chat_id " : chat_id ,
" thread_id " : None ,
}
2026-02-22 17:14:44 -08:00
def _deliver_result ( job : dict , content : str ) - > None :
"""
Deliver job output to the configured target ( origin chat , specific platform , etc . ) .
Uses the standalone platform send functions from send_message_tool so delivery
works whether or not the gateway is running .
"""
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: 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-21 07:18:36 -07:00
# Wrap the content so the user knows this is a cron delivery and that
# the interactive agent has no visibility into it.
task_name = job . get ( " name " , job [ " id " ] )
wrapped = (
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. "
)
2026-02-22 17:14:44 -08:00
# Run the async send in a fresh event loop (safe from any thread)
2026-03-21 07:20:58 -07:00
coro = _send_to_platform ( platform , pconfig , chat_id , wrapped , thread_id = thread_id )
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-03-21 07:18:36 -07:00
future = pool . submit ( asyncio . run , _send_to_platform ( platform , pconfig , chat_id , wrapped , thread_id = thread_id ) )
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 )
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
# Always prepend [SILENT] guidance so the cron agent can suppress
# delivery when it has nothing new or noteworthy to report.
silent_hint = (
" [SYSTEM: If you have nothing new or noteworthy to report, respond "
" with exactly \" [SILENT] \" (optionally followed by a brief internal "
" note). This suppresses delivery to the user while still saving "
" output locally. Only use [SILENT] when there are genuinely no "
" changes worth reporting.] \n \n "
)
prompt = silent_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-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
# Inject origin context so the agent's send_message tool knows the chat
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-02 08:26:42 -08:00
try :
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-14 22:22:31 -07:00
model = job . get ( " model " ) or os . getenv ( " HERMES_MODEL " ) or " anthropic/claude-opus-4.6 "
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
reasoning_config = None
effort = os . getenv ( " HERMES_REASONING_EFFORT " , " " )
if not effort :
effort = str ( _cfg . get ( " agent " , { } ) . get ( " reasoning_effort " , " " ) ) . strip ( )
if effort and effort . lower ( ) != " none " :
valid = ( " xhigh " , " high " , " medium " , " low " , " minimal " )
if effort . lower ( ) in valid :
reasoning_config = { " enabled " : True , " effort " : effort . lower ( ) }
elif effort . lower ( ) == " none " :
reasoning_config = { " enabled " : False }
# 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 ,
2026-03-14 00:12:34 -07:00
platform = " cron " ,
2026-03-11 13:11:45 +03:00
session_id = f " cron_ { job_id } _ { _hermes_now ( ) . strftime ( ' % Y % m %d _ % H % M % S ' ) } " ,
session_db = _session_db ,
2026-02-02 08:26:42 -08:00
)
result = agent . run_conversation ( prompt )
final_response = result . get ( " final_response " , " " )
if not final_response :
final_response = " (No response generated) "
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
{ final_response }
"""
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-02-21 03:11:11 -08:00
logger . error ( " 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 }
{ traceback . format_exc ( ) }
` ` `
"""
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 :
try :
_session_db . close ( )
except Exception as e :
logger . debug ( " Job ' %s ' : failed to close SQLite session store: %s " , job_id , e )
2026-02-02 08:26:42 -08:00
def tick ( verbose : bool = True ) - > int :
"""
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
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-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 )
if should_deliver and success and deliver_content . strip ( ) . upper ( ) . startswith ( SILENT_MARKER ) :
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 :
_deliver_result ( job , deliver_content )
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 )