Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3645ee221c | ||
|
|
8d2e0127b3 | ||
|
|
e71d746820 | ||
|
|
5508f4bc54 | ||
|
|
b2043cf157 | ||
|
|
dca11b6650 | ||
|
|
ee1a744ace |
+61
-30
@@ -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 (May–Jun 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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 ──────────────────────────────────────────
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user