Compare commits

...
Author SHA1 Message Date
Teknium 3645ee221c docs(agent): verify edit-format steering against current Codex CLI + Claude Code source
Checked ~/agent-codebases (Codex @ 2026-05-30, Claude Code decompiled src):
- Codex: apply_patch is the only file-edit tool; apply_patch.lark is V4A;
  GPT-5.1/5.2(-codex) prompts mandate it; gated per-model via
  ModelInfo.apply_patch_tool_type. No str_replace editor in the repo.
- Claude Code: FileEditTool is old_string/new_string exact replacement with
  unique-match semantics — current Claude models train against str_replace
  in their first-party harness, not just the 2025 API text-editor tool.
2026-06-11 09:16:45 -07:00
Teknium 8d2e0127b3 fix(agent): gate skill-index pruning behind focus mode; drop redundant context-files line; cite edit-format sources
Follow-up to #43316 (coding-context posture):

- Skill-category pruning from the prompt's skill index now only fires under
  the opt-in 'focus' mode, matching the toolset-collapse gating. The default
  'auto' posture must never silently hide skills — a hidden skill is
  effectively never loaded proactively, footer note or not.
- Drop the 'Context files: AGENTS.md ...' line from the workspace snapshot:
  those files' full contents are already injected into the system prompt as
  the Project Context block, so naming them again is redundant.
- Replace the unsourced edit-format steering rationale with citations:
  OpenAI GPT-4.1 prompting guide (apply_patch/V4A), Anthropic text-editor
  tool docs (str_replace schema trained into the model), and the
  str_replace-style editors in SWE-agent/OpenHands/Qwen Code/Gemini CLI.
