Skip to content
Open
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
25 changes: 25 additions & 0 deletions agent/coding_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,31 @@ def _coding_mode(config: Optional[dict[str, Any]]) -> str:
return _MODE_ALIASES.get(str(raw).strip().lower(), "auto")


def guarded_prompt_enabled(
*,
platform: Optional[str] = None,
cwd: Optional[str | Path] = None,
provider: Optional[str] = None,
model: Optional[str] = None,
config: Optional[dict[str, Any]] = None,
) -> bool:
"""Return whether the explicitly opted-in local prompt profile is allowed."""
agent_cfg = (config or {}).get("agent", {}) or {}
if not isinstance(agent_cfg, dict) or _coding_mode(config) != "focus":
return False
raw = agent_cfg.get("guarded_prompt_mode")
if not isinstance(raw, dict) or raw.get("enabled") is not True:
return False
routes = raw.get("routes")
if not isinstance(routes, (list, tuple)):
return False
route = (str(provider or "").strip().lower(), str(model or "").strip().lower())
allowed = {(str(item.get("provider") or "").strip().lower(), str(item.get("model") or "").strip().lower()) for item in routes if isinstance(item, dict)}
if not route[0] or not route[1] or route not in allowed:
return False
return resolve_runtime_mode(platform=platform, cwd=cwd, config=config, model=model).is_coding


