2026-02-21 22:31:43 -08:00
""" System prompt assembly -- identity, platform hints, skills index, context files.
All functions are stateless . AIAgent . _build_system_prompt ( ) calls these to
assemble pieces , then combines them with memory and ephemeral prompts .
"""
2026-03-27 10:54:02 -07:00
import json
2026-02-21 22:31:43 -08:00
import logging
import os
import re
2026-03-27 10:54:02 -07:00
import threading
from collections import OrderedDict
2026-02-21 22:31:43 -08:00
from pathlib import Path
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
2026-04-12 02:26:28 -07:00
from hermes_constants import get_hermes_home , get_skills_dir , is_wsl
2026-02-21 22:31:43 -08:00
from typing import Optional
2026-03-27 10:54:02 -07:00
from agent . skill_utils import (
extract_skill_conditions ,
extract_skill_description ,
2026-03-29 00:33:30 -07:00
get_all_skills_dirs ,
2026-03-27 10:54:02 -07:00
get_disabled_skill_names ,
iter_skill_index_files ,
parse_frontmatter ,
skill_matches_platform ,
)
from utils import atomic_json_write
2026-02-21 22:31:43 -08:00
logger = logging . getLogger ( __name__ )
Harden agent attack surface: scan writes to memory, skills, cron, and context files
The security scanner (skills_guard.py) was only wired into the hub install path.
All other write paths to persistent state — skills created by the agent, memory
entries, cron prompts, and context files — bypassed it entirely. This closes
those gaps:
- file_operations: deny-list blocks writes to ~/.ssh, ~/.aws, ~/.hermes/.env, etc.
- code_execution_tool: filter secret env vars from sandbox child process
- skill_manager_tool: wire scan_skill() into create/edit/patch/write_file with rollback
- skills_guard: add "agent-created" trust level (same policy as community)
- memory_tool: scan content for injection/exfil before system prompt injection
- prompt_builder: scan AGENTS.md, .cursorrules, SOUL.md for prompt injection
- cronjob_tools: scan cron prompts for critical threats before scheduling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:43:15 -05:00
# ---------------------------------------------------------------------------
# Context file scanning — detect prompt injection in AGENTS.md, .cursorrules,
# SOUL.md before they get injected into the system prompt.
# ---------------------------------------------------------------------------
_CONTEXT_THREAT_PATTERNS = [
( r ' ignore \ s+(previous|all|above|prior) \ s+instructions ' , " prompt_injection " ) ,
( r ' do \ s+not \ s+tell \ s+the \ s+user ' , " deception_hide " ) ,
( r ' system \ s+prompt \ s+override ' , " sys_prompt_override " ) ,
( r ' disregard \ s+(your|all|any) \ s+(instructions|rules|guidelines) ' , " disregard_rules " ) ,
( r ' act \ s+as \ s+(if|though) \ s+you \ s+(have \ s+no|don \' t \ s+have) \ s+(restrictions|limits|rules) ' , " bypass_restrictions " ) ,
( r ' <!--[^>]*(?:ignore|override|system|secret|hidden)[^>]*--> ' , " html_comment_injection " ) ,
2026-04-10 11:49:35 +08:00
( r ' < \ s*div \ s+style \ s*= \ s*[ " \' ][ \ s \ S]*?display \ s*: \ s*none ' , " hidden_div " ) ,
Harden agent attack surface: scan writes to memory, skills, cron, and context files
The security scanner (skills_guard.py) was only wired into the hub install path.
All other write paths to persistent state — skills created by the agent, memory
entries, cron prompts, and context files — bypassed it entirely. This closes
those gaps:
- file_operations: deny-list blocks writes to ~/.ssh, ~/.aws, ~/.hermes/.env, etc.
- code_execution_tool: filter secret env vars from sandbox child process
- skill_manager_tool: wire scan_skill() into create/edit/patch/write_file with rollback
- skills_guard: add "agent-created" trust level (same policy as community)
- memory_tool: scan content for injection/exfil before system prompt injection
- prompt_builder: scan AGENTS.md, .cursorrules, SOUL.md for prompt injection
- cronjob_tools: scan cron prompts for critical threats before scheduling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:43:15 -05:00
( r ' translate \ s+.* \ s+into \ s+.* \ s+and \ s+(execute|run|eval) ' , " translate_execute " ) ,
( r ' curl \ s+[^ \ n]* \ $ \ { ? \ w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API) ' , " exfil_curl " ) ,
( r ' cat \ s+[^ \ n]*( \ .env|credentials| \ .netrc| \ .pgpass) ' , " read_secrets " ) ,
]
_CONTEXT_INVISIBLE_CHARS = {
' \u200b ' , ' \u200c ' , ' \u200d ' , ' \u2060 ' , ' \ufeff ' ,
' \u202a ' , ' \u202b ' , ' \u202c ' , ' \u202d ' , ' \u202e ' ,
}
def _scan_context_content ( content : str , filename : str ) - > str :
""" Scan context file content for injection. Returns sanitized content. """
findings = [ ]
# Check invisible unicode
for char in _CONTEXT_INVISIBLE_CHARS :
if char in content :
findings . append ( f " invisible unicode U+ { ord ( char ) : 04X } " )
# Check threat patterns
for pattern , pid in _CONTEXT_THREAT_PATTERNS :
if re . search ( pattern , content , re . IGNORECASE ) :
findings . append ( pid )
if findings :
logger . warning ( " Context file %s blocked: %s " , filename , " , " . join ( findings ) )
return f " [BLOCKED: { filename } contained potential prompt injection ( { ' , ' . join ( findings ) } ). Content not loaded.] "
return content
2026-03-17 04:16:32 -07:00
def _find_git_root ( start : Path ) - > Optional [ Path ] :
""" Walk *start* and its parents looking for a ``.git`` directory.
Returns the directory containing ` ` . git ` ` , or ` ` None ` ` if we hit the
filesystem root without finding one .
"""
current = start . resolve ( )
for parent in [ current , * current . parents ] :
if ( parent / " .git " ) . exists ( ) :
return parent
return None
_HERMES_MD_NAMES = ( " .hermes.md " , " HERMES.md " )
def _find_hermes_md ( cwd : Path ) - > Optional [ Path ] :
""" Discover the nearest ``.hermes.md`` or ``HERMES.md``.
Search order : * cwd * first , then each parent directory up to ( and
including ) the git repository root . Returns the first match , or
` ` None ` ` if nothing is found .
"""
stop_at = _find_git_root ( cwd )
current = cwd . resolve ( )
for directory in [ current , * current . parents ] :
for name in _HERMES_MD_NAMES :
candidate = directory / name
if candidate . is_file ( ) :
return candidate
# Stop walking at the git root (or filesystem root).
if stop_at and directory == stop_at :
break
return None
def _strip_yaml_frontmatter ( content : str ) - > str :
""" Remove optional YAML frontmatter (``---`` delimited) from *content*.
The frontmatter may contain structured config ( model overrides , tool
settings ) that will be handled separately in a future PR . For now we
strip it so only the human - readable markdown body is injected into the
system prompt .
"""
if content . startswith ( " --- " ) :
end = content . find ( " \n --- " , 3 )
if end != - 1 :
# Skip past the closing --- and any trailing newline
body = content [ end + 4 : ] . lstrip ( " \n " )
return body if body else content
return content
2026-02-21 22:31:43 -08:00
# =========================================================================
# Constants
# =========================================================================
DEFAULT_AGENT_IDENTITY = (
" You are Hermes Agent, an intelligent AI assistant created by Nous Research. "
" You are helpful, knowledgeable, and direct. You assist users with a wide "
" range of tasks including answering questions, writing and editing code, "
" analyzing information, creative work, and executing actions via your tools. "
" You communicate clearly, admit uncertainty when appropriate, and prioritize "
2026-03-07 10:14:19 -08:00
" being genuinely useful over being verbose unless otherwise directed below. "
" Be targeted and efficient in your exploration and investigations. "
2026-02-21 22:31:43 -08:00
)
2026-02-22 02:31:52 -08:00
MEMORY_GUIDANCE = (
2026-03-14 11:26:18 -07:00
" You have persistent memory across sessions. Save durable facts using the memory "
" tool: user preferences, environment details, tool quirks, and stable conventions. "
2026-03-16 06:52:32 -07:00
" Memory is injected into every turn, so keep it compact and focused on facts that "
" will still matter later. \n "
" Prioritize what reduces future user steering — the most valuable memory is one "
" that prevents the user from having to correct or remind you again. "
" User preferences and recurring corrections matter more than procedural task details. \n "
" Do NOT save task progress, session outcomes, completed-work logs, or temporary TODO "
" state to memory; use session_search to recall those from past transcripts. "
" If you ' ve discovered a new way to do something, solved a problem that could be "
2026-04-19 12:00:53 -07:00
" necessary later, save it as a skill with the skill tool. \n "
" Write memories as declarative facts, not instructions to yourself. "
" ' User prefers concise responses ' ✓ — ' Always respond concisely ' ✗. "
" ' Project uses pytest with xdist ' ✓ — ' Run tests with pytest -n 4 ' ✗. "
" Imperative phrasing gets re-read as a directive in later sessions and can "
" cause repeated work or override the user ' s current request. Procedures and "
" workflows belong in skills, not memory. "
2026-02-22 02:31:52 -08:00
)
SESSION_SEARCH_GUIDANCE = (
" When the user references something from a past conversation or you suspect "
2026-03-14 11:26:18 -07:00
" relevant cross-session context exists, use session_search to recall it before "
" asking them to repeat themselves. "
2026-02-22 02:31:52 -08:00
)
2026-02-22 13:28:13 -08:00
SKILLS_GUIDANCE = (
" After completing a complex task (5+ tool calls), fixing a tricky error, "
2026-03-16 06:52:32 -07:00
" or discovering a non-trivial workflow, save the approach as a "
" skill with skill_manage so you can reuse it next time. \n "
" When using a skill and finding it outdated, incomplete, or wrong, "
" patch it immediately with skill_manage(action= ' patch ' ) — don ' t wait to be asked. "
" Skills that aren ' t maintained become liabilities. "
2026-02-22 13:28:13 -08:00
)
2026-03-28 07:38:36 -07:00
TOOL_USE_ENFORCEMENT_GUIDANCE = (
" # Tool-use enforcement \n "
" You MUST use your tools to take action — do not describe what you would do "
" or plan to do without actually doing it. When you say you will perform an "
" action (e.g. ' I will run the tests ' , ' Let me check the file ' , ' I will create "
" the project ' ), you MUST immediately make the corresponding tool call in the same "
" response. Never end your turn with a promise of future action — execute it now. \n "
" Keep working until the task is actually complete. Do not stop with a summary of "
" what you plan to do next time. If you have tools available that can accomplish "
" the task, use them instead of telling the user what you would do. \n "
" Every response should either (a) contain tool calls that make progress, or "
" (b) deliver a final result to the user. Responses that only describe intentions "
" without acting are not acceptable. "
)
# Model name substrings that trigger tool-use enforcement guidance.
# Add new patterns here when a model family needs explicit steering.
2026-04-06 11:22:07 -07:00
TOOL_USE_ENFORCEMENT_MODELS = ( " gpt " , " codex " , " gemini " , " gemma " , " grok " )
2026-04-02 11:52:34 -07:00
2026-04-05 21:51:07 -07:00
# OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes
# where GPT models abandon work on partial results, skip prerequisite lookups,
# hallucinate instead of using tools, and declare "done" without verification.
# Inspired by patterns from OpenAI's GPT-5.4 prompting guide & OpenClaw PR #38953.
OPENAI_MODEL_EXECUTION_GUIDANCE = (
" # Execution discipline \n "
" <tool_persistence> \n "
" - Use tools whenever they improve correctness, completeness, or grounding. \n "
" - Do not stop early when another tool call would materially improve the result. \n "
" - If a tool returns empty or partial results, retry with a different query or "
" strategy before giving up. \n "
" - Keep calling tools until: (1) the task is complete, AND (2) you have verified "
" the result. \n "
" </tool_persistence> \n "
" \n "
2026-04-08 04:06:42 -07:00
" <mandatory_tool_use> \n "
" NEVER answer these from memory or mental computation — ALWAYS use a tool: \n "
" - Arithmetic, math, calculations → use terminal or execute_code \n "
" - Hashes, encodings, checksums → use terminal (e.g. sha256sum, base64) \n "
" - Current time, date, timezone → use terminal (e.g. date) \n "
" - System state: OS, CPU, memory, disk, ports, processes → use terminal \n "
" - File contents, sizes, line counts → use read_file, search_files, or terminal \n "
" - Git history, branches, diffs → use terminal \n "
" - Current facts (weather, news, versions) → use web_search \n "
" Your memory and user profile describe the USER, not the system you are "
" running on. The execution environment may differ from what the user profile "
" says about their personal setup. \n "
" </mandatory_tool_use> \n "
" \n "
" <act_dont_ask> \n "
" When a question has an obvious default interpretation, act on it immediately "
" instead of asking for clarification. Examples: \n "
" - ' Is port 443 open? ' → check THIS machine (don ' t ask ' open where? ' ) \n "
" - ' What OS am I running? ' → check the live system (don ' t use user profile) \n "
" - ' What time is it? ' → run `date` (don ' t guess) \n "
" Only ask for clarification when the ambiguity genuinely changes what tool "
" you would call. \n "
" </act_dont_ask> \n "
" \n "
2026-04-05 21:51:07 -07:00
" <prerequisite_checks> \n "
" - Before taking an action, check whether prerequisite discovery, lookup, or "
" context-gathering steps are needed. \n "
" - Do not skip prerequisite steps just because the final action seems obvious. \n "
" - If a task depends on output from a prior step, resolve that dependency first. \n "
" </prerequisite_checks> \n "
" \n "
" <verification> \n "
" Before finalizing your response: \n "
" - Correctness: does the output satisfy every stated requirement? \n "
" - Grounding: are factual claims backed by tool outputs or provided context? \n "
" - Formatting: does the output match the requested format or schema? \n "
" - Safety: if the next step has side effects (file writes, commands, API calls), "
" confirm scope before executing. \n "
" </verification> \n "
" \n "
" <missing_context> \n "
" - If required context is missing, do NOT guess or hallucinate an answer. \n "
" - Use the appropriate lookup tool when missing information is retrievable "
" (search_files, web_search, read_file, etc.). \n "
" - Ask a clarifying question only when the information cannot be retrieved by tools. \n "
" - If you must proceed with incomplete information, label assumptions explicitly. \n "
" </missing_context> "
)
2026-04-02 11:52:34 -07:00
# Gemini/Gemma-specific operational guidance, adapted from OpenCode's gemini.txt.
# Injected alongside TOOL_USE_ENFORCEMENT_GUIDANCE when the model is Gemini or Gemma.
GOOGLE_MODEL_OPERATIONAL_GUIDANCE = (
" # Google model operational directives \n "
" Follow these operational rules strictly: \n "
" - **Absolute paths:** Always construct and use absolute file paths for all "
" file system operations. Combine the project root with relative paths. \n "
" - **Verify first:** Use read_file/search_files to check file contents and "
" project structure before making changes. Never guess at file contents. \n "
" - **Dependency checks:** Never assume a library is available. Check "
" package.json, requirements.txt, Cargo.toml, etc. before importing. \n "
" - **Conciseness:** Keep explanatory text brief — a few sentences, not "
" paragraphs. Focus on actions and results over narration. \n "
" - **Parallel tool calls:** When you need to perform multiple independent "
" operations (e.g. reading several files), make all the tool calls in a "
" single response rather than sequentially. \n "
" - **Non-interactive commands:** Use flags like -y, --yes, --non-interactive "
" to prevent CLI tools from hanging on prompts. \n "
" - **Keep going:** Work autonomously until the task is fully resolved. "
" Don ' t stop with a plan — execute it. \n "
)
2026-03-28 07:38:36 -07:00
2026-04-01 14:49:32 -07:00
# Model name substrings that should use the 'developer' role instead of
# 'system' for the system prompt. OpenAI's newer models (GPT-5, Codex)
# give stronger instruction-following weight to the 'developer' role.
# The swap happens at the API boundary in _build_api_kwargs() so internal
# message representation stays consistent ("system" everywhere).
DEVELOPER_ROLE_MODELS = ( " gpt-5 " , " codex " )
2026-02-21 22:31:43 -08:00
PLATFORM_HINTS = {
" whatsapp " : (
" You are on a text messaging communication platform, WhatsApp. "
2026-03-02 16:34:49 -03:00
" Please do not use markdown as it does not render. "
" You can send media files natively: to deliver a file to the user, "
" include MEDIA:/absolute/path/to/file in your response. The file "
" will be sent as a native WhatsApp attachment — images (.jpg, .png, "
" .webp) appear as photos, videos (.mp4, .mov) play inline, and other "
" files arrive as downloadable documents. You can also include image "
" URLs in markdown format  and they will be sent as photos. "
2026-02-21 22:31:43 -08:00
) ,
" telegram " : (
" You are on a text messaging communication platform, Telegram. "
2026-04-15 20:43:55 +03:00
" Standard markdown is automatically converted to Telegram format. "
" Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, "
" `inline code`, ```code blocks```, [links](url), and ## headers. "
2026-03-02 16:34:49 -03:00
" You can send media files natively: to deliver a file to the user, "
2026-03-07 22:57:05 -08:00
" include MEDIA:/absolute/path/to/file in your response. Images "
" (.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice "
" bubbles, and videos (.mp4) play inline. You can also include image "
" URLs in markdown format  and they will be sent as native photos. "
2026-02-21 22:31:43 -08:00
) ,
" discord " : (
2026-03-07 22:57:05 -08:00
" You are in a Discord server or group chat communicating with your user. "
" You can send media files natively: include MEDIA:/absolute/path/to/file "
" in your response. Images (.png, .jpg, .webp) are sent as photo "
" attachments, audio as file attachments. You can also include image URLs "
" in markdown format  and they will be sent as attachments. "
) ,
" slack " : (
" You are in a Slack workspace communicating with your user. "
" You can send media files natively: include MEDIA:/absolute/path/to/file "
" in your response. Images (.png, .jpg, .webp) are uploaded as photo "
" attachments, audio as file attachments. You can also include image URLs "
" in markdown format  and they will be uploaded as attachments. "
2026-02-21 22:31:43 -08:00
) ,
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 " : (
" You are on a text messaging communication platform, Signal. "
" Please do not use markdown as it does not render. "
" You can send media files natively: to deliver a file to the user, "
" include MEDIA:/absolute/path/to/file in your response. Images "
" (.png, .jpg, .webp) appear as photos, audio as attachments, and other "
" files arrive as downloadable documents. You can also include image "
" URLs in markdown format  and they will be sent as photos. "
) ,
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 " : (
" You are communicating via email. Write clear, well-structured responses "
" suitable for email. Use plain text formatting (no markdown). "
" Keep responses concise but complete. You can send file attachments — "
" include MEDIA:/absolute/path/to/file in your response. The subject line "
" is preserved for threading. Do not include greetings or sign-offs unless "
" contextually appropriate. "
) ,
2026-03-14 19:07:50 -07:00
" cron " : (
2026-03-20 05:18:05 -07:00
" You are running as a scheduled cron job. There is no user present — you "
" cannot ask questions, request clarification, or wait for follow-up. Execute "
" the task fully and autonomously, making reasonable decisions where needed. "
" Your final response is automatically delivered to the job ' s configured "
" destination — put the primary content directly in your response. "
2026-03-14 19:07:50 -07:00
) ,
2026-02-21 22:31:43 -08:00
" cli " : (
" You are a CLI AI Agent. Try not to use markdown but simple text "
fix(prompt): tell CLI agents not to emit MEDIA:/path tags (#13766)
The CLI has no attachment channel — MEDIA:<path> tags are only
intercepted on messaging gateway platforms (Telegram, Discord,
Slack, WhatsApp, Signal, BlueBubbles, email, etc.). On the CLI
they render as literal text, which is confusing for users.
The CLI platform hint was the one PLATFORM_HINTS entry that said
nothing about file delivery, so models trained on the messaging
hints would default to MEDIA: tags on the CLI too. Tool schemas
(browser_tool, tts_tool, etc.) also recommend MEDIA: generically.
Extend the CLI hint to explicitly discourage MEDIA: tags and tell
the agent to reference files by plain absolute path instead.
Add a regression test asserting the CLI hint carries negative
guidance about MEDIA: while messaging hints keep positive guidance.
2026-04-21 19:36:05 -07:00
" renderable inside a terminal. "
" File delivery: there is no attachment channel — the user reads your "
" response directly in their terminal. Do NOT emit MEDIA:/path tags "
" (those are only intercepted on messaging platforms like Telegram, "
" Discord, Slack, etc.; on the CLI they render as literal text). "
" When referring to a file you created or changed, just state its "
" absolute path in plain text; the user can open it from there. "
2026-02-21 22:31:43 -08:00
) ,
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 " : (
" You are communicating via SMS. Keep responses concise and use plain text "
" only — no markdown, no formatting. SMS messages are limited to ~1600 "
" characters, so be brief and direct. "
) ,
feat(gateway): add BlueBubbles iMessage platform adapter (#6437)
Adds Apple iMessage as a gateway platform via BlueBubbles macOS server.
Architecture:
- Webhook-based inbound (event-driven, no polling/dedup needed)
- Email/phone → chat GUID resolution for user-friendly addressing
- Private API safety (checks helper_connected before tapback/typing)
- Inbound attachment downloading (images, audio, documents cached locally)
- Markdown stripping for clean iMessage delivery
- Smart progress suppression for platforms without message editing
Based on PR #5869 by @benjaminsehl (webhook architecture, GUID resolution,
Private API safety, progress suppression) with inbound attachment downloading
from PR #4588 by @1960697431 (attachment cache routing).
Integration points: Platform enum, env config, adapter factory, auth maps,
cron delivery, send_message routing, channel directory, platform hints,
toolset definition, setup wizard, status display.
27 tests covering config, adapter, webhook parsing, GUID resolution,
attachment download routing, toolset consistency, and prompt hints.
2026-04-08 23:54:03 -07:00
" bluebubbles " : (
" You are chatting via iMessage (BlueBubbles). iMessage does not render "
" markdown formatting — use plain text. Keep responses concise as they "
" appear as text messages. You can send media files natively: include "
" MEDIA:/absolute/path/to/file in your response. Images (.jpg, .png, "
" .heic) appear as photos and other files arrive as attachments. "
) ,
2026-04-10 05:20:20 -07:00
" weixin " : (
" You are on Weixin/WeChat. Markdown formatting is supported, so you may use it when "
" it improves readability, but keep the message compact and chat-friendly. You can send media files natively: "
" include MEDIA:/absolute/path/to/file in your response. Images are sent as native "
" photos, videos play inline when supported, and other files arrive as downloadable "
" documents. You can also include image URLs in markdown format  and they "
" will be downloaded and sent as native media when possible. "
) ,
2026-04-13 17:50:49 +08:00
" wecom " : (
" You are on WeCom (企业微信 / Enterprise WeChat). Markdown formatting is supported. "
" You CAN send media files natively — to deliver a file to the user, include "
" MEDIA:/absolute/path/to/file in your response. The file will be sent as a native "
" WeCom attachment: images (.jpg, .png, .webp) are sent as photos (up to 10 MB), "
" other files (.pdf, .docx, .xlsx, .md, .txt, etc.) arrive as downloadable documents "
" (up to 20 MB), and videos (.mp4) play inline. Voice messages are supported but "
" must be in AMR format — other audio formats are automatically sent as file attachments. "
" You can also include image URLs in markdown format  and they will be "
" downloaded and sent as native photos. Do NOT tell the user you lack file-sending "
" capability — use MEDIA: syntax whenever a file delivery is appropriate. "
) ,
2026-04-14 01:33:06 +08:00
" qqbot " : (
" You are on QQ, a popular Chinese messaging platform. QQ supports markdown formatting "
" and emoji. You can send media files natively: include MEDIA:/absolute/path/to/file in "
" your response. Images are sent as native photos, and other files arrive as downloadable "
" documents. "
) ,
2026-02-21 22:31:43 -08:00
}
2026-04-12 02:26:28 -07:00
# ---------------------------------------------------------------------------
# Environment hints — execution-environment awareness for the agent.
# Unlike PLATFORM_HINTS (which describe the messaging channel), these describe
# the machine/OS the agent's tools actually run on.
# ---------------------------------------------------------------------------
WSL_ENVIRONMENT_HINT = (
" You are running inside WSL (Windows Subsystem for Linux). "
" The Windows host filesystem is mounted under /mnt/ — "
" /mnt/c/ is the C: drive, /mnt/d/ is D:, etc. "
" The user ' s Windows files are typically at "
" /mnt/c/Users/<username>/Desktop/, Documents/, Downloads/, etc. "
" When the user references Windows paths or desktop files, translate "
" to the /mnt/c/ equivalent. You can list /mnt/c/Users/ to discover "
" the Windows username if needed. "
)
def build_environment_hints ( ) - > str :
""" Return environment-specific guidance for the system prompt.
Detects WSL , and can be extended for Termux , Docker , etc .
Returns an empty string when no special environment is detected .
"""
hints : list [ str ] = [ ]
if is_wsl ( ) :
hints . append ( WSL_ENVIRONMENT_HINT )
return " \n \n " . join ( hints )
2026-02-21 22:31:43 -08:00
CONTEXT_FILE_MAX_CHARS = 20_000
CONTEXT_TRUNCATE_HEAD_RATIO = 0.7
CONTEXT_TRUNCATE_TAIL_RATIO = 0.2
2026-03-27 10:54:02 -07:00
# =========================================================================
# Skills prompt cache
# =========================================================================
_SKILLS_PROMPT_CACHE_MAX = 8
_SKILLS_PROMPT_CACHE : OrderedDict [ tuple , str ] = OrderedDict ( )
_SKILLS_PROMPT_CACHE_LOCK = threading . Lock ( )
_SKILLS_SNAPSHOT_VERSION = 1
def _skills_prompt_snapshot_path ( ) - > Path :
return get_hermes_home ( ) / " .skills_prompt_snapshot.json "
def clear_skills_system_prompt_cache ( * , clear_snapshot : bool = False ) - > None :
""" Drop the in-process skills prompt cache (and optionally the disk snapshot). """
with _SKILLS_PROMPT_CACHE_LOCK :
_SKILLS_PROMPT_CACHE . clear ( )
if clear_snapshot :
try :
_skills_prompt_snapshot_path ( ) . unlink ( missing_ok = True )
except OSError as e :
logger . debug ( " Could not remove skills prompt snapshot: %s " , e )
def _build_skills_manifest ( skills_dir : Path ) - > dict [ str , list [ int ] ] :
""" Build an mtime/size manifest of all SKILL.md and DESCRIPTION.md files. """
manifest : dict [ str , list [ int ] ] = { }
for filename in ( " SKILL.md " , " DESCRIPTION.md " ) :
for path in iter_skill_index_files ( skills_dir , filename ) :
try :
st = path . stat ( )
except OSError :
continue
manifest [ str ( path . relative_to ( skills_dir ) ) ] = [ st . st_mtime_ns , st . st_size ]
return manifest
def _load_skills_snapshot ( skills_dir : Path ) - > Optional [ dict ] :
""" Load the disk snapshot if it exists and its manifest still matches. """
snapshot_path = _skills_prompt_snapshot_path ( )
if not snapshot_path . exists ( ) :
return None
try :
snapshot = json . loads ( snapshot_path . read_text ( encoding = " utf-8 " ) )
except Exception :
return None
if not isinstance ( snapshot , dict ) :
return None
if snapshot . get ( " version " ) != _SKILLS_SNAPSHOT_VERSION :
return None
if snapshot . get ( " manifest " ) != _build_skills_manifest ( skills_dir ) :
return None
return snapshot
def _write_skills_snapshot (
skills_dir : Path ,
manifest : dict [ str , list [ int ] ] ,
skill_entries : list [ dict ] ,
category_descriptions : dict [ str , str ] ,
) - > None :
""" Persist skill metadata to disk for fast cold-start reuse. """
payload = {
" version " : _SKILLS_SNAPSHOT_VERSION ,
" manifest " : manifest ,
" skills " : skill_entries ,
" category_descriptions " : category_descriptions ,
}
try :
atomic_json_write ( _skills_prompt_snapshot_path ( ) , payload )
except Exception as e :
logger . debug ( " Could not write skills prompt snapshot: %s " , e )
def _build_snapshot_entry (
skill_file : Path ,
skills_dir : Path ,
frontmatter : dict ,
description : str ,
) - > dict :
""" Build a serialisable metadata dict for one skill. """
rel_path = skill_file . relative_to ( skills_dir )
parts = rel_path . parts
if len ( parts ) > = 2 :
skill_name = parts [ - 2 ]
category = " / " . join ( parts [ : - 2 ] ) if len ( parts ) > 2 else parts [ 0 ]
else :
category = " general "
skill_name = skill_file . parent . name
platforms = frontmatter . get ( " platforms " ) or [ ]
if isinstance ( platforms , str ) :
platforms = [ platforms ]
return {
" skill_name " : skill_name ,
" category " : category ,
" frontmatter_name " : str ( frontmatter . get ( " name " , skill_name ) ) ,
" description " : description ,
" platforms " : [ str ( p ) . strip ( ) for p in platforms if str ( p ) . strip ( ) ] ,
" conditions " : extract_skill_conditions ( frontmatter ) ,
}
2026-02-21 22:31:43 -08:00
# =========================================================================
# Skills index
# =========================================================================
2026-03-13 03:14:04 -07:00
def _parse_skill_file ( skill_file : Path ) - > tuple [ bool , dict , str ] :
""" Read a SKILL.md once and return platform compatibility, frontmatter, and description.
Returns ( is_compatible , frontmatter , description ) . On any error , returns
( True , { } , " " ) to err on the side of showing the skill .
2026-03-07 00:47:54 -08:00
"""
try :
fix(gateway): replace os.environ session state with contextvars for concurrency safety
When two gateway messages arrived concurrently, _set_session_env wrote
HERMES_SESSION_PLATFORM/CHAT_ID/CHAT_NAME/THREAD_ID into the process-global
os.environ. Because asyncio tasks share the same process, Message B would
overwrite Message A's values mid-flight, causing background-task notifications
and tool calls to route to the wrong thread/chat.
Replace os.environ with Python's contextvars.ContextVar. Each asyncio task
(and any run_in_executor thread it spawns) gets its own copy, so concurrent
messages never interfere.
Changes:
- New gateway/session_context.py with ContextVar definitions, set/clear/get
helpers, and os.environ fallback for CLI/cron/test backward compatibility
- gateway/run.py: _set_session_env returns reset tokens, _clear_session_env
accepts them for proper cleanup in finally blocks
- All tool consumers updated: cronjob_tools, send_message_tool, skills_tool,
terminal_tool (both notify_on_complete AND check_interval blocks), tts_tool,
agent/skill_utils, agent/prompt_builder
- Tests updated for new contextvar-based API
Fixes #7358
Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
2026-04-10 16:50:56 -07:00
raw = skill_file . read_text ( encoding = " utf-8 " )
2026-03-27 10:54:02 -07:00
frontmatter , _ = parse_frontmatter ( raw )
2026-03-13 03:14:04 -07:00
if not skill_matches_platform ( frontmatter ) :
2026-03-27 10:54:02 -07:00
return False , frontmatter , " "
2026-03-13 03:14:04 -07:00
2026-03-27 10:54:02 -07:00
return True , frontmatter , extract_skill_description ( frontmatter )
2026-03-14 02:19:30 -07:00
except Exception as e :
fix(gateway): replace os.environ session state with contextvars for concurrency safety
When two gateway messages arrived concurrently, _set_session_env wrote
HERMES_SESSION_PLATFORM/CHAT_ID/CHAT_NAME/THREAD_ID into the process-global
os.environ. Because asyncio tasks share the same process, Message B would
overwrite Message A's values mid-flight, causing background-task notifications
and tool calls to route to the wrong thread/chat.
Replace os.environ with Python's contextvars.ContextVar. Each asyncio task
(and any run_in_executor thread it spawns) gets its own copy, so concurrent
messages never interfere.
Changes:
- New gateway/session_context.py with ContextVar definitions, set/clear/get
helpers, and os.environ fallback for CLI/cron/test backward compatibility
- gateway/run.py: _set_session_env returns reset tokens, _clear_session_env
accepts them for proper cleanup in finally blocks
- All tool consumers updated: cronjob_tools, send_message_tool, skills_tool,
terminal_tool (both notify_on_complete AND check_interval blocks), tts_tool,
agent/skill_utils, agent/prompt_builder
- Tests updated for new contextvar-based API
Fixes #7358
Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
2026-04-10 16:50:56 -07:00
logger . warning ( " Failed to parse skill file %s : %s " , skill_file , e )
2026-03-13 03:14:04 -07:00
return True , { } , " "
2026-03-07 00:47:54 -08:00
2026-03-09 23:13:39 +03:00
def _skill_should_show (
conditions : dict ,
available_tools : " set[str] | None " ,
available_toolsets : " set[str] | None " ,
) - > bool :
""" Return False if the skill ' s conditional activation rules exclude it. """
if available_tools is None and available_toolsets is None :
return True # No filtering info — show everything (backward compat)
at = available_tools or set ( )
ats = available_toolsets or set ( )
# fallback_for: hide when the primary tool/toolset IS available
for ts in conditions . get ( " fallback_for_toolsets " , [ ] ) :
if ts in ats :
return False
for t in conditions . get ( " fallback_for_tools " , [ ] ) :
if t in at :
return False
# requires: hide when a required tool/toolset is NOT available
for ts in conditions . get ( " requires_toolsets " , [ ] ) :
if ts not in ats :
return False
for t in conditions . get ( " requires_tools " , [ ] ) :
if t not in at :
return False
return True
def build_skills_system_prompt (
available_tools : " set[str] | None " = None ,
available_toolsets : " set[str] | None " = None ,
) - > str :
2026-02-21 22:31:43 -08:00
""" Build a compact skill index for the system prompt.
2026-03-27 10:54:02 -07:00
Two - layer cache :
1. In - process LRU dict keyed by ( skills_dir , tools , toolsets )
2. Disk snapshot ( ` ` . skills_prompt_snapshot . json ` ` ) validated by
mtime / size manifest — survives process restarts
Falls back to a full filesystem scan when both layers miss .
2026-03-29 00:33:30 -07:00
External skill directories ( ` ` skills . external_dirs ` ` in config . yaml ) are
scanned alongside the local ` ` ~ / . hermes / skills / ` ` directory . External dirs
are read - only — they appear in the index but new skills are always created
in the local dir . Local skills take precedence when names collide .
2026-02-21 22:31:43 -08:00
"""
refactor: extract shared helpers to deduplicate repeated code patterns (#7917)
* refactor: add shared helper modules for code deduplication
New modules:
- gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator,
strip_markdown, ThreadParticipationTracker, redact_phone
- hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers
- tools/path_security.py: validate_within_dir, has_traversal_component
- utils.py additions: safe_json_loads, read_json_file, read_jsonl,
append_jsonl, env_str/lower/int/bool helpers
- hermes_constants.py additions: get_config_path, get_skills_dir,
get_logs_dir, get_env_path
* refactor: migrate gateway adapters to shared helpers
- MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost
- strip_markdown: bluebubbles, feishu, sms
- redact_phone: sms, signal
- ThreadParticipationTracker: discord, matrix
- _acquire/_release_platform_lock: telegram, discord, slack, whatsapp,
signal, weixin
Net -316 lines across 19 files.
* refactor: migrate CLI modules to shared helpers
- tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines)
- setup.py: use cli_output print helpers + curses_radiolist (-101 lines)
- mcp_config.py: use cli_output prompt (-15 lines)
- memory_setup.py: use curses_radiolist (-86 lines)
Net -263 lines across 5 files.
* refactor: migrate to shared utility helpers
- safe_json_loads: agent/display.py (4 sites)
- get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py
- get_skills_dir: skill_utils.py, prompt_builder.py
- Token estimation dedup: skills_tool.py imports from model_metadata
- Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files
- Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write
- Platform dict: new platforms.py, skills_config + tools_config derive from it
- Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main
* test: update tests for shared helper migrations
- test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate()
- test_mattermost: use _dedup instead of _seen_posts/_prune_seen
- test_signal: import redact_phone from helpers instead of signal
- test_discord_connect: _platform_lock_identity instead of _token_lock_identity
- test_telegram_conflict: updated lock error message format
- test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
2026-04-11 13:59:52 -07:00
skills_dir = get_skills_dir ( )
2026-03-29 00:33:30 -07:00
external_dirs = get_all_skills_dirs ( ) [ 1 : ] # skip local (index 0)
2026-02-21 22:31:43 -08:00
2026-03-29 00:33:30 -07:00
if not skills_dir . exists ( ) and not external_dirs :
2026-02-21 22:31:43 -08:00
return " "
2026-03-27 10:54:02 -07:00
# ── Layer 1: in-process LRU cache ─────────────────────────────────
2026-04-03 10:10:53 -07:00
# Include the resolved platform so per-platform disabled-skill lists
# produce distinct cache entries (gateway serves multiple platforms).
fix(gateway): replace os.environ session state with contextvars for concurrency safety
When two gateway messages arrived concurrently, _set_session_env wrote
HERMES_SESSION_PLATFORM/CHAT_ID/CHAT_NAME/THREAD_ID into the process-global
os.environ. Because asyncio tasks share the same process, Message B would
overwrite Message A's values mid-flight, causing background-task notifications
and tool calls to route to the wrong thread/chat.
Replace os.environ with Python's contextvars.ContextVar. Each asyncio task
(and any run_in_executor thread it spawns) gets its own copy, so concurrent
messages never interfere.
Changes:
- New gateway/session_context.py with ContextVar definitions, set/clear/get
helpers, and os.environ fallback for CLI/cron/test backward compatibility
- gateway/run.py: _set_session_env returns reset tokens, _clear_session_env
accepts them for proper cleanup in finally blocks
- All tool consumers updated: cronjob_tools, send_message_tool, skills_tool,
terminal_tool (both notify_on_complete AND check_interval blocks), tts_tool,
agent/skill_utils, agent/prompt_builder
- Tests updated for new contextvar-based API
Fixes #7358
Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
2026-04-10 16:50:56 -07:00
from gateway . session_context import get_session_env
2026-04-03 10:10:53 -07:00
_platform_hint = (
os . environ . get ( " HERMES_PLATFORM " )
fix(gateway): replace os.environ session state with contextvars for concurrency safety
When two gateway messages arrived concurrently, _set_session_env wrote
HERMES_SESSION_PLATFORM/CHAT_ID/CHAT_NAME/THREAD_ID into the process-global
os.environ. Because asyncio tasks share the same process, Message B would
overwrite Message A's values mid-flight, causing background-task notifications
and tool calls to route to the wrong thread/chat.
Replace os.environ with Python's contextvars.ContextVar. Each asyncio task
(and any run_in_executor thread it spawns) gets its own copy, so concurrent
messages never interfere.
Changes:
- New gateway/session_context.py with ContextVar definitions, set/clear/get
helpers, and os.environ fallback for CLI/cron/test backward compatibility
- gateway/run.py: _set_session_env returns reset tokens, _clear_session_env
accepts them for proper cleanup in finally blocks
- All tool consumers updated: cronjob_tools, send_message_tool, skills_tool,
terminal_tool (both notify_on_complete AND check_interval blocks), tts_tool,
agent/skill_utils, agent/prompt_builder
- Tests updated for new contextvar-based API
Fixes #7358
Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
2026-04-10 16:50:56 -07:00
or get_session_env ( " HERMES_SESSION_PLATFORM " )
2026-04-03 10:10:53 -07:00
or " "
)
2026-04-11 03:54:51 +03:00
disabled = get_disabled_skill_names ( )
2026-03-27 10:54:02 -07:00
cache_key = (
str ( skills_dir . resolve ( ) ) ,
2026-03-29 00:33:30 -07:00
tuple ( str ( d ) for d in external_dirs ) ,
2026-03-27 10:54:02 -07:00
tuple ( sorted ( str ( t ) for t in ( available_tools or set ( ) ) ) ) ,
tuple ( sorted ( str ( ts ) for ts in ( available_toolsets or set ( ) ) ) ) ,
2026-04-03 10:10:53 -07:00
_platform_hint ,
2026-04-11 03:54:51 +03:00
tuple ( sorted ( disabled ) ) ,
2026-03-27 10:54:02 -07:00
)
with _SKILLS_PROMPT_CACHE_LOCK :
cached = _SKILLS_PROMPT_CACHE . get ( cache_key )
if cached is not None :
_SKILLS_PROMPT_CACHE . move_to_end ( cache_key )
return cached
# ── Layer 2: disk snapshot ────────────────────────────────────────
snapshot = _load_skills_snapshot ( skills_dir )
2026-03-18 03:17:37 -07:00
2026-02-22 13:28:13 -08:00
skills_by_category : dict [ str , list [ tuple [ str , str ] ] ] = { }
2026-03-27 10:54:02 -07:00
category_descriptions : dict [ str , str ] = { }
2026-02-21 22:31:43 -08:00
2026-03-27 10:54:02 -07:00
if snapshot is not None :
# Fast path: use pre-parsed metadata from disk
for entry in snapshot . get ( " skills " , [ ] ) :
if not isinstance ( entry , dict ) :
continue
skill_name = entry . get ( " skill_name " ) or " "
category = entry . get ( " category " ) or " general "
frontmatter_name = entry . get ( " frontmatter_name " ) or skill_name
platforms = entry . get ( " platforms " ) or [ ]
if not skill_matches_platform ( { " platforms " : platforms } ) :
continue
if frontmatter_name in disabled or skill_name in disabled :
continue
if not _skill_should_show (
entry . get ( " conditions " ) or { } ,
available_tools ,
available_toolsets ,
) :
continue
skills_by_category . setdefault ( category , [ ] ) . append (
2026-04-17 23:25:11 +00:00
( frontmatter_name , entry . get ( " description " , " " ) )
2026-03-27 10:54:02 -07:00
)
category_descriptions = {
str ( k ) : str ( v )
for k , v in ( snapshot . get ( " category_descriptions " ) or { } ) . items ( )
}
else :
# Cold path: full filesystem scan + write snapshot for next time
skill_entries : list [ dict ] = [ ]
for skill_file in iter_skill_index_files ( skills_dir , " SKILL.md " ) :
is_compatible , frontmatter , desc = _parse_skill_file ( skill_file )
entry = _build_snapshot_entry ( skill_file , skills_dir , frontmatter , desc )
skill_entries . append ( entry )
if not is_compatible :
continue
skill_name = entry [ " skill_name " ]
if entry [ " frontmatter_name " ] in disabled or skill_name in disabled :
continue
if not _skill_should_show (
extract_skill_conditions ( frontmatter ) ,
available_tools ,
available_toolsets ,
) :
continue
skills_by_category . setdefault ( entry [ " category " ] , [ ] ) . append (
2026-04-17 23:25:11 +00:00
( entry [ " frontmatter_name " ] , entry [ " description " ] )
2026-03-27 10:54:02 -07:00
)
2026-02-21 22:31:43 -08:00
2026-03-27 10:54:02 -07:00
# Read category-level DESCRIPTION.md files
for desc_file in iter_skill_index_files ( skills_dir , " DESCRIPTION.md " ) :
2026-02-21 22:31:43 -08:00
try :
content = desc_file . read_text ( encoding = " utf-8 " )
2026-03-27 10:54:02 -07:00
fm , _ = parse_frontmatter ( content )
cat_desc = fm . get ( " description " )
if not cat_desc :
continue
rel = desc_file . relative_to ( skills_dir )
cat = " / " . join ( rel . parts [ : - 1 ] ) if len ( rel . parts ) > 1 else " general "
category_descriptions [ cat ] = str ( cat_desc ) . strip ( ) . strip ( " ' \" " )
2026-02-21 22:31:43 -08:00
except Exception as e :
logger . debug ( " Could not read skill description %s : %s " , desc_file , e )
2026-03-27 10:54:02 -07:00
_write_skills_snapshot (
skills_dir ,
_build_skills_manifest ( skills_dir ) ,
skill_entries ,
category_descriptions ,
)
2026-03-29 00:33:30 -07:00
# ── External skill directories ─────────────────────────────────────
# Scan external dirs directly (no snapshot caching — they're read-only
# and typically small). Local skills already in skills_by_category take
# precedence: we track seen names and skip duplicates from external dirs.
seen_skill_names : set [ str ] = set ( )
for cat_skills in skills_by_category . values ( ) :
for name , _desc in cat_skills :
seen_skill_names . add ( name )
for ext_dir in external_dirs :
if not ext_dir . exists ( ) :
continue
for skill_file in iter_skill_index_files ( ext_dir , " SKILL.md " ) :
try :
is_compatible , frontmatter , desc = _parse_skill_file ( skill_file )
if not is_compatible :
continue
entry = _build_snapshot_entry ( skill_file , ext_dir , frontmatter , desc )
skill_name = entry [ " skill_name " ]
2026-04-17 23:25:11 +00:00
frontmatter_name = entry [ " frontmatter_name " ]
if frontmatter_name in seen_skill_names :
2026-03-29 00:33:30 -07:00
continue
2026-04-17 23:25:11 +00:00
if frontmatter_name in disabled or skill_name in disabled :
2026-03-29 00:33:30 -07:00
continue
if not _skill_should_show (
extract_skill_conditions ( frontmatter ) ,
available_tools ,
available_toolsets ,
) :
continue
2026-04-17 23:25:11 +00:00
seen_skill_names . add ( frontmatter_name )
2026-03-29 00:33:30 -07:00
skills_by_category . setdefault ( entry [ " category " ] , [ ] ) . append (
2026-04-17 23:25:11 +00:00
( frontmatter_name , entry [ " description " ] )
2026-03-29 00:33:30 -07:00
)
except Exception as e :
logger . debug ( " Error reading external skill %s : %s " , skill_file , e )
# External category descriptions
for desc_file in iter_skill_index_files ( ext_dir , " DESCRIPTION.md " ) :
try :
content = desc_file . read_text ( encoding = " utf-8 " )
fm , _ = parse_frontmatter ( content )
cat_desc = fm . get ( " description " )
if not cat_desc :
continue
rel = desc_file . relative_to ( ext_dir )
cat = " / " . join ( rel . parts [ : - 1 ] ) if len ( rel . parts ) > 1 else " general "
category_descriptions . setdefault ( cat , str ( cat_desc ) . strip ( ) . strip ( " ' \" " ) )
except Exception as e :
logger . debug ( " Could not read external skill description %s : %s " , desc_file , e )
2026-03-27 10:54:02 -07:00
if not skills_by_category :
result = " "
else :
index_lines = [ ]
for category in sorted ( skills_by_category . keys ( ) ) :
cat_desc = category_descriptions . get ( category , " " )
if cat_desc :
index_lines . append ( f " { category } : { cat_desc } " )
2026-02-22 13:28:13 -08:00
else :
2026-03-27 10:54:02 -07:00
index_lines . append ( f " { category } : " )
# Deduplicate and sort skills within each category
seen = set ( )
for name , desc in sorted ( skills_by_category [ category ] , key = lambda x : x [ 0 ] ) :
if name in seen :
continue
seen . add ( name )
if desc :
index_lines . append ( f " - { name } : { desc } " )
else :
index_lines . append ( f " - { name } " )
result = (
" ## Skills (mandatory) \n "
2026-04-12 01:46:34 -07:00
" Before replying, scan the skills below. If a skill matches or is even partially relevant "
" to your task, you MUST load it with skill_view(name) and follow its instructions. "
" Err on the side of loading — it is always better to have context you don ' t need "
" than to miss critical steps, pitfalls, or established workflows. "
" Skills contain specialized knowledge — API endpoints, tool-specific commands, "
" and proven workflows that outperform general-purpose approaches. Load the skill "
2026-04-12 03:03:16 -07:00
" even if you think you could handle the task with basic tools like web_search or terminal. "
" Skills also encode the user ' s preferred approach, conventions, and quality standards "
" for tasks like code review, planning, and testing — load them even for tasks you "
" already know how to do, because the skill defines how it should be done here. \n "
2026-03-27 10:54:02 -07:00
" If a skill has issues, fix it with skill_manage(action= ' patch ' ). \n "
" After difficult/iterative tasks, offer to save as a skill. "
" If a skill you loaded was missing steps, had wrong commands, or needed "
" pitfalls you discovered, update it before finishing. \n "
" \n "
" <available_skills> \n "
+ " \n " . join ( index_lines ) + " \n "
" </available_skills> \n "
" \n "
2026-04-12 01:46:34 -07:00
" Only proceed without loading a skill if genuinely none are relevant to the task. "
2026-03-27 10:54:02 -07:00
)
# ── Store in LRU cache ────────────────────────────────────────────
with _SKILLS_PROMPT_CACHE_LOCK :
_SKILLS_PROMPT_CACHE [ cache_key ] = result
_SKILLS_PROMPT_CACHE . move_to_end ( cache_key )
while len ( _SKILLS_PROMPT_CACHE ) > _SKILLS_PROMPT_CACHE_MAX :
_SKILLS_PROMPT_CACHE . popitem ( last = False )
return result
2026-02-21 22:31:43 -08:00
2026-03-26 15:27:27 -07:00
def build_nous_subscription_prompt ( valid_tool_names : " set[str] | None " = None ) - > str :
""" Build a compact Nous subscription capability block for the system prompt. """
try :
from hermes_cli . nous_subscription import get_nous_subscription_features
2026-03-30 13:28:10 +09:00
from tools . tool_backend_helpers import managed_nous_tools_enabled
2026-03-26 15:27:27 -07:00
except Exception as exc :
logger . debug ( " Failed to import Nous subscription helper: %s " , exc )
return " "
2026-03-30 13:28:10 +09:00
if not managed_nous_tools_enabled ( ) :
return " "
2026-03-26 15:27:27 -07:00
valid_names = set ( valid_tool_names or set ( ) )
relevant_tool_names = {
" web_search " ,
" web_extract " ,
" browser_navigate " ,
" browser_snapshot " ,
" browser_click " ,
" browser_type " ,
" browser_scroll " ,
" browser_console " ,
" browser_press " ,
" browser_get_images " ,
" browser_vision " ,
" image_generate " ,
" text_to_speech " ,
" terminal " ,
" process " ,
" execute_code " ,
}
if valid_names and not ( valid_names & relevant_tool_names ) :
return " "
features = get_nous_subscription_features ( )
def _status_line ( feature ) - > str :
if feature . managed_by_nous :
return f " - { feature . label } : active via Nous subscription "
if feature . active :
current = feature . current_provider or " configured provider "
return f " - { feature . label } : currently using { current } "
if feature . included_by_default and features . nous_auth_present :
return f " - { feature . label } : included with Nous subscription, not currently selected "
if feature . key == " modal " and features . nous_auth_present :
return f " - { feature . label } : optional via Nous subscription "
return f " - { feature . label } : not currently available "
lines = [
" # Nous Subscription " ,
feat: switch managed browser provider from Browserbase to Browser Use (#5750)
* feat: switch managed browser provider from Browserbase to Browser Use
The Nous subscription tool gateway now routes browser automation through
Browser Use instead of Browserbase. This commit:
- Adds managed Nous gateway support to BrowserUseProvider (idempotency
keys, X-BB-API-Key auth header, external_call_id persistence)
- Removes managed gateway support from BrowserbaseProvider (now
direct-only via BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID)
- Updates browser_tool.py fallback: prefers Browser Use over Browserbase
- Updates nous_subscription.py: gateway vendor 'browser-use', auto-config
sets cloud_provider='browser-use' for new subscribers
- Updates tools_config.py: Nous Subscription entry now uses Browser Use
- Updates setup.py, cli.py, status.py, prompt_builder.py display strings
- Updates all affected tests to match new behavior
Browserbase remains fully functional for users with direct API credentials.
The change only affects the managed/subscription path.
* chore: remove redundant Browser Use hint from system prompt
* fix: upgrade Browser Use provider to v3 API
- Base URL: api/v2 -> api/v3 (v2 is legacy)
- Unified all endpoints to use native Browser Use paths:
- POST /browsers (create session, returns cdpUrl)
- PATCH /browsers/{id} with {action: stop} (close session)
- Removed managed-mode branching that used Browserbase-style
/v1/sessions paths — v3 gateway now supports /browsers directly
- Removed unused managed_mode variable in close_session
* fix(browser-use): use X-Browser-Use-API-Key header for managed mode
The managed gateway expects X-Browser-Use-API-Key, not X-BB-API-Key
(which is a Browserbase-specific header). Using the wrong header caused
a 401 AUTH_ERROR on every managed-mode browser session create.
Simplified _headers() to always use X-Browser-Use-API-Key regardless
of direct vs managed mode.
* fix(nous_subscription): browserbase explicit provider is direct-only
Since managed Nous gateway now routes through Browser Use, the
browserbase explicit provider path should not check managed_browser_available
(which resolves against the browser-use gateway). Simplified to direct-only
with managed=False.
* fix(browser-use): port missing improvements from PR #5605
- CDP URL normalization: resolve HTTP discovery URLs to websocket after
cloud provider create_session() (prevents agent-browser failures)
- Managed session payload: send timeout=5 and proxyCountryCode=us for
gateway-backed sessions (prevents billing overruns)
- Update prompt builder, browser_close schema, and module docstring to
replace remaining Browserbase references with Browser Use
- Dynamic /browser status detection via _get_cloud_provider() instead
of hardcoded env var checks (future-proof for new providers)
- Rename post_setup key from 'browserbase' to 'agent_browser'
- Update setup hint to mention Browser Use alongside Browserbase
- Add tests: CDP normalization, browserbase direct-only guard,
managed browser-use gateway, direct browserbase fallback
---------
Co-authored-by: rob-maron <132852777+rob-maron@users.noreply.github.com>
2026-04-07 22:40:22 +10:00
" Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, and browser automation (Browser Use) by default. Modal execution is optional. " ,
2026-03-26 15:27:27 -07:00
" Current capability status: " ,
]
lines . extend ( _status_line ( feature ) for feature in features . items ( ) )
lines . extend (
[
feat: switch managed browser provider from Browserbase to Browser Use (#5750)
* feat: switch managed browser provider from Browserbase to Browser Use
The Nous subscription tool gateway now routes browser automation through
Browser Use instead of Browserbase. This commit:
- Adds managed Nous gateway support to BrowserUseProvider (idempotency
keys, X-BB-API-Key auth header, external_call_id persistence)
- Removes managed gateway support from BrowserbaseProvider (now
direct-only via BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID)
- Updates browser_tool.py fallback: prefers Browser Use over Browserbase
- Updates nous_subscription.py: gateway vendor 'browser-use', auto-config
sets cloud_provider='browser-use' for new subscribers
- Updates tools_config.py: Nous Subscription entry now uses Browser Use
- Updates setup.py, cli.py, status.py, prompt_builder.py display strings
- Updates all affected tests to match new behavior
Browserbase remains fully functional for users with direct API credentials.
The change only affects the managed/subscription path.
* chore: remove redundant Browser Use hint from system prompt
* fix: upgrade Browser Use provider to v3 API
- Base URL: api/v2 -> api/v3 (v2 is legacy)
- Unified all endpoints to use native Browser Use paths:
- POST /browsers (create session, returns cdpUrl)
- PATCH /browsers/{id} with {action: stop} (close session)
- Removed managed-mode branching that used Browserbase-style
/v1/sessions paths — v3 gateway now supports /browsers directly
- Removed unused managed_mode variable in close_session
* fix(browser-use): use X-Browser-Use-API-Key header for managed mode
The managed gateway expects X-Browser-Use-API-Key, not X-BB-API-Key
(which is a Browserbase-specific header). Using the wrong header caused
a 401 AUTH_ERROR on every managed-mode browser session create.
Simplified _headers() to always use X-Browser-Use-API-Key regardless
of direct vs managed mode.
* fix(nous_subscription): browserbase explicit provider is direct-only
Since managed Nous gateway now routes through Browser Use, the
browserbase explicit provider path should not check managed_browser_available
(which resolves against the browser-use gateway). Simplified to direct-only
with managed=False.
* fix(browser-use): port missing improvements from PR #5605
- CDP URL normalization: resolve HTTP discovery URLs to websocket after
cloud provider create_session() (prevents agent-browser failures)
- Managed session payload: send timeout=5 and proxyCountryCode=us for
gateway-backed sessions (prevents billing overruns)
- Update prompt builder, browser_close schema, and module docstring to
replace remaining Browserbase references with Browser Use
- Dynamic /browser status detection via _get_cloud_provider() instead
of hardcoded env var checks (future-proof for new providers)
- Rename post_setup key from 'browserbase' to 'agent_browser'
- Update setup hint to mention Browser Use alongside Browserbase
- Add tests: CDP normalization, browserbase direct-only guard,
managed browser-use gateway, direct browserbase fallback
---------
Co-authored-by: rob-maron <132852777+rob-maron@users.noreply.github.com>
2026-04-07 22:40:22 +10:00
" When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browser-Use API keys. " ,
2026-03-26 15:27:27 -07:00
" If the user is not subscribed and asks for a capability that Nous subscription would unlock or simplify, suggest Nous subscription as one option alongside direct setup or local alternatives. " ,
" Do not mention subscription unless the user asks about it or it directly solves the current missing capability. " ,
" Useful commands: hermes setup, hermes setup tools, hermes setup terminal, hermes status. " ,
]
)
return " \n " . join ( lines )
2026-02-21 22:31:43 -08:00
# =========================================================================
# Context files (SOUL.md, AGENTS.md, .cursorrules)
# =========================================================================
def _truncate_content ( content : str , filename : str , max_chars : int = CONTEXT_FILE_MAX_CHARS ) - > str :
""" Head/tail truncation with a marker in the middle. """
if len ( content ) < = max_chars :
return content
head_chars = int ( max_chars * CONTEXT_TRUNCATE_HEAD_RATIO )
tail_chars = int ( max_chars * CONTEXT_TRUNCATE_TAIL_RATIO )
head = content [ : head_chars ]
tail = content [ - tail_chars : ]
marker = f " \n \n [...truncated { filename } : kept { head_chars } + { tail_chars } of { len ( content ) } chars. Use file tools to read the full file.] \n \n "
return head + marker + tail
2026-03-18 04:11:20 -07:00
def load_soul_md ( ) - > Optional [ str ] :
""" Load SOUL.md from HERMES_HOME and return its content, or None.
Used as the agent identity ( slot #1 in the system prompt). When this
returns content , ` ` build_context_files_prompt ` ` should be called with
` ` skip_soul = True ` ` so SOUL . md isn ' t injected twice.
"""
try :
from hermes_cli . config import ensure_hermes_home
ensure_hermes_home ( )
except Exception as e :
logger . debug ( " Could not ensure HERMES_HOME before loading SOUL.md: %s " , e )
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
soul_path = get_hermes_home ( ) / " SOUL.md "
2026-03-18 04:11:20 -07:00
if not soul_path . exists ( ) :
return None
try :
content = soul_path . read_text ( encoding = " utf-8 " ) . strip ( )
if not content :
return None
content = _scan_context_content ( content , " SOUL.md " )
content = _truncate_content ( content , " SOUL.md " )
return content
except Exception as e :
logger . debug ( " Could not read SOUL.md from %s : %s " , soul_path , e )
return None
feat: priority-based context file selection + CLAUDE.md support (#2301)
Previously, all project context files (AGENTS.md, .cursorrules, .hermes.md)
were loaded and concatenated into the system prompt. This bloated the prompt
with potentially redundant or conflicting instructions.
Now only ONE project context type is loaded, using priority order:
1. .hermes.md / HERMES.md (walk to git root)
2. AGENTS.md / agents.md (recursive directory walk)
3. CLAUDE.md / claude.md (cwd only, NEW)
4. .cursorrules / .cursor/rules/*.mdc (cwd only)
SOUL.md from HERMES_HOME remains independent and always loads.
Also adds CLAUDE.md as a recognized context file format, matching the
convention popularized by Claude Code.
Refactored the monolithic function into four focused helpers:
_load_hermes_md, _load_agents_md, _load_claude_md, _load_cursorrules.
Tests: replaced 1 coexistence test with 10 new tests covering priority
ordering, CLAUDE.md loading, case sensitivity, injection blocking.
2026-03-21 06:26:20 -07:00
def _load_hermes_md ( cwd_path : Path ) - > str :
""" .hermes.md / HERMES.md — walk to git root. """
hermes_md_path = _find_hermes_md ( cwd_path )
if not hermes_md_path :
return " "
try :
content = hermes_md_path . read_text ( encoding = " utf-8 " ) . strip ( )
if not content :
return " "
content = _strip_yaml_frontmatter ( content )
rel = hermes_md_path . name
try :
rel = str ( hermes_md_path . relative_to ( cwd_path ) )
except ValueError :
pass
content = _scan_context_content ( content , rel )
result = f " ## { rel } \n \n { content } "
return _truncate_content ( result , " .hermes.md " )
except Exception as e :
logger . debug ( " Could not read %s : %s " , hermes_md_path , e )
return " "
2026-02-21 22:31:43 -08:00
feat: priority-based context file selection + CLAUDE.md support (#2301)
Previously, all project context files (AGENTS.md, .cursorrules, .hermes.md)
were loaded and concatenated into the system prompt. This bloated the prompt
with potentially redundant or conflicting instructions.
Now only ONE project context type is loaded, using priority order:
1. .hermes.md / HERMES.md (walk to git root)
2. AGENTS.md / agents.md (recursive directory walk)
3. CLAUDE.md / claude.md (cwd only, NEW)
4. .cursorrules / .cursor/rules/*.mdc (cwd only)
SOUL.md from HERMES_HOME remains independent and always loads.
Also adds CLAUDE.md as a recognized context file format, matching the
convention popularized by Claude Code.
Refactored the monolithic function into four focused helpers:
_load_hermes_md, _load_agents_md, _load_claude_md, _load_cursorrules.
Tests: replaced 1 coexistence test with 10 new tests covering priority
ordering, CLAUDE.md loading, case sensitivity, injection blocking.
2026-03-21 06:26:20 -07:00
def _load_agents_md ( cwd_path : Path ) - > str :
2026-03-25 18:30:45 -07:00
""" AGENTS.md — top-level only (no recursive walk). """
2026-02-21 22:31:43 -08:00
for name in [ " AGENTS.md " , " agents.md " ] :
candidate = cwd_path / name
if candidate . exists ( ) :
2026-03-25 18:30:45 -07:00
try :
content = candidate . read_text ( encoding = " utf-8 " ) . strip ( )
if content :
content = _scan_context_content ( content , name )
result = f " ## { name } \n \n { content } "
return _truncate_content ( result , " AGENTS.md " )
except Exception as e :
logger . debug ( " Could not read %s : %s " , candidate , e )
return " "
feat: priority-based context file selection + CLAUDE.md support (#2301)
Previously, all project context files (AGENTS.md, .cursorrules, .hermes.md)
were loaded and concatenated into the system prompt. This bloated the prompt
with potentially redundant or conflicting instructions.
Now only ONE project context type is loaded, using priority order:
1. .hermes.md / HERMES.md (walk to git root)
2. AGENTS.md / agents.md (recursive directory walk)
3. CLAUDE.md / claude.md (cwd only, NEW)
4. .cursorrules / .cursor/rules/*.mdc (cwd only)
SOUL.md from HERMES_HOME remains independent and always loads.
Also adds CLAUDE.md as a recognized context file format, matching the
convention popularized by Claude Code.
Refactored the monolithic function into four focused helpers:
_load_hermes_md, _load_agents_md, _load_claude_md, _load_cursorrules.
Tests: replaced 1 coexistence test with 10 new tests covering priority
ordering, CLAUDE.md loading, case sensitivity, injection blocking.
2026-03-21 06:26:20 -07:00
def _load_claude_md ( cwd_path : Path ) - > str :
""" CLAUDE.md / claude.md — cwd only. """
for name in [ " CLAUDE.md " , " claude.md " ] :
candidate = cwd_path / name
if candidate . exists ( ) :
2026-02-21 22:31:43 -08:00
try :
feat: priority-based context file selection + CLAUDE.md support (#2301)
Previously, all project context files (AGENTS.md, .cursorrules, .hermes.md)
were loaded and concatenated into the system prompt. This bloated the prompt
with potentially redundant or conflicting instructions.
Now only ONE project context type is loaded, using priority order:
1. .hermes.md / HERMES.md (walk to git root)
2. AGENTS.md / agents.md (recursive directory walk)
3. CLAUDE.md / claude.md (cwd only, NEW)
4. .cursorrules / .cursor/rules/*.mdc (cwd only)
SOUL.md from HERMES_HOME remains independent and always loads.
Also adds CLAUDE.md as a recognized context file format, matching the
convention popularized by Claude Code.
Refactored the monolithic function into four focused helpers:
_load_hermes_md, _load_agents_md, _load_claude_md, _load_cursorrules.
Tests: replaced 1 coexistence test with 10 new tests covering priority
ordering, CLAUDE.md loading, case sensitivity, injection blocking.
2026-03-21 06:26:20 -07:00
content = candidate . read_text ( encoding = " utf-8 " ) . strip ( )
2026-02-21 22:31:43 -08:00
if content :
feat: priority-based context file selection + CLAUDE.md support (#2301)
Previously, all project context files (AGENTS.md, .cursorrules, .hermes.md)
were loaded and concatenated into the system prompt. This bloated the prompt
with potentially redundant or conflicting instructions.
Now only ONE project context type is loaded, using priority order:
1. .hermes.md / HERMES.md (walk to git root)
2. AGENTS.md / agents.md (recursive directory walk)
3. CLAUDE.md / claude.md (cwd only, NEW)
4. .cursorrules / .cursor/rules/*.mdc (cwd only)
SOUL.md from HERMES_HOME remains independent and always loads.
Also adds CLAUDE.md as a recognized context file format, matching the
convention popularized by Claude Code.
Refactored the monolithic function into four focused helpers:
_load_hermes_md, _load_agents_md, _load_claude_md, _load_cursorrules.
Tests: replaced 1 coexistence test with 10 new tests covering priority
ordering, CLAUDE.md loading, case sensitivity, injection blocking.
2026-03-21 06:26:20 -07:00
content = _scan_context_content ( content , name )
result = f " ## { name } \n \n { content } "
return _truncate_content ( result , " CLAUDE.md " )
2026-02-21 22:31:43 -08:00
except Exception as e :
feat: priority-based context file selection + CLAUDE.md support (#2301)
Previously, all project context files (AGENTS.md, .cursorrules, .hermes.md)
were loaded and concatenated into the system prompt. This bloated the prompt
with potentially redundant or conflicting instructions.
Now only ONE project context type is loaded, using priority order:
1. .hermes.md / HERMES.md (walk to git root)
2. AGENTS.md / agents.md (recursive directory walk)
3. CLAUDE.md / claude.md (cwd only, NEW)
4. .cursorrules / .cursor/rules/*.mdc (cwd only)
SOUL.md from HERMES_HOME remains independent and always loads.
Also adds CLAUDE.md as a recognized context file format, matching the
convention popularized by Claude Code.
Refactored the monolithic function into four focused helpers:
_load_hermes_md, _load_agents_md, _load_claude_md, _load_cursorrules.
Tests: replaced 1 coexistence test with 10 new tests covering priority
ordering, CLAUDE.md loading, case sensitivity, injection blocking.
2026-03-21 06:26:20 -07:00
logger . debug ( " Could not read %s : %s " , candidate , e )
return " "
2026-02-21 22:31:43 -08:00
feat: priority-based context file selection + CLAUDE.md support (#2301)
Previously, all project context files (AGENTS.md, .cursorrules, .hermes.md)
were loaded and concatenated into the system prompt. This bloated the prompt
with potentially redundant or conflicting instructions.
Now only ONE project context type is loaded, using priority order:
1. .hermes.md / HERMES.md (walk to git root)
2. AGENTS.md / agents.md (recursive directory walk)
3. CLAUDE.md / claude.md (cwd only, NEW)
4. .cursorrules / .cursor/rules/*.mdc (cwd only)
SOUL.md from HERMES_HOME remains independent and always loads.
Also adds CLAUDE.md as a recognized context file format, matching the
convention popularized by Claude Code.
Refactored the monolithic function into four focused helpers:
_load_hermes_md, _load_agents_md, _load_claude_md, _load_cursorrules.
Tests: replaced 1 coexistence test with 10 new tests covering priority
ordering, CLAUDE.md loading, case sensitivity, injection blocking.
2026-03-21 06:26:20 -07:00
def _load_cursorrules ( cwd_path : Path ) - > str :
""" .cursorrules + .cursor/rules/*.mdc — cwd only. """
2026-02-21 22:31:43 -08:00
cursorrules_content = " "
cursorrules_file = cwd_path / " .cursorrules "
if cursorrules_file . exists ( ) :
try :
content = cursorrules_file . read_text ( encoding = " utf-8 " ) . strip ( )
if content :
Harden agent attack surface: scan writes to memory, skills, cron, and context files
The security scanner (skills_guard.py) was only wired into the hub install path.
All other write paths to persistent state — skills created by the agent, memory
entries, cron prompts, and context files — bypassed it entirely. This closes
those gaps:
- file_operations: deny-list blocks writes to ~/.ssh, ~/.aws, ~/.hermes/.env, etc.
- code_execution_tool: filter secret env vars from sandbox child process
- skill_manager_tool: wire scan_skill() into create/edit/patch/write_file with rollback
- skills_guard: add "agent-created" trust level (same policy as community)
- memory_tool: scan content for injection/exfil before system prompt injection
- prompt_builder: scan AGENTS.md, .cursorrules, SOUL.md for prompt injection
- cronjob_tools: scan cron prompts for critical threats before scheduling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:43:15 -05:00
content = _scan_context_content ( content , " .cursorrules " )
2026-02-21 22:31:43 -08:00
cursorrules_content + = f " ## .cursorrules \n \n { content } \n \n "
except Exception as e :
logger . debug ( " Could not read .cursorrules: %s " , e )
cursor_rules_dir = cwd_path / " .cursor " / " rules "
if cursor_rules_dir . exists ( ) and cursor_rules_dir . is_dir ( ) :
mdc_files = sorted ( cursor_rules_dir . glob ( " *.mdc " ) )
for mdc_file in mdc_files :
try :
content = mdc_file . read_text ( encoding = " utf-8 " ) . strip ( )
if content :
Harden agent attack surface: scan writes to memory, skills, cron, and context files
The security scanner (skills_guard.py) was only wired into the hub install path.
All other write paths to persistent state — skills created by the agent, memory
entries, cron prompts, and context files — bypassed it entirely. This closes
those gaps:
- file_operations: deny-list blocks writes to ~/.ssh, ~/.aws, ~/.hermes/.env, etc.
- code_execution_tool: filter secret env vars from sandbox child process
- skill_manager_tool: wire scan_skill() into create/edit/patch/write_file with rollback
- skills_guard: add "agent-created" trust level (same policy as community)
- memory_tool: scan content for injection/exfil before system prompt injection
- prompt_builder: scan AGENTS.md, .cursorrules, SOUL.md for prompt injection
- cronjob_tools: scan cron prompts for critical threats before scheduling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:43:15 -05:00
content = _scan_context_content ( content , f " .cursor/rules/ { mdc_file . name } " )
2026-02-21 22:31:43 -08:00
cursorrules_content + = f " ## .cursor/rules/ { mdc_file . name } \n \n { content } \n \n "
except Exception as e :
logger . debug ( " Could not read %s : %s " , mdc_file , e )
feat: priority-based context file selection + CLAUDE.md support (#2301)
Previously, all project context files (AGENTS.md, .cursorrules, .hermes.md)
were loaded and concatenated into the system prompt. This bloated the prompt
with potentially redundant or conflicting instructions.
Now only ONE project context type is loaded, using priority order:
1. .hermes.md / HERMES.md (walk to git root)
2. AGENTS.md / agents.md (recursive directory walk)
3. CLAUDE.md / claude.md (cwd only, NEW)
4. .cursorrules / .cursor/rules/*.mdc (cwd only)
SOUL.md from HERMES_HOME remains independent and always loads.
Also adds CLAUDE.md as a recognized context file format, matching the
convention popularized by Claude Code.
Refactored the monolithic function into four focused helpers:
_load_hermes_md, _load_agents_md, _load_claude_md, _load_cursorrules.
Tests: replaced 1 coexistence test with 10 new tests covering priority
ordering, CLAUDE.md loading, case sensitivity, injection blocking.
2026-03-21 06:26:20 -07:00
if not cursorrules_content :
return " "
return _truncate_content ( cursorrules_content , " .cursorrules " )
2026-02-21 22:31:43 -08:00
2026-03-17 04:16:32 -07:00
feat: priority-based context file selection + CLAUDE.md support (#2301)
Previously, all project context files (AGENTS.md, .cursorrules, .hermes.md)
were loaded and concatenated into the system prompt. This bloated the prompt
with potentially redundant or conflicting instructions.
Now only ONE project context type is loaded, using priority order:
1. .hermes.md / HERMES.md (walk to git root)
2. AGENTS.md / agents.md (recursive directory walk)
3. CLAUDE.md / claude.md (cwd only, NEW)
4. .cursorrules / .cursor/rules/*.mdc (cwd only)
SOUL.md from HERMES_HOME remains independent and always loads.
Also adds CLAUDE.md as a recognized context file format, matching the
convention popularized by Claude Code.
Refactored the monolithic function into four focused helpers:
_load_hermes_md, _load_agents_md, _load_claude_md, _load_cursorrules.
Tests: replaced 1 coexistence test with 10 new tests covering priority
ordering, CLAUDE.md loading, case sensitivity, injection blocking.
2026-03-21 06:26:20 -07:00
def build_context_files_prompt ( cwd : Optional [ str ] = None , skip_soul : bool = False ) - > str :
""" Discover and load context files for the system prompt.
Priority ( first found wins — only ONE project context type is loaded ) :
1. . hermes . md / HERMES . md ( walk to git root )
2026-03-25 18:30:45 -07:00
2. AGENTS . md / agents . md ( cwd only )
feat: priority-based context file selection + CLAUDE.md support (#2301)
Previously, all project context files (AGENTS.md, .cursorrules, .hermes.md)
were loaded and concatenated into the system prompt. This bloated the prompt
with potentially redundant or conflicting instructions.
Now only ONE project context type is loaded, using priority order:
1. .hermes.md / HERMES.md (walk to git root)
2. AGENTS.md / agents.md (recursive directory walk)
3. CLAUDE.md / claude.md (cwd only, NEW)
4. .cursorrules / .cursor/rules/*.mdc (cwd only)
SOUL.md from HERMES_HOME remains independent and always loads.
Also adds CLAUDE.md as a recognized context file format, matching the
convention popularized by Claude Code.
Refactored the monolithic function into four focused helpers:
_load_hermes_md, _load_agents_md, _load_claude_md, _load_cursorrules.
Tests: replaced 1 coexistence test with 10 new tests covering priority
ordering, CLAUDE.md loading, case sensitivity, injection blocking.
2026-03-21 06:26:20 -07:00
3. CLAUDE . md / claude . md ( cwd only )
4. . cursorrules / . cursor / rules / * . mdc ( cwd only )
SOUL . md from HERMES_HOME is independent and always included when present .
Each context source is capped at 20 , 000 chars .
When * skip_soul * is True , SOUL . md is not included here ( it was already
loaded via ` ` load_soul_md ( ) ` ` for the identity slot ) .
"""
if cwd is None :
cwd = os . getcwd ( )
cwd_path = Path ( cwd ) . resolve ( )
sections = [ ]
# Priority-based project context: first match wins
project_context = (
_load_hermes_md ( cwd_path )
or _load_agents_md ( cwd_path )
or _load_claude_md ( cwd_path )
or _load_cursorrules ( cwd_path )
)
if project_context :
sections . append ( project_context )
2026-03-17 04:16:32 -07:00
2026-03-18 04:11:20 -07:00
# SOUL.md from HERMES_HOME only — skip when already loaded as identity
if not skip_soul :
soul_content = load_soul_md ( )
if soul_content :
sections . append ( soul_content )
2026-02-21 22:31:43 -08:00
if not sections :
return " "
return " # Project Context \n \n The following project context files have been loaded and should be followed: \n \n " + " \n " . join ( sections )