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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions agent/context_prompt_compactor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import re


_URL_RE = re.compile(r"https?://\S+")
_INLINE_CODE_RE = re.compile(r"`[^`]+`")
_PATH_RE = re.compile(r"(?:~?/|\.?\./|/)[^\s`]+")
_BULLET_RE = re.compile(r"^\s*[-*]\s+", re.M)
_NUMBERED_RE = re.compile(r"^\s*\d+\.\s+", re.M)
_FILLER_PATTERNS = [
(re.compile(r"\bplease\b", re.I), ""),
(re.compile(r"\bcarefully\b", re.I), ""),
(re.compile(r"\bsimply\b", re.I), ""),
(re.compile(r"\bbasically\b", re.I), ""),
(re.compile(r"\bjust\b", re.I), ""),
(re.compile(r"\bhelpfully\b", re.I), ""),
(re.compile(r"\bin order to\b", re.I), "to"),
(re.compile(r"\bdo not forget to\b", re.I), "remember to"),
(re.compile(r"\bmake sure to\b", re.I), "ensure"),
(re.compile(r"\bit is important to note that\b", re.I), "note:"),
(re.compile(r"\bthe following\b", re.I), ""),
(re.compile(r"\bshould be followed\b", re.I), "apply"),
]


def _protect(text: str):
protected = []

def repl(match):
protected.append(match.group(0))
return f"__CTXPROT_{len(protected)-1}__"

for pattern in (_URL_RE, _INLINE_CODE_RE, _PATH_RE):
text = pattern.sub(repl, text)
return text, protected


def _restore(text: str, protected):
for i, value in enumerate(protected):
text = text.replace(f"__CTXPROT_{i}__", value)
return text


def compact_context_prose(text: str) -> str:
if not text or len(text) < 80:
return text
if _BULLET_RE.search(text) or _NUMBERED_RE.search(text):
return text
if text.lstrip().startswith("## "):
parts = text.split("\n\n", 1)
if len(parts) == 2:
head, body = parts
compact_body = compact_context_prose(body)
return head + "\n\n" + compact_body
return text

working, protected = _protect(text)
original = working

for pattern, replacement in _FILLER_PATTERNS:
working = pattern.sub(replacement, working)

working = re.sub(r"[ \t]{2,}", " ", working)
working = re.sub(r" ?\n ?", "\n", working)
working = re.sub(r"\n{3,}", "\n\n", working)
working = re.sub(r"\s+([,.;:])", r"\1", working)
working = re.sub(r"\(\s+", "(", working)
working = re.sub(r"\s+\)", ")", working)
working = re.sub(r"\bNote:\s*note:\b", "note:", working, flags=re.I)
working = re.sub(r"\s{2,}", " ", working).strip()

if not working or len(working) >= len(original):
return text

restored = _restore(working, protected)
if len(restored) >= len(text):
return text
return restored
213 changes: 155 additions & 58 deletions agent/prompt_builder.py

Large diffs are not rendered by default.

76 changes: 21 additions & 55 deletions agent/system_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,21 @@
DEFAULT_AGENT_IDENTITY,
GOOGLE_MODEL_OPERATIONAL_GUIDANCE,
HERMES_AGENT_HELP_GUIDANCE,
HERMES_AGENT_HELP_GUIDANCE_COMPACT,
KANBAN_GUIDANCE,
MEMORY_GUIDANCE,
MEMORY_GUIDANCE_COMPACT,
OPENAI_MODEL_EXECUTION_GUIDANCE,
PLATFORM_HINTS,
SEARCH_ROUTER_GUIDANCE,
SEARCH_ROUTER_GUIDANCE_COMPACT,
SESSION_SEARCH_GUIDANCE,
SESSION_SEARCH_GUIDANCE_COMPACT,
SKILLS_GUIDANCE,
SKILLS_GUIDANCE_COMPACT,
TOOL_USE_ENFORCEMENT_GUIDANCE,
TOOL_USE_ENFORCEMENT_MODELS,
_compact_guidance_blocks_enabled,
)


Expand Down Expand Up @@ -97,26 +104,28 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
# Fallback to hardcoded identity
stable_parts.append(DEFAULT_AGENT_IDENTITY)

compact_guidance = _compact_guidance_blocks_enabled()

# Pointer to the hermes-agent skill + docs for user questions about Hermes itself.
stable_parts.append(HERMES_AGENT_HELP_GUIDANCE)
stable_parts.append(
HERMES_AGENT_HELP_GUIDANCE_COMPACT if compact_guidance else HERMES_AGENT_HELP_GUIDANCE
)