def _resolve_cwd(cwd: Optional[str | Path]) -> Path:
if cwd:
return Path(cwd).expanduser()
Expand Down
13 changes: 10 additions & 3 deletions agent/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -1207,6 +1207,7 @@ def _current_session_platform_hint() -> str:
def build_skills_system_prompt(
available_tools: "set[str] | None" = None, available_toolsets: "set[str] | None" = None,
compact_categories: "frozenset[str] | None" = None, skills_dir_override: "Path | None" = None,
compact_all_categories: bool = False,
) -> str:
"""Compact skill index for the system prompt.

Expand All @@ -1229,7 +1230,8 @@ def build_skills_system_prompt(
if not skills_dir.exists() and not external_dirs and not project_dirs:
return ""
return _build_skills_system_prompt_inner(
skills_dir, external_dirs, available_tools, available_toolsets, compact_categories, project_dirs)
skills_dir, external_dirs, available_tools, available_toolsets, compact_categories,
compact_all_categories, project_dirs)
finally:
if _home_token is not None:
reset_hermes_home_override(_home_token)
Expand Down Expand Up @@ -1291,13 +1293,15 @@ def _label_visible_entries(visible_entries: list[dict], skills_by_category: dict
def _render_skills_index(
skills_by_category: dict[str, list[tuple[str, str]]], category_descriptions: dict[str, str],
compact_categories: "frozenset[str] | None", available_tools: "set[str] | None",
compact_all_categories: bool = False,
) -> str:
"""Render the ## Skills block; "" when there is nothing to list."""
if not skills_by_category:
return ""
# Demoted categories collapse to one names-only line. NEVER drop entries — agent-created skills are the
# model's project memory and it won't rediscover them via skills_list. Nested categories follow their parent.
demoted = frozenset(cat for cat in skills_by_category if cat.split("/", 1)[0] in (compact_categories or frozenset()))
demoted = (frozenset(skills_by_category) if compact_all_categories else
frozenset(cat for cat in skills_by_category if cat.split("/", 1)[0] in (compact_categories or frozenset())))
hidden_note = (
"\n(Categories marked [names only] are outside the current coding "
"context, so their descriptions are omitted — the skills work "
Expand Down Expand Up @@ -1344,6 +1348,7 @@ def _render_skills_index(
def _build_skills_system_prompt_inner(
skills_dir: "Path", external_dirs: "list[Path]", available_tools: "set[str] | None",
available_toolsets: "set[str] | None", compact_categories: "frozenset[str] | None",
compact_all_categories: bool = False,
project_dirs: "list[Path] | None" = None,
) -> str:
# The resolved platform is part of the key: per-platform disabled-skill lists need distinct cache entries.
Expand All @@ -1355,6 +1360,7 @@ def _build_skills_system_prompt_inner(
tuple(sorted(str(t) for t in (available_tools or set()))),
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
_platform_hint, tuple(sorted(disabled)), tuple(sorted(compact_categories or ())),
bool(compact_all_categories),
)
with _SKILLS_PROMPT_CACHE_LOCK:
cached = _SKILLS_PROMPT_CACHE.get(cache_key)
Expand Down Expand Up @@ -1412,7 +1418,8 @@ def hides(frontmatter_name: str, skill_name: str, conditions: dict) -> bool:
for cat, cat_desc in _read_category_descriptions(ext_dir, "Could not read external skill description %s: %s").items():
category_descriptions.setdefault(cat, cat_desc)

result = _render_skills_index(skills_by_category, category_descriptions, compact_categories, available_tools)
result = _render_skills_index(skills_by_category, category_descriptions, compact_categories, available_tools,
compact_all_categories)
with _SKILLS_PROMPT_CACHE_LOCK:
_SKILLS_PROMPT_CACHE[cache_key] = result
_SKILLS_PROMPT_CACHE.move_to_end(cache_key)
Expand Down
65 changes: 54 additions & 11 deletions agent/system_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@
)
_GATE_WORDS = {**dict.fromkeys(("true", "always", "yes", "on"), True), **dict.fromkeys(("false", "never", "no", "off"), False)}

GUARDED_EXECUTION_CONTRACT = (
"# Guarded coding execution contract\n"
"- Work only in the current session worktree. Inspect git status before edits "
"and preserve unrelated user changes.\n"
"- Ground claims in tools: read/search before changing code; use tools for "
"files, git, system state, calculations, and current facts.\n"
"- Make the requested change through tools, then verify it with the relevant "
"command and report its real result. Do not claim completion from a plan or guess.\n"
"- Batch independent read-only calls. Serialize dependent edits. Respect tool "
"permissions and confirmations for side effects.\n"
"- All listed skills remain available. Load a relevant skill with skill_view; "
"use tool discovery when a needed capability is not visible."
)


def _model_gate(setting: Any, model: Optional[str], default_models) -> bool:
"""Resolve a config gate: True/"true"-ish -> on, False/"false"-ish -> off,
Expand Down Expand Up @@ -308,8 +322,16 @@ def _skills_prompt(agent: Any) -> str:
_compact_cats = coding_compact_skill_categories(platform=agent.platform, cwd=resolve_context_cwd())
except Exception:
_compact_cats = frozenset()
return _pb.build_skills_system_prompt(available_tools=agent.valid_tool_names, available_toolsets=avail_toolsets,
compact_categories=_compact_cats or None, skills_dir_override=_agent_skills_dir(agent))
build_skills_system_prompt = _pb.build_skills_system_prompt
try:
import run_agent
build_skills_system_prompt = run_agent.__dict__.get("build_skills_system_prompt") or build_skills_system_prompt
except Exception:
pass
return build_skills_system_prompt(available_tools=agent.valid_tool_names, available_toolsets=avail_toolsets,
compact_categories=_compact_cats or None,
compact_all_categories=_guarded_prompt_enabled(agent),
skills_dir_override=_agent_skills_dir(agent))


def _bot_mode_parts(agent: Any) -> List[str]:
Expand Down Expand Up @@ -485,6 +507,20 @@ def _memory_parts(agent: Any) -> List[str]:
return parts


def _guarded_prompt_enabled(agent: Any) -> bool:
"""Guarded prompt is opt-in and restricted to coding-focus sessions."""
try:
from agent.coding_context import guarded_prompt_enabled
return bool(guarded_prompt_enabled(
platform=getattr(agent, "platform", None),
cwd=resolve_context_cwd(),
provider=getattr(agent, "provider", None),
model=getattr(agent, "model", None),
))
except Exception:
return False


def _identity_parts(agent: Any, ctx_len: Optional[int]) -> Tuple[List[str], bool]:
"""SOUL.md (primary identity; cron keeps the persona while skipping cwd
instructions, scoped to the agent's OWN home) or the default identity.
Expand All @@ -497,18 +533,25 @@ def _identity_parts(agent: Any, ctx_len: Optional[int]) -> Tuple[List[str], bool
def _guidance_parts(agent: Any) -> List[str]:
"""Universal + tool-aware + model-gated guidance blocks, each gated by its config.yaml key."""
parts: List[str] = []
guarded = _guarded_prompt_enabled(agent)
if guarded:
parts.append(GUARDED_EXECUTION_CONTRACT)
if agent.valid_tool_names:
parts += [
text for flag, text in (
("_task_completion_guidance", TASK_COMPLETION_GUIDANCE),
("_parallel_tool_call_guidance", PARALLEL_TOOL_CALL_GUIDANCE),
) if getattr(agent, flag, True)
]
parts.append(_tool_guidance_block(agent)) # None/empty entries are dropped by _join_tier
if getattr(agent, "_task_completion_guidance", True):
parts.append(TASK_COMPLETION_GUIDANCE)
if not guarded and getattr(agent, "_parallel_tool_call_guidance", True):
parts.append(PARALLEL_TOOL_CALL_GUIDANCE)
if not guarded:
parts.append(_tool_guidance_block(agent)) # None/empty entries are dropped by _join_tier
elif getattr(agent, "_kanban_worker_guidance", None):
parts.append(agent._kanban_worker_guidance)
elif "kanban_show" in agent.valid_tool_names:
parts.append(KANBAN_GUIDANCE)
if not agent.valid_tool_names:
return parts
# Steering only lands inside tool results, so only reachable with tools.
parts.append(STEER_CHANNEL_NOTE)
if not guarded:
parts.append(STEER_CHANNEL_NOTE)
# agent.tool_use_enforcement / agent.execution_guidance: "auto" (default)
# matches the hardcoded model lists; true/false force; a list gives custom
# model-name substrings. Execution guidance is an independent gate so
Expand All @@ -517,7 +560,7 @@ def _guidance_parts(agent: Any) -> List[str]:
parts.append(TOOL_USE_ENFORCEMENT_GUIDANCE)
if any(g in (agent.model or "").lower() for g in ("gemini", "gemma")):
parts.append(GOOGLE_MODEL_OPERATIONAL_GUIDANCE)
if _model_gate(getattr(agent, "_execution_guidance", "auto"), agent.model, EXECUTION_GUIDANCE_MODELS):
if not guarded and _model_gate(getattr(agent, "_execution_guidance", "auto"), agent.model, EXECUTION_GUIDANCE_MODELS):
from agent.prompt_builder import execution_guidance_text
parts.append(execution_guidance_text(agent.valid_tool_names))
return parts
Expand Down
21 changes: 18 additions & 3 deletions hermes_cli/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,24 @@ def _aux(timeout, *, reasoning_effort=True, **extra):
# toolset to the lean coding set (+ enabled MCP servers) + demote non-coding skill
# categories to names-only (explicit opt-in); "on" = force everywhere; "off" = disable.
"coding_context": "auto",
# Standing operator instructions (string or list) appended to the coding brief as an extra
# stable system block — project-wide workflow rules, e.g. "Don't run tsc/lint until I
# approve." Cache-safe: takes effect next session.
# Guarded prompt profile — opt-in, exact provider/model route pairs
# only. It replaces redundant long-form coaching with a compact
# worktree/verification contract and renders skills names-only. The
# task-completion, configured tool-use enforcement, and env-gated
# Kanban worker protocol remain load-bearing in this mode.
# Requires coding_context: focus and a coding workspace. It is safe to
# list local Ollama and Copilot routes together because matching is by
# pair, not independent provider/model allowlists.
"guarded_prompt_mode": {
"enabled": False,
"routes": [],
},
# Standing operator instructions for the coding posture. A string (or
# list of strings) appended to the coding brief as an extra stable
# system block — pin project-wide workflow rules here instead of editing
# the shipped brief, e.g. "For UI work, don't run tsc/lint until I
# approve. Clean the diff before you commit and push." Cache-safe:
# takes effect next session. Empty by default.
"coding_instructions": "",
# When verify-on-stop finds edits without fresh verification evidence, add guidance for
# creative UI work (no broad tsc/lint/test before visual approval) and clean-diff
Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/dump.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ def _config_overrides(config: dict) -> dict[str, str]:
"""Find non-default config values worth reporting."""
from hermes_cli.config import DEFAULT_CONFIG
overrides = {}
for section, key in _INTERESTING_PATHS:
for section, key in _INTERESTING_PATHS + (("agent", "guarded_prompt_mode"),):
default_section = DEFAULT_CONFIG.get(section, {})
user_section = config.get(section, {})
if not isinstance(default_section, dict) or not isinstance(user_section, dict):
Expand Down
70 changes: 70 additions & 0 deletions tests/agent/test_coding_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,76 @@ def test_auto_is_prompt_only(self, tmp_path):
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True


class TestGuardedPrompt:
def test_requires_exact_local_provider_model_and_focus_workspace(self, tmp_path):
_git_init(tmp_path)
cfg = {
"agent": {
"coding_context": "focus",
"guarded_prompt_mode": {
"enabled": True,
"routes": [
{"provider": "ollama-launch", "model": "hermes-qwen3-fast"},
],
},
}
}

assert cc.guarded_prompt_enabled(
platform="desktop",
cwd=tmp_path,
provider="ollama-launch",
model="hermes-qwen3-fast",
config=cfg,
) is True
# An exact allowlist is deliberate: no cloud provider or unrelated
# local model may silently get a smaller prompt.
assert cc.guarded_prompt_enabled(
platform="desktop",
cwd=tmp_path,
provider="ollama-launch",
model="unrelated",
config=cfg,
) is False
assert cc.guarded_prompt_enabled(
platform="desktop",
cwd=tmp_path,
provider="some-other-provider",
model="hermes-qwen3-fast",
config=cfg,
) is False

def test_is_off_outside_focus_or_a_coding_workspace(self, tmp_path):
cfg = {
"agent": {
"coding_context": "focus",
"guarded_prompt_mode": {
"enabled": True,
"routes": [
{"provider": "ollama-launch", "model": "hermes-qwen3-fast"},
],
},
}
}
assert cc.guarded_prompt_enabled(
platform="desktop",
cwd=tmp_path,
provider="ollama-launch",
model="hermes-qwen3-fast",
config=cfg,
) is False

_git_init(tmp_path)
cfg["agent"]["coding_context"] = "auto"
assert cc.guarded_prompt_enabled(
platform="desktop",
cwd=tmp_path,
provider="ollama-launch",
model="hermes-qwen3-fast",
config=cfg,
) is False





Expand Down
50 changes: 50 additions & 0 deletions tests/agent/test_system_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,56 @@ def _prompt_parts(agent):
return build_system_prompt_parts(agent)


def test_guarded_prompt_replaces_verbose_coaching_and_compacts_skills():
"""The local profile is smaller, but never drops the execution contract."""
from agent.prompt_builder import (
OPENAI_MODEL_EXECUTION_GUIDANCE,
PARALLEL_TOOL_CALL_GUIDANCE,
TASK_COMPLETION_GUIDANCE,
TOOL_USE_ENFORCEMENT_GUIDANCE,
)
from agent.system_prompt import GUARDED_EXECUTION_CONTRACT

agent = _make_agent(
valid_tool_names=["read_file", "skills_list", "skill_view"],
_task_completion_guidance=True,
_parallel_tool_call_guidance=True,
_tool_use_enforcement=True,
_execution_guidance=True,
platform="desktop",
provider="ollama-launch",
model="hermes-qwen3-fast",
)
with (
patch("agent.coding_context.guarded_prompt_enabled", return_value=True),
patch("run_agent.build_skills_system_prompt", return_value="SKILLS") as skills,
):
stable = _stable_prompt(agent)

assert GUARDED_EXECUTION_CONTRACT in stable
assert "worktree" in GUARDED_EXECUTION_CONTRACT.lower()
assert "verify" in GUARDED_EXECUTION_CONTRACT.lower()
assert TASK_COMPLETION_GUIDANCE in stable
assert TOOL_USE_ENFORCEMENT_GUIDANCE in stable
assert PARALLEL_TOOL_CALL_GUIDANCE not in stable
assert OPENAI_MODEL_EXECUTION_GUIDANCE not in stable
assert skills.call_args.kwargs["compact_all_categories"] is True


def test_guarded_prompt_keeps_kanban_worker_lifecycle_guidance():
agent = _make_agent(
valid_tool_names=["kanban_show", "read_file"],
_kanban_worker_guidance="KANBAN_WORKER_LIFECYCLE",
platform="desktop",
provider="ollama-launch",
model="hermes-qwen3-fast",
)
with patch("agent.coding_context.guarded_prompt_enabled", return_value=True):
stable = _stable_prompt(agent)

assert "KANBAN_WORKER_LIFECYCLE" in stable


def _init_code_repo(path):
"""A git repo that actually holds code — the coding posture requires a source
file (or manifest), not a bare ``.git`` (a prose/notes repo stays general)."""
Expand Down
18 changes: 18 additions & 0 deletions website/docs/user-guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1860,6 +1860,24 @@ The injected block covers:

The gate is independent of `tool_use_enforcement` — either can be on without the other. The guidance is chosen once at session start keyed on the model name, so the system prompt stays byte-stable (and prompt-cache-friendly) for the life of the conversation. Gemini/Gemma are excluded from the auto list because they receive the more specific Google operational guidance; Claude is excluded because it doesn't exhibit these failure modes — opt any model in with `true` or a substring list.

## Guarded Prompt Mode

For smaller local coding models, guarded prompt mode replaces redundant long-form coaching with a compact contract that retains the current-worktree rule, tool grounding, permission checks, verification before completion, skill loading, and deferred tool discovery. Universal task-completion guidance, configured tool-use enforcement, and the environment-gated Kanban worker lifecycle protocol remain active because they prevent load-bearing execution failures. It does **not** change tool-side permission enforcement or hide any skill.

It is disabled by default and requires `coding_context: focus`, a detected coding workspace, and an exact provider/model route pair. This makes the mode reversible and prevents it from silently affecting another model.

```yaml
agent:
coding_context: focus
guarded_prompt_mode:
enabled: true
routes:
- provider: ollama-launch
model: hermes-qwen3-fast
```

Every route is matched as a pair: `ollama-launch + gpt-5.4`, for example, does not activate merely because each value appears elsewhere in the list. In guarded sessions all skill names remain visible, but their descriptions are loaded on demand with `skill_view`.

## Tool-Loop Guardrails

Hermes detects when the agent is stuck in an unproductive tool-calling loop — the same tool call failing repeatedly, the same tool failing over and over, or an idempotent call returning the same result with no progress. By default it injects a **warning** into the tool result so the model self-corrects. Interactive CLI, TUI, Desktop, and ACP sessions remain warning-only because a person can intervene; unattended gateway and cron sessions enable hard stops by default.
Expand Down