2026-06-11 09:16:45 -07:00
helix4u e71d746820 fix(mcp): avoid false failed startup status 2026-06-11 09:01:52 -07:00
Teknium 5508f4bc54 fix(cli): utf-8 decode for whatsapp-bridge npm install capture (sibling of #43790) 2026-06-11 09:00:55 -07:00
helix4u b2043cf157 fix(tui): decode startup subprocess output as utf-8 2026-06-11 09:00:55 -07:00
helix4u dca11b6650 fix(mcp): preserve stdio argv passthrough 2026-06-11 08:59:55 -07:00
brooklyn! ee1a744ace fix(agent): demote non-coding skill categories to names-only — never hide skills (#44342)
Real-world failure with the original index pruning: under the default auto
posture, an agent-created ops skill in a demoted category vanished from the
prompt's skill index mid-project, and the agent silently fell back to a
stale sibling skill instead. The "discovery-only" premise didn't hold —
models do not reach for skills_list to rediscover what the index stops
showing them, and agent-created skills are the model's accumulated project
memory (runbooks, pitfalls, operating rules).

Gating pruning behind the opt-in focus mode was the wrong fix too: users
opening a worktree don't know the config exists, so the index-noise win
would effectively never ship.

Instead, the coding posture now DEMOTES non-coding categories rather than
hiding them: each demoted category renders as a single names-only line
("gaming [names only]: allthemons10-ops, mc-backup") with a footer note
explaining the omitted descriptions. Every skill name stays in the prompt,
so memory-anchored recall ("load <name>") keeps working in every mode,
while the description noise is still cut. Applies in auto/on/focus alike;
the general posture demotes nothing. Deny-list semantics unchanged —
unknown/custom categories and coding-adjacent ones keep full entries.

API renamed to match the honest semantics: hidden_skill_categories →
compact_skill_categories, build_skills_system_prompt(hidden_categories=) →
compact_categories=.
2026-06-11 10:25:42 -05:00
18 changed files with 584 additions and 118 deletions
+61 -30
View File
@@ -38,9 +38,10 @@ session (deferred), the same contract as ``/skills install`` vs ``--now``.
Activation (config ``agent.coding_context``):
* ``auto`` (default) — posture (brief + snapshot) on an interactive coding
surface sitting in a code workspace (git repo or recognised project root).
Prompt-only; toolsets untouched.
* ``auto`` (default) — posture (brief + snapshot + names-only demotion of
non-coding skill categories) on an interactive coding surface sitting in
a code workspace (git repo or recognised project root). Prompt-only;
toolsets untouched, no skill is ever hidden.
* ``focus`` — like ``auto``, but additionally collapses the toolset to the
``coding`` set + enabled MCP servers. Explicit opt-in for a lean schema.
* ``on`` — force the posture anywhere (incl. non-workspaces). Prompt-only.
@@ -97,10 +98,26 @@ _GIT_TIMEOUT = 2.5
# Per-model edit-format steering. Matching the edit tool format to how a model
# was trained reduces mistakes and wasted reasoning (OpenAI/Codex handle
# patch-style diffs best; Anthropic models — and most open-weight coding
# models, whose RL scaffolds use str_replace-style editors — do best with
# string-replacement). Our `patch` tool exposes both: mode="patch" (V4A
# was trained reduces mistakes and wasted reasoning. Documented sources,
# verified against current first-party agent source (MayJun 2026):
# - GPT/Codex → V4A patch: Codex CLI's ONLY file-edit tool is apply_patch and
# its grammar (codex-rs/core/src/tools/handlers/apply_patch.lark) is exactly
# the V4A format; the GPT-5.1/5.2(-codex) prompts instruct "Use the
# `apply_patch` tool to edit files", and OpenAI gates the tool per model via
# ModelInfo.apply_patch_tool_type. No str_replace editor exists in Codex.
# (Earlier doc: the GPT-4.1 prompting guide ships apply_patch/V4A —
# https://developers.openai.com/cookbook/examples/gpt4-1_prompting_guide)
# - Claude → str_replace: Claude Code's FileEditTool is exact string
# replacement (old_string/new_string/replace_all, unique-match semantics) —
# current Claude models are RL'd against str_replace editing in their own
# first-party harness. Also Anthropic's API text-editor tool
# (`str_replace_based_edit_tool`) is schema-less: the schema is built into
# the model.
# https://platform.claude.com/docs/en/agents-and-tools/tool-use/text-editor-tool
# - Open-weight coding models → str_replace: the dominant open RL/agentic
# scaffolds (SWE-agent, OpenHands ACI) use str_replace-style editors, and
# Qwen Code / Gemini CLI ship old_string/new_string `replace` tools.
# Our `patch` tool exposes both: mode="patch" (V4A
# multi-file) and mode="replace" (find-and-swap). We nudge each family toward
# its native format. Unknown families get nothing (the brief's neutral wording
# stands). Substrings match the model id; aligned with TOOL_USE_ENFORCEMENT_MODELS.
@@ -212,11 +229,13 @@ class ContextProfile:
``model_hint`` — routing preference key for smart model routing
(extension seam; not yet consumed by the router).
``memory_policy``— memory namespace/weighting hint (extension seam).
``hidden_skill_categories`` — skill categories pruned from the system-prompt
skill index while this posture is active. Discovery-only:
nothing is disabled — ``skills_list`` still returns the
full catalog and ``skill_view`` loads anything. Deny-list
semantics so unknown/custom categories stay visible.
``compact_skill_categories`` — skill categories DEMOTED to names-only in
the system-prompt skill index while this posture is
active. Never hidden: every skill name stays visible
(so memory-anchored recall keeps working) — only the
descriptions are dropped to cut index noise. Deny-list
semantics so unknown/custom categories keep full
entries.
"""
name: str
@@ -224,14 +243,14 @@ class ContextProfile:
guidance: str = ""
model_hint: Optional[str] = None
memory_policy: str = "default"
hidden_skill_categories: tuple[str, ...] = ()
compact_skill_categories: tuple[str, ...] = ()
# Skill categories that are clearly not part of a coding workflow. Hidden from
# the prompt's skill index in the coding posture (deny-list — anything not
# listed here, incl. custom user categories, stays visible). Coding-adjacent
# categories (devops, github, mcp, data-science, diagramming, research,
# security, …) are intentionally absent.
# Skill categories that are clearly not part of a coding workflow. Demoted to
# names-only in the prompt's skill index while the coding posture is active
# (deny-list — anything not listed here, incl. custom user categories, keeps
# full entries). Coding-adjacent categories (devops, github, mcp,
# data-science, diagramming, research, security, …) are intentionally absent.
_NON_CODING_SKILL_CATEGORIES = (
"apple", "communication", "cooking", "creative", "email", "finance",
"gaming", "gifs", "health", "media", "music", "note-taking",
@@ -247,7 +266,7 @@ CODING_PROFILE = ContextProfile(
guidance=CODING_AGENT_GUIDANCE,
model_hint="coding",
memory_policy="project",
hidden_skill_categories=_NON_CODING_SKILL_CATEGORIES,
compact_skill_categories=_NON_CODING_SKILL_CATEGORIES,
)
_PROFILES: dict[str, ContextProfile] = {
@@ -432,9 +451,20 @@ class RuntimeMode:
blocks.append(workspace)
return blocks
def hidden_skill_categories(self) -> frozenset[str]:
"""Skill categories to prune from the prompt's skill index (may be empty)."""
return frozenset(self.profile.hidden_skill_categories)
def compact_skill_categories(self) -> frozenset[str]:
"""Skill categories to demote to names-only in the prompt's skill index.
Demoted — never hidden. An earlier revision fully pruned these
categories from the index, which caused silent capability loss in a
real workflow: agent-created skills are the model's accumulated
project memory (server-ops runbooks, learned pitfalls, …), and models
do not reliably reach for ``skills_list`` to rediscover what the
index stopped showing them. Names-only keeps every skill loadable on
recall while still cutting the description noise from the index.
"""
if not self.is_coding:
return frozenset()
return frozenset(self.profile.compact_skill_categories)
def resolve_runtime_mode(
@@ -512,20 +542,21 @@ def coding_system_blocks(
).system_blocks()
def coding_hidden_skill_categories(
def coding_compact_skill_categories(
*,
platform: Optional[str] = None,
cwd: Optional[str | Path] = None,
config: Optional[dict[str, Any]] = None,
) -> frozenset[str]:
"""Skill categories the active posture prunes from the prompt's skill index.
"""Skill categories the active posture demotes to names-only in the index.
Empty outside the coding posture. Discovery-only: hidden skills remain
loadable via ``skills_list`` / ``skill_view``.
Empty outside the coding posture. Demoted — never hidden: every skill
name stays in the index and remains loadable via ``skill_view`` /
``skills_list``; only descriptions are dropped.
"""
return resolve_runtime_mode(
platform=platform, cwd=cwd, config=config
).hidden_skill_categories()
).compact_skill_categories()
def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]:
@@ -642,9 +673,9 @@ def _project_facts(root: Path) -> list[str]:
deduped = list(dict.fromkeys(verify))[:_MAX_VERIFY_COMMANDS]
facts.append(f"- Verify: {'; '.join(deduped)}")
context_files = [c for c in _CONTEXT_FILES if (root / c).is_file()]
if context_files:
facts.append(f"- Context files: {', '.join(context_files)}")
# Note: context files (AGENTS.md / CLAUDE.md / .cursorrules) are NOT listed
# here — their full contents are already injected into the system prompt as
# the Project Context block, so naming them again is redundant.
return facts
+33 -27
View File
@@ -1101,7 +1101,7 @@ def _skill_should_show(
def build_skills_system_prompt(
available_tools: "set[str] | None" = None,
available_toolsets: "set[str] | None" = None,
hidden_categories: "frozenset[str] | None" = None,
compact_categories: "frozenset[str] | None" = None,
) -> str:
"""Build a compact skill index for the system prompt.
@@ -1117,11 +1117,11 @@ def build_skills_system_prompt(
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.
``hidden_categories`` (e.g. from the coding posture — see
agent/coding_context.py) prunes whole categories from the rendered index.
Discovery-only: the snapshot stores everything, ``skills_list`` /
``skill_view`` still reach every skill, and a footer note tells the model
the full catalog exists.
``compact_categories`` (e.g. from the coding posture — see
agent/coding_context.py) demotes whole categories to a names-only line in
the rendered index. Nothing is ever hidden: every skill name stays
visible and loadable via ``skill_view`` / ``skills_list``; only the
descriptions are dropped, and a footer note explains the demotion.
"""
skills_dir = get_skills_dir()
external_dirs = get_all_skills_dirs()[1:] # skip local (index 0)
@@ -1146,7 +1146,7 @@ def build_skills_system_prompt(
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
_platform_hint,
tuple(sorted(disabled)),
tuple(sorted(hidden_categories or ())),
tuple(sorted(compact_categories or ())),
)
with _SKILLS_PROMPT_CACHE_LOCK:
cached = _SKILLS_PROMPT_CACHE.get(cache_key)
@@ -1280,38 +1280,44 @@ def build_skills_system_prompt(
except Exception as e:
logger.debug("Could not read external skill description %s: %s", desc_file, e)
# Posture-driven category pruning (e.g. non-coding skills while pairing on
# code). Match on the top-level category segment so nested categories
# ("social-media/twitter") are pruned with their parent.
# Posture-driven category demotion (e.g. non-coding skills while pairing
# on code). Demoted categories stay in the index as a single names-only
# line — descriptions are dropped to cut noise, but every skill name
# remains visible so memory-anchored recall ("load <name>") keeps working.
# NEVER remove entries entirely: agent-created skills are the model's
# project memory, and models don't reach for skills_list to rediscover
# what the index stops showing them. Match on the top-level category
# segment so nested categories ("social-media/twitter") are demoted with
# their parent.
demoted = frozenset(
cat for cat in skills_by_category
if cat.split("/", 1)[0] in (compact_categories or frozenset())
)
hidden_note = ""
if hidden_categories:
before = sum(len(v) for v in skills_by_category.values())
skills_by_category = {
cat: entries
for cat, entries in skills_by_category.items()
if cat.split("/", 1)[0] not in hidden_categories
}
pruned = before - sum(len(v) for v in skills_by_category.values())
if pruned:
hidden_note = (
f"\n(Note: {pruned} skill(s) in categories unrelated to the "
"current coding context are not listed here. The full catalog "
"is available via skills_list if the user asks for something "
"outside this list.)"
)
if demoted:
hidden_note = (
"\n(Categories marked [names only] are outside the current coding "
"context, so their descriptions are omitted — the skills work "
"normally and load with skill_view(name) as usual.)"
)
if not skills_by_category:
result = ""
else:
index_lines = []
for category in sorted(skills_by_category.keys()):
# Deduplicate and sort skills within each category
seen = set()
if category in demoted:
names = sorted({name for name, _ in skills_by_category[category]})
index_lines.append(f" {category} [names only]: {', '.join(names)}")
continue
cat_desc = category_descriptions.get(category, "")
if cat_desc:
index_lines.append(f" {category}: {cat_desc}")
else:
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
+8 -7
View File
@@ -191,21 +191,22 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
)
if toolset
}
# Coding posture prunes non-coding skill categories from the index
# (discovery-only — skills_list/skill_view still reach everything).
_hidden_cats = frozenset()
# Coding posture demotes non-coding skill categories to names-only in
# the index (never hidden — skill_view/skills_list reach everything,
# and every name stays visible for memory-anchored recall).
_compact_cats = frozenset()
try:
from agent.coding_context import coding_hidden_skill_categories
from agent.coding_context import coding_compact_skill_categories
_hidden_cats = coding_hidden_skill_categories(
_compact_cats = coding_compact_skill_categories(
platform=agent.platform, cwd=resolve_context_cwd()
)
except Exception:
_hidden_cats = frozenset()
_compact_cats = frozenset()
skills_prompt = _r.build_skills_system_prompt(
available_tools=agent.valid_tool_names,
available_toolsets=avail_toolsets,
hidden_categories=_hidden_cats or None,
compact_categories=_compact_cats or None,
)
else:
skills_prompt = ""
+12 -1
View File
@@ -693,16 +693,27 @@ def build_welcome_banner(console: "Console", model: str, cwd: str,
right_lines.append("")
right_lines.append(f"[bold {accent}]MCP Servers[/]")
for srv in mcp_status:
status = srv.get("status")
if srv["connected"]:
right_lines.append(
f"[dim {dim}]{srv['name']}[/] [{text}]({srv['transport']})[/] "
f"[dim {dim}]—[/] [{text}]{srv['tools']} tool(s)[/]"
)
elif srv.get("disabled"):
elif srv.get("disabled") or status == "disabled":
right_lines.append(
f"[dim {dim}]{srv['name']}[/] [dim]({srv['transport']})[/] "
f"[dim {dim}]— disabled[/]"
)
elif status == "connecting":
right_lines.append(
f"[dim {dim}]{srv['name']}[/] [dim]({srv['transport']})[/] "
f"[yellow]— connecting[/]"
)
elif status == "configured":
right_lines.append(
f"[dim {dim}]{srv['name']}[/] [dim]({srv['transport']})[/] "
f"[dim {dim}]— configured[/]"
)
else:
right_lines.append(
f"[red]{srv['name']}[/] [dim]({srv['transport']})[/] "
+63 -14
View File
@@ -334,21 +334,66 @@ sys.path.insert(0, str(PROJECT_ROOT))
# Falls back to ~/.hermes/active_profile for sticky default.
# ---------------------------------------------------------------------------
def _apply_profile_override() -> None:
"""Pre-parse --profile/-p and set HERMES_HOME before module imports."""
"""Pre-parse --profile/-p and set HERMES_HOME before imports."""
argv = sys.argv[1:]
profile_name = None
consume = 0
profile_index = None
# 1. Check for explicit -p / --profile flag
for i, arg in enumerate(argv):
def _inside_mcp_add_args(index: int) -> bool:
"""True once argv reaches `hermes mcp add ... --args <command argv>`.
``mcp add --args`` is command-argv passthrough. Flags after that point
belong to the child MCP command (for example Docker MCP Toolkit's
``--profile``), not to Hermes' own profile selector.
"""
try:
mcp_index = argv.index("mcp", 0, index)
argv.index("add", mcp_index + 1, index)
except ValueError:
return False
return True
# 1. Check for explicit -p / --profile flag. Historically this worked even
# after the subcommand (`hermes chat -p coder`), so keep scanning broadly.
# The exception is command-argv passthrough regions such as `mcp add --args`.
value_flags = {
"-z", "--oneshot",
"-m", "--model",
"--provider",
"-t", "--toolsets",
"-r", "--resume",
"-s", "--skills",
}
optional_value_flags = {"-c", "--continue"}
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--":
break
if arg == "--args" and _inside_mcp_add_args(i):
break
if arg in {"--profile", "-p"} and i + 1 < len(argv):
profile_name = argv[i + 1]
consume = 2
profile_index = i
break
elif arg.startswith("--profile="):
if arg.startswith("--profile="):
profile_name = arg.split("=", 1)[1]
consume = 1
profile_index = i
break
if "=" not in arg and arg in value_flags and i + 1 < len(argv):
i += 2
elif (
"=" not in arg
and arg in optional_value_flags
and i + 1 < len(argv)
and not argv[i + 1].startswith("-")
):
i += 2
else:
i += 1
# 1b. Reject values that can't be valid profile names (e.g. pytest's
# "-p no:xdist" would be misread as profile "no:xdist" otherwise).
@@ -360,6 +405,7 @@ def _apply_profile_override() -> None:
if not _re.match(r"^[a-z0-9][a-z0-9_-]{0,63}$", profile_name):
profile_name = None
consume = 0
profile_index = None
# 1.5 If HERMES_HOME is already set and no explicit flag was given, trust it
# only when it already points to a specific profile directory. The
@@ -407,16 +453,9 @@ def _apply_profile_override() -> None:
return
os.environ["HERMES_HOME"] = hermes_home
# Strip the flag from argv so argparse doesn't choke
if consume > 0:
for i, arg in enumerate(argv):
if arg in {"--profile", "-p"}:
start = i + 1 # +1 because argv is sys.argv[1:]
sys.argv = sys.argv[:start] + sys.argv[start + consume :]
break
elif arg.startswith("--profile="):
start = i + 1
sys.argv = sys.argv[:start] + sys.argv[start + 1 :]
break
if consume > 0 and profile_index is not None:
start = profile_index + 1 # +1 because argv is sys.argv[1:]
sys.argv = sys.argv[:start] + sys.argv[start + consume :]
_apply_profile_override()
@@ -1523,6 +1562,8 @@ def _ensure_tui_node() -> None:
env={**os.environ, "HERMES_HOME": hermes_home},
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
except (OSError, subprocess.SubprocessError):
@@ -1647,6 +1688,8 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
env={**os.environ, "CI": "1"},
)
if result.returncode != 0:
@@ -1671,6 +1714,8 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
cwd=str(ink_dir),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if result.returncode != 0:
combined = f"{result.stdout or ''}{result.stderr or ''}".strip()
@@ -1699,6 +1744,8 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
cwd=str(tui_dir),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if result.returncode != 0:
combined = f"{result.stdout or ''}{result.stderr or ''}".strip()
@@ -2372,6 +2419,8 @@ def cmd_whatsapp(args):
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
)
except KeyboardInterrupt:
print("\n ✗ Install cancelled")
+2
View File
@@ -288,6 +288,8 @@ def cmd_mcp_add(args):
# hermes_cli/main.py for why the dest is renamed.
command = getattr(args, "mcp_command", None)
cmd_args = getattr(args, "args", None) or []
if cmd_args and cmd_args[0] == "--":
cmd_args = cmd_args[1:]
auth_type = getattr(args, "auth", None)
preset_name = getattr(args, "preset", None)
raw_env = getattr(args, "env", None)
+5 -1
View File
@@ -6,6 +6,7 @@ Handler injected to avoid importing ``main``.
from __future__ import annotations
import argparse
from typing import Callable
from hermes_cli.subcommands._shared import add_accept_hooks_flag
@@ -52,7 +53,10 @@ def build_mcp_parser(subparsers, *, cmd_mcp: Callable) -> None:
"--command", dest="mcp_command", help="Stdio command (e.g. npx)"
)
mcp_add_p.add_argument(
"--args", nargs="*", default=[], help="Arguments for stdio command"
"--args",
nargs=argparse.REMAINDER,
default=[],
help="Arguments for stdio command; must be the last option",
)
mcp_add_p.add_argument("--auth", choices=["oauth", "header"], help="Auth method")
mcp_add_p.add_argument("--preset", help="Known MCP preset name")
+22 -15
View File
@@ -152,11 +152,14 @@ class TestProjectFacts:
assert "make test" in block
assert "make deploy" not in block
def test_context_files_listed(self, tmp_path):
def test_context_files_not_listed(self, tmp_path):
# Context files (AGENTS.md etc.) are injected into the system prompt
# in full as the Project Context block — naming them in the snapshot
# would be redundant.
_git_init(tmp_path)
(tmp_path / "AGENTS.md").write_text("# rules")
block = cc.build_coding_workspace_block(tmp_path)
assert "Context files: AGENTS.md" in block
assert "Context files:" not in block
def test_marker_only_project_gets_snapshot_without_git(self, tmp_path):
# A non-git project (manifest only) still gets a workspace snapshot —
@@ -368,20 +371,24 @@ class TestProfiles:
assert cc.GENERAL_PROFILE.toolset is None
assert cc.GENERAL_PROFILE.guidance == ""
def test_skill_pruning_scoped_to_coding_posture(self, tmp_path):
# Coding posture hides clearly-non-coding categories; coding-adjacent
# ones stay visible (deny-list semantics).
def test_skill_demotion_scoped_to_coding_posture(self, tmp_path):
# Coding posture demotes clearly-non-coding categories to names-only
# in the index (never hides them — agent-created skills are the
# model's project memory and must stay recallable by name).
# Coding-adjacent categories keep full entries (deny-list semantics).
_git_init(tmp_path)
coding = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={})
hidden = coding.hidden_skill_categories()
assert "social-media" in hidden and "smart-home" in hidden
for kept in ("github", "devops", "software-development", "data-science"):
assert kept not in hidden
# General posture hides nothing.
general = cc.resolve_runtime_mode(
platform="telegram", cwd=tmp_path, config={}
)
assert general.hidden_skill_categories() == frozenset()
for raw in ("auto", "on", "focus"):
mode = cc.resolve_runtime_mode(
platform="cli", cwd=tmp_path, config={"agent": {"coding_context": raw}}
)
assert mode.is_coding is True
compact = mode.compact_skill_categories()
assert "social-media" in compact and "smart-home" in compact
for kept in ("github", "devops", "software-development", "data-science"):
assert kept not in compact
# General posture demotes nothing.
general = cc.resolve_runtime_mode(platform="telegram", cwd=tmp_path, config={})
assert general.compact_skill_categories() == frozenset()
# ── detection signals ───────────────────────────────────────────────────────
+26 -14
View File
@@ -276,8 +276,14 @@ class TestBuildSkillsSystemPrompt:
# "search" should appear only once per category
assert result.count("- search") == 1
def test_hidden_categories_pruned_with_note(self, monkeypatch, tmp_path):
"""Posture-driven pruning drops whole categories and discloses it."""
def test_compact_categories_demoted_to_names_only(self, monkeypatch, tmp_path):
"""Posture-driven demotion keeps every skill NAME visible.
Demoted categories lose their descriptions, never their entries —
full pruning caused silent capability loss in a real workflow
(agent-created skills are the model's project memory, and models
don't rediscover them via skills_list once the index goes quiet).
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
for cat, name in (("social-media", "tweet-stuff"), ("github", "pr-review")):
d = tmp_path / "skills" / cat / name
@@ -287,14 +293,18 @@ class TestBuildSkillsSystemPrompt:
)
result = build_skills_system_prompt(
hidden_categories=frozenset({"social-media"})
compact_categories=frozenset({"social-media"})
)
assert "pr-review" in result
assert "tweet-stuff" not in result
# Disclosure note so the model knows the full catalog exists.
assert "skills_list" in result
# Coding-adjacent category keeps its full entry.
assert "pr-review" in result and "Does pr-review things" in result
# Demoted category: name stays visible, description is dropped.
assert "tweet-stuff" in result
assert "Does tweet-stuff things" not in result
assert "social-media [names only]" in result
# Disclosure note explains the demotion and how to load.
assert "skill_view" in result
def test_hidden_categories_prune_nested_and_miss_cache_separately(
def test_compact_categories_demote_nested_and_miss_cache_separately(
self, monkeypatch, tmp_path
):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
@@ -303,14 +313,16 @@ class TestBuildSkillsSystemPrompt:
(d / "SKILL.md").write_text(
"---\nname: thread-writer\ndescription: Write threads\n---\n"
)
# Nested category ("social-media/twitter") pruned via its parent.
pruned = build_skills_system_prompt(
hidden_categories=frozenset({"social-media"})
# Nested category ("social-media/twitter") demoted via its parent:
# name visible, description gone.
compact = build_skills_system_prompt(
compact_categories=frozenset({"social-media"})
)
assert "thread-writer" not in pruned
# Unfiltered call must not be served from the filtered cache entry.
assert "thread-writer" in compact
assert "Write threads" not in compact
# Unfiltered call must not be served from the compacted cache entry.
full = build_skills_system_prompt()
assert "thread-writer" in full
assert "Write threads" in full
def test_excludes_incompatible_platform_skills(self, monkeypatch, tmp_path):
"""Skills with platforms: [macos] should not appear on Linux."""
@@ -138,3 +138,80 @@ class TestApplyProfileOverrideHermesHomeGuard:
_apply_profile_override()
assert os.environ.get("HERMES_HOME") is None
def test_subcommand_profile_flag_is_not_consumed(self, tmp_path, monkeypatch):
"""Command argv flags named --profile must stay with that command.
Docker Desktop's MCP Toolkit uses `docker mcp gateway run --profile ...`.
When that argv is passed through `hermes mcp add --args`, the early
profile pre-parser must not interpret the Docker profile as a Hermes
profile.
"""
hermes_root = tmp_path / ".hermes"
hermes_root.mkdir(parents=True, exist_ok=True)
argv = [
"hermes",
"mcp",
"add",
"docker-research",
"--command",
"docker",
"--args",
"mcp",
"gateway",
"run",
"--profile",
"research",
]
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.delenv("HERMES_HOME", raising=False)
monkeypatch.setattr(sys, "argv", list(argv))
from hermes_cli.main import _apply_profile_override
_apply_profile_override()
assert os.environ.get("HERMES_HOME") is None
assert sys.argv == argv
def test_profile_after_chat_subcommand_is_still_consumed(self, tmp_path, monkeypatch):
"""Profile flags historically work after normal Hermes subcommands."""
result = _run_apply_profile_override(
tmp_path,
monkeypatch,
hermes_home=None,
active_profile="coder",
argv=["hermes", "chat", "-p", "coder", "-q", "hello"],
)
assert result is not None
assert result.endswith("coder")
assert sys.argv == ["hermes", "chat", "-q", "hello"]
def test_top_level_profile_after_value_flag_is_consumed(self, tmp_path, monkeypatch):
"""Top-level --profile still works after other top-level value flags."""
result = _run_apply_profile_override(
tmp_path,
monkeypatch,
hermes_home=None,
active_profile="coder",
argv=["hermes", "-m", "gpt-5", "--profile", "coder", "chat"],
)
assert result is not None
assert result.endswith("coder")
assert sys.argv == ["hermes", "-m", "gpt-5", "chat"]
def test_top_level_profile_after_continue_flag_is_consumed(self, tmp_path, monkeypatch):
"""--continue has an optional value, so a following --profile is a flag."""
result = _run_apply_profile_override(
tmp_path,
monkeypatch,
hermes_home=None,
active_profile="coder",
argv=["hermes", "--continue", "--profile", "coder"],
)
assert result is not None
assert result.endswith("coder")
assert sys.argv == ["hermes", "--continue"]
+33
View File
@@ -167,3 +167,36 @@ def test_build_welcome_banner_disabled_mcp_shows_disabled_not_failed():
assert "broken" in output
assert "failed" in output
def test_build_welcome_banner_configured_mcp_is_not_failed():
"""A configured MCP server with no connection attempt yet is not a failure."""
with (
patch.object(model_tools, "check_tool_availability", return_value=(["web"], [])),
patch.object(banner, "get_available_skills", return_value={}),
patch.object(banner, "get_update_result", return_value=None),
patch.object(
tools.mcp_tool,
"get_mcp_status",
return_value=[
{
"name": "docker-profile",
"transport": "stdio",
"tools": 0,
"connected": False,
"disabled": False,
"status": "configured",
},
],
),
):
console = Console(record=True, force_terminal=False, color_system=None, width=160)
banner.build_welcome_banner(
console=console, model="anthropic/test-model", cwd="/tmp/project",
tools=[{"function": {"name": "read_file"}}],
get_toolset_for_tool=lambda n: "file",
)
output = console.export_text()
assert "docker-profile" in output
assert "configured" in output
assert "failed" not in output
@@ -41,6 +41,7 @@ def _build_parser():
mcp_add.add_argument("name")
mcp_add.add_argument("--url")
mcp_add.add_argument("--command", dest="mcp_command")
mcp_add.add_argument("--args", nargs=argparse.REMAINDER, default=[])
return parser
@@ -85,3 +86,26 @@ class TestMcpAddCommandDest:
assert args.command == "mcp"
assert args.mcp_command is None
assert args.url is None
def test_args_passthrough_keeps_nested_option_flags(self):
"""`--args` must keep command flags like Docker MCP's --profile."""
parser = _build_parser()
args = parser.parse_args(
[
"mcp",
"add",
"docker-research",
"--command",
"docker",
"--args",
"mcp",
"gateway",
"run",
"--profile",
"research",
]
)
assert args.command == "mcp"
assert args.mcp_command == "docker"
assert args.args == ["mcp", "gateway", "run", "--profile", "research"]
+39
View File
@@ -26,6 +26,12 @@ def _touch_tui_entry(root: Path) -> None:
entry.write_text("console.log('tui')")
def _assert_utf8_replace_capture(kwargs: dict) -> None:
assert kwargs["text"] is True
assert kwargs["encoding"] == "utf-8"
assert kwargs["errors"] == "replace"
def test_need_install_when_ink_missing(tmp_path: Path, main_mod) -> None:
(tmp_path / "package-lock.json").write_text("{}")
assert main_mod._tui_need_npm_install(tmp_path) is True
@@ -228,6 +234,8 @@ def test_make_tui_argv_scopes_npm_install_on_termux_workspace(
"--include-workspace-root=false",
]
assert calls[0][1]["cwd"] == str(tmp_path)
_assert_utf8_replace_capture(calls[0][1])
_assert_utf8_replace_capture(calls[1][1])
def test_make_tui_argv_keeps_desktop_workspace_install_behaviour(
@@ -263,6 +271,8 @@ def test_make_tui_argv_keeps_desktop_workspace_install_behaviour(
"--progress=false",
]
assert calls[0][1]["cwd"] == str(tmp_path)
_assert_utf8_replace_capture(calls[0][1])
_assert_utf8_replace_capture(calls[1][1])
def test_make_tui_argv_keeps_desktop_always_build_behaviour(
@@ -286,6 +296,35 @@ def test_make_tui_argv_keeps_desktop_always_build_behaviour(
assert calls
assert calls[0][0][0] == ["/bin/npm", "run", "build"]
_assert_utf8_replace_capture(calls[0][1])
def test_make_tui_argv_decodes_dev_prebuild_with_utf8_replace(
tmp_path: Path, main_mod, monkeypatch
) -> None:
ink_dir = tmp_path / "packages" / "hermes-ink"
ink_dir.mkdir(parents=True)
tsx = tmp_path / "node_modules" / ".bin" / "tsx"
tsx.parent.mkdir(parents=True)
tsx.write_text("")
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: False)
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
calls = []
def fake_run(*args, **kwargs):
calls.append((args, kwargs))
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
argv, cwd = main_mod._make_tui_argv(tmp_path, tui_dev=True)
assert argv == [str(tsx), "src/entry.tsx"]
assert cwd == tmp_path
assert calls[0][0][0] == ["/bin/npm", "run", "build"]
assert calls[0][1]["cwd"] == str(ink_dir)
_assert_utf8_replace_capture(calls[0][1])
# ── _workspace_root helper ──────────────────────────────────────────
+77
View File
@@ -82,6 +82,56 @@ class TestLoadMCPConfig:
assert result == {}
class TestMCPStatus:
def test_status_distinguishes_configured_connecting_failed_and_disabled(
self, monkeypatch
):
import tools.mcp_tool as mcp_tool
monkeypatch.setattr(
mcp_tool,
"_load_mcp_config",
lambda: {
"configured": {"command": "docker", "args": ["mcp", "gateway", "run"]},
"connecting": {"command": "slow-mcp"},
"failed": {"command": "bad-mcp"},
"disabled": {"command": "off-mcp", "enabled": False},
},
)
with mcp_tool._lock:
saved_servers = dict(mcp_tool._servers)
saved_connecting = set(mcp_tool._server_connecting)
saved_errors = dict(mcp_tool._server_connect_errors)
mcp_tool._servers.clear()
mcp_tool._server_connecting.clear()
mcp_tool._server_connect_errors.clear()
mcp_tool._server_connecting.add("connecting")
mcp_tool._server_connect_errors["failed"] = "Connection closed"
try:
statuses = {
entry["name"]: entry
for entry in mcp_tool.get_mcp_status()
}
finally:
with mcp_tool._lock:
mcp_tool._servers.clear()
mcp_tool._servers.update(saved_servers)
mcp_tool._server_connecting.clear()
mcp_tool._server_connecting.update(saved_connecting)
mcp_tool._server_connect_errors.clear()
mcp_tool._server_connect_errors.update(saved_errors)
assert statuses["configured"]["status"] == "configured"
assert statuses["configured"]["connected"] is False
assert statuses["configured"]["disabled"] is False
assert statuses["connecting"]["status"] == "connecting"
assert statuses["failed"]["status"] == "failed"
assert statuses["failed"]["error"] == "Connection closed"
assert statuses["disabled"]["status"] == "disabled"
assert statuses["disabled"]["disabled"] is True
# ---------------------------------------------------------------------------
# Schema conversion
# ---------------------------------------------------------------------------
@@ -1378,6 +1428,33 @@ class TestBuildSafeEnv:
assert "DATABASE_URL" not in result
assert "API_SECRET" not in result
def test_windows_location_vars_passed_without_secrets(self):
"""Windows launcher tools need location vars, but secrets stay filtered."""
from tools.mcp_tool import _build_safe_env
fake_env = {
"PATH": r"C:\Windows\System32",
"ProgramFiles": r"C:\Program Files",
"ProgramData": r"C:\ProgramData",
"ProgramW6432": r"C:\Program Files",
"LOCALAPPDATA": r"C:\Users\alice\AppData\Local",
"APPDATA": r"C:\Users\alice\AppData\Roaming",
"USERPROFILE": r"C:\Users\alice",
"GITHUB_TOKEN": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"OPENAI_API_KEY": "sk-proj-abc123",
}
with patch.dict("os.environ", fake_env, clear=True):
result = _build_safe_env(None)
assert result["ProgramFiles"] == r"C:\Program Files"
assert result["ProgramData"] == r"C:\ProgramData"
assert result["ProgramW6432"] == r"C:\Program Files"
assert result["LOCALAPPDATA"].endswith("Local")
assert result["APPDATA"].endswith("Roaming")
assert result["USERPROFILE"] == r"C:\Users\alice"
assert "GITHUB_TOKEN" not in result
assert "OPENAI_API_KEY" not in result
# ---------------------------------------------------------------------------
# _sanitize_error
+93 -8
View File
@@ -268,6 +268,38 @@ _SAFE_ENV_KEYS = frozenset({
"PATH", "HOME", "USER", "LANG", "LC_ALL", "TERM", "SHELL", "TMPDIR",
})
_SAFE_ENV_KEYS_CASE_INSENSITIVE = frozenset({
# Windows process/location vars. These are needed by launcher-style tools
# such as Docker Desktop's MCP plugin discovery, and do not carry secrets.
"ALLUSERSPROFILE",
"APPDATA",
"COMMONPROGRAMFILES",
"COMMONPROGRAMFILES(X86)",
"COMMONPROGRAMW6432",
"COMPUTERNAME",
"COMSPEC",
"HOMEDRIVE",
"HOMEPATH",
"LOCALAPPDATA",
"NUMBER_OF_PROCESSORS",
"OS",
"PATHEXT",
"PROCESSOR_ARCHITECTURE",
"PROGRAMDATA",
"PROGRAMFILES",
"PROGRAMFILES(X86)",
"PROGRAMW6432",
"PUBLIC",
"SYSTEMDRIVE",
"SYSTEMROOT",
"TEMP",
"TMP",
"USERDOMAIN",
"USERNAME",
"USERPROFILE",
"WINDIR",
})
# Regex for credential patterns to strip from error messages
_CREDENTIAL_PATTERN = re.compile(
r"(?:"
@@ -305,7 +337,11 @@ def _build_safe_env(user_env: Optional[dict]) -> dict:
"""
env = {}
for key, value in os.environ.items():
if key in _SAFE_ENV_KEYS or key.startswith("XDG_"):
if (
key in _SAFE_ENV_KEYS
or key.upper() in _SAFE_ENV_KEYS_CASE_INSENSITIVE
or key.startswith("XDG_")
):
env[key] = value
if user_env:
env.update(user_env)
@@ -1986,6 +2022,8 @@ class MCPServerTask:
# ---------------------------------------------------------------------------
_servers: Dict[str, MCPServerTask] = {}
_server_connecting: set[str] = set()
_server_connect_errors: Dict[str, str] = {}
# Circuit breaker: consecutive error counts per server. After
# _CIRCUIT_BREAKER_THRESHOLD consecutive failures, the handler returns
@@ -2372,8 +2410,8 @@ _mcp_tool_server_names: Dict[str, str] = {}
_mcp_loop: Optional[asyncio.AbstractEventLoop] = None
_mcp_thread: Optional[threading.Thread] = None
# Protects _mcp_loop, _mcp_thread, _servers, _parallel_safe_servers,
# _mcp_tool_server_names, and _stdio_pids.
# Protects _mcp_loop, _mcp_thread, _servers, MCP connection status maps,
# _parallel_safe_servers, _mcp_tool_server_names, and _stdio_pids.
_lock = threading.Lock()
# PIDs of stdio MCP server subprocesses. Tracked so we can force-kill
@@ -3517,6 +3555,8 @@ async def _discover_and_register_server(name: str, config: dict) -> List[str]:
timeout=connect_timeout,
)
with _lock:
_server_connecting.discard(name)
_server_connect_errors.pop(name, None)
_servers[name] = server
registered_names = _register_server_tools(name, server, config)
@@ -3563,6 +3603,9 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]:
for k, v in servers.items()
if k not in _servers and _parse_boolish(v.get("enabled", True), default=True)
}
_server_connecting.update(new_servers)
for srv_name in new_servers:
_server_connect_errors.pop(srv_name, None)
# Track which servers opt-in to parallel tool calls (idempotent).
for srv_name, srv_cfg in servers.items():
if _parse_boolish(srv_cfg.get("supports_parallel_tool_calls", False), default=False):
@@ -3590,12 +3633,20 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]:
for name, result in zip(server_names, results):
if isinstance(result, BaseException):
command = new_servers.get(name, {}).get("command")
message = _format_connect_error(result)
with _lock:
_server_connecting.discard(name)
_server_connect_errors[name] = message
logger.warning(
"Failed to connect to MCP server '%s'%s: %s",
name,
f" (command={command})" if command else "",
_format_connect_error(result),
message,
)
else:
with _lock:
_server_connecting.discard(name)
_server_connect_errors.pop(name, None)
# Per-server timeouts are handled inside _discover_and_register_server.
# The outer timeout is generous: 120s total for parallel discovery.
@@ -3700,8 +3751,10 @@ def is_mcp_tool_parallel_safe(tool_name: str) -> bool:
def get_mcp_status() -> List[dict]:
"""Return status of all configured MCP servers for banner display.
Returns a list of dicts with keys: name, transport, tools, connected.
Includes both successfully connected servers and configured-but-failed ones.
Returns a list of dicts with keys: name, transport, tools, connected,
disabled, and status. Includes connected servers, disabled servers,
in-flight connection attempts, recorded failures, and servers that are
configured but have not been started in this process yet.
"""
result: List[dict] = []
@@ -3712,6 +3765,8 @@ def get_mcp_status() -> List[dict]:
with _lock:
active_servers = dict(_servers)
connecting = set(_server_connecting)
connect_errors = dict(_server_connect_errors)
for name, cfg in configured.items():
transport = cfg.get("transport", "http") if "url" in cfg else "stdio"
@@ -3724,11 +3779,12 @@ def get_mcp_status() -> List[dict]:
"tools": len(server._registered_tool_names) if hasattr(server, "_registered_tool_names") else len(server._tools),
"connected": True,
"disabled": False,
"status": "connected",
}
if server._sampling:
entry["sampling"] = dict(server._sampling.metrics)
result.append(entry)
else:
elif not enabled:
# A server with enabled: false is intentionally not connected — it is
# disabled, not failed. Surface that distinction so consumers (banner,
# TUI) can render "disabled" rather than an alarming "failed".
@@ -3737,7 +3793,36 @@ def get_mcp_status() -> List[dict]:
"transport": transport,
"tools": 0,
"connected": False,
"disabled": not enabled,
"disabled": True,
"status": "disabled",
})
elif name in connecting:
result.append({
"name": name,
"transport": transport,
"tools": 0,
"connected": False,
"disabled": False,
"status": "connecting",
})
elif name in connect_errors:
result.append({
"name": name,
"transport": transport,
"tools": 0,
"connected": False,
"disabled": False,
"status": "failed",
"error": connect_errors[name],
})
else:
result.append({
"name": name,
"transport": transport,
"tools": 0,
"connected": False,
"disabled": False,
"status": "configured",
})
return result
+6
View File
@@ -254,6 +254,12 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
<Text color={t.color.text}>
{s.tools} tool{s.tools === 1 ? '' : 's'}
</Text>
) : s.disabled || s.status === 'disabled' ? (
<Text color={t.color.muted}>disabled</Text>
) : s.status === 'connecting' ? (
<Text color={t.color.warn}>connecting</Text>
) : s.status === 'configured' ? (
<Text color={t.color.muted}>configured</Text>
) : (
<Text color={t.color.error}>failed</Text>
)}
+2
View File
@@ -138,6 +138,8 @@ export type SectionVisibility = Partial<Record<SectionName, DetailsMode>>
export interface McpServerStatus {
connected: boolean
disabled?: boolean
status?: 'configured' | 'connecting' | 'connected' | 'disabled' | 'failed'
name: string
tools: number
transport: string
+1 -1
View File
@@ -1180,7 +1180,7 @@ Manage MCP (Model Context Protocol) server configurations and run Hermes as an M
| `catalog` | List Nous-approved MCPs (plain text, scriptable). |
| `install <name>` | Install a catalog entry (e.g. `hermes mcp install n8n`). |
| `serve [-v\|--verbose]` | Run Hermes as an MCP server — expose conversations to other agents. |
| `add <name> [--url URL] [--command CMD] [--args ...] [--auth oauth\|header]` | Add a custom MCP server with automatic tool discovery. |
| `add <name> [--url URL] [--command CMD] [--auth oauth\|header] [--args ...]` | Add a custom MCP server with automatic tool discovery. `--args` passes the remaining argv to the stdio command, so put it last. |
| `remove <name>` (alias: `rm`) | Remove an MCP server from config. |
| `list` (alias: `ls`) | List configured MCP servers. |
| `test <name>` | Test connection to an MCP server. |