# Tool-aware behavioral guidance: only inject when the tools are loaded
tool_guidance = []
if "memory" in agent.valid_tool_names:
tool_guidance.append(MEMORY_GUIDANCE)
tool_guidance.append(MEMORY_GUIDANCE_COMPACT if compact_guidance else MEMORY_GUIDANCE)
if "session_search" in agent.valid_tool_names:
tool_guidance.append(SESSION_SEARCH_GUIDANCE)
tool_guidance.append(SESSION_SEARCH_GUIDANCE_COMPACT if compact_guidance else SESSION_SEARCH_GUIDANCE)
if "skill_manage" in agent.valid_tool_names:
tool_guidance.append(SKILLS_GUIDANCE)
tool_guidance.append(SKILLS_GUIDANCE_COMPACT if compact_guidance else SKILLS_GUIDANCE)
if "search_router" in agent.valid_tool_names and "web_search" in agent.valid_tool_names:
tool_guidance.append(SEARCH_ROUTER_GUIDANCE_COMPACT if compact_guidance else SEARCH_ROUTER_GUIDANCE)
# Kanban worker/orchestrator lifecycle — only present when the
# dispatcher spawned this process (kanban_show check_fn gates on
# HERMES_KANBAN_TASK env var). Normal chat sessions never see
# this block. Resolved once at __init__ (see _kanban_worker_guidance).
_kanban_guidance = getattr(agent, "_kanban_worker_guidance", None)
if _kanban_guidance:
tool_guidance.append(_kanban_guidance)
elif _kanban_guidance is None and "kanban_show" in agent.valid_tool_names:
# Fallback for code paths that bypass agent_init (rare).
# this block.
if "kanban_show" in agent.valid_tool_names:
tool_guidance.append(KANBAN_GUIDANCE)
if tool_guidance:
stable_parts.append(" ".join(tool_guidance))
Expand Down Expand Up @@ -160,10 +169,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
stable_parts.append(GOOGLE_MODEL_OPERATIONAL_GUIDANCE)
# OpenAI GPT/Codex execution discipline (tool persistence,
# prerequisite checks, verification, anti-hallucination).
# Also applied to xAI Grok — same failure modes (claims completion
# without tool calls, suggests workarounds instead of using
# existing tools, replies with plans instead of executing).
if "gpt" in _model_lower or "codex" in _model_lower or "grok" in _model_lower:
if any(p in _model_lower for p in ("gpt", "codex", "grok", "qwen", "deepseek")):
stable_parts.append(OPENAI_MODEL_EXECUTION_GUIDANCE)

has_skills_tools = any(name in agent.valid_tool_names for name in ['skills_list', 'skill_view', 'skill_manage'])
Expand Down Expand Up @@ -205,40 +211,6 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
if _env_hints:
stable_parts.append(_env_hints)

# Active-profile hint — names the Hermes profile the agent is running
# under so it doesn't conflate ~/.hermes/skills/ (default profile) with
# ~/.hermes/profiles/<active>/skills/ (this profile's). Deterministic
# for the lifetime of the agent — profile name doesn't change
# mid-session, so this doesn't break the prompt cache.
# See file_safety._resolve_active_profile_name + classify_cross_profile_target
# for the matching tool-side guard.
try:
from agent.file_safety import _resolve_active_profile_name
active_profile = _resolve_active_profile_name()
except Exception:
active_profile = "default"
if active_profile == "default":
stable_parts.append(
"Active Hermes profile: default. Other profiles (if any) live "
"under ~/.hermes/profiles/<name>/. Each profile has its own "
"skills/, plugins/, cron/, and memories/ that affect a different "
"session than this one. Do not modify another profile's "
"skills/plugins/cron/memories unless the user explicitly directs "
"you to."
)
else:
stable_parts.append(
f"Active Hermes profile: {active_profile}. This session reads "
f"and writes ~/.hermes/profiles/{active_profile}/. The default "
f"profile's data lives at ~/.hermes/skills/, ~/.hermes/plugins/, "
f"~/.hermes/cron/, ~/.hermes/memories/ — those belong to a "
f"different session run from a different shell. Do NOT modify "
f"another profile's skills/plugins/cron/memories unless the user "
f"explicitly directs you to. The cross-profile write guard will "
f"refuse such writes by default; pass cross_profile=True only "
f"after explicit direction."
)

platform_key = (agent.platform or "").lower().strip()
if platform_key in PLATFORM_HINTS:
stable_parts.append(PLATFORM_HINTS[platform_key])
Expand Down Expand Up @@ -296,13 +268,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)

from hermes_time import now as _hermes_now
now = _hermes_now()
# Date-only (not minute-precision) so the system prompt is byte-stable
# for the full day. Minute-precision changes invalidate prefix-cache KV
# on every rebuild path (compression boundary, fresh-agent gateway turns,
# session resume without a stored prompt). The model can still query the
# exact wall-clock time via tools when it actually needs it.
# Credit: @iamfoz (PR #20451).
timestamp_line = f"Conversation started: {now.strftime('%A, %B %d, %Y')}"
timestamp_line = f"Conversation started: {now.strftime('%A, %B %d, %Y %I:%M %p')}"
if agent.pass_session_id and agent.session_id:
timestamp_line += f"\nSession ID: {agent.session_id}"
if agent.model:
Expand Down
130 changes: 130 additions & 0 deletions agent/tool_description_compactor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""Conservative prose compaction for tool descriptions.

Goal: shrink verbose natural-language descriptions without changing tool names,
URLs, paths, inline code, CLI flags, or numeric limits. This is intentionally
lighter than full caveman persona rewriting: preserve meaning, only trim prose.
"""

from __future__ import annotations

import copy
import re
from typing import Any

_URL_RE = re.compile(r"https?://[^\s)]+")
_INLINE_CODE_RE = re.compile(r"`[^`]+`")
_PATH_RE = re.compile(
r"(?:\./|\.\./|/|~\/|[A-Za-z]:\\)[\w\-./\\]+|[\w\-.]+/[\w\-./]+"
)
_FLAG_RE = re.compile(r"--?[A-Za-z0-9][A-Za-z0-9_-]*")

_FILLER_PATTERNS = [
(re.compile(r"\bplease\b", re.IGNORECASE), ""),
(re.compile(r"\b(?:simply|basically|carefully|helpfully|clearly|really|just)\b", re.IGNORECASE), ""),
(re.compile(r"\b(?:you can|you may)\b", re.IGNORECASE), ""),
(re.compile(r"\b(?:used to|use this to)\b", re.IGNORECASE), "to"),
(re.compile(r"\b(?:allows you to)\b", re.IGNORECASE), "lets you"),
(re.compile(r"\b(?:in order to)\b", re.IGNORECASE), "to"),
(re.compile(r"\b(?:for the purpose of)\b", re.IGNORECASE), "for"),
(re.compile(r"\b(?:that you can)\b", re.IGNORECASE), "that can"),
(re.compile(r"\b(?:there is|there are)\b", re.IGNORECASE), ""),
(re.compile(r"\b(?:it is important to note that)\b", re.IGNORECASE), ""),
(re.compile(r"\b(?:keep in mind that)\b", re.IGNORECASE), ""),
(re.compile(r"\b(?:note that)\b", re.IGNORECASE), ""),
(re.compile(r"\b(?:in the current page|on the current page)\b", re.IGNORECASE), "on the page"),
(re.compile(r"\b(?:identified by its)\b", re.IGNORECASE), "by its"),
(re.compile(r"\b(?:requires browser_navigate and browser_snapshot to be called first)\b", re.IGNORECASE), "Requires browser_navigate and browser_snapshot first"),
(re.compile(r"\b(?:requires browser_navigate to be called first)\b", re.IGNORECASE), "Requires browser_navigate first"),
]

_SENTENCE_CLEANUPS = [
(re.compile(r"\s+,", re.IGNORECASE), ","),
(re.compile(r"\s+\.", re.IGNORECASE), "."),
(re.compile(r"\(\s+", re.IGNORECASE), "("),
(re.compile(r"\s+\)", re.IGNORECASE), ")"),
(re.compile(r"\s{2,}"), " "),
]


def _protect(text: str) -> tuple[str, dict[str, str]]:
protected: dict[str, str] = {}
counter = 0

def _sub(pattern: re.Pattern[str], src: str) -> str:
nonlocal counter

def repl(match: re.Match[str]) -> str:
nonlocal counter
key = f"__CPTK_{counter}__"
counter += 1
protected[key] = match.group(0)
return key

return pattern.sub(repl, src)

out = text
for pattern in (_URL_RE, _INLINE_CODE_RE, _PATH_RE, _FLAG_RE):
out = _sub(pattern, out)
return out, protected


def _restore(text: str, protected: dict[str, str]) -> str:
out = text
for key, value in protected.items():
out = out.replace(key, value)
return out


def compact_description(text: str) -> str:
if not isinstance(text, str):
return text
original = text
stripped = text.strip()
if len(stripped) < 40:
return original

working, protected = _protect(original)

for pattern, repl in _FILLER_PATTERNS:
working = pattern.sub(repl, working)

# Tighten a few verbose constructions while keeping meaning stable.
working = re.sub(r"\b[Aa]sk the user a question when you need\b", "Ask the user when you need", working)
working = re.sub(r"\bReturns up to ([0-9]+) results by default\b", r"Returns up to \1 results", working)
working = re.sub(r"\bOptional:?\s+", "", working)
working = re.sub(r"\bDefault:?\s+", "", working)

for pattern, repl in _SENTENCE_CLEANUPS:
working = pattern.sub(repl, working)

# Clean repeated punctuation/space artifacts from aggressive substitutions.
working = re.sub(r"\s*;\s*;", ";", working)
working = re.sub(r"\s*\.\s*\.\s*", ". ", working)
working = re.sub(r"\s+([:;!?])", r"\1", working)
working = re.sub(r"([:;!?])(\w)", r"\1 \2", working)
working = working.strip()

out = _restore(working, protected)
out = re.sub(r"\s{2,}", " ", out).strip()

# Safety: never return empty, never expand a lot, and require a small win.
if not out:
return original
if len(out) >= len(original) - 4:
return original
if len(out) < max(16, int(len(original) * 0.45)):
# Too aggressive for a conservative v1.
return original
return out


def compact_tool_definitions(tool_defs: list[dict[str, Any]]) -> list[dict[str, Any]]:
out = copy.deepcopy(tool_defs)
for tool in out:
fn = tool.get("function")
if not isinstance(fn, dict):
continue
desc = fn.get("description")
if isinstance(desc, str) and desc:
fn["description"] = compact_description(desc)
return out
Loading
Loading