From 9d8763dd26dabd7d81d8d6a57e278532306d0c68 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 10 Jun 2026 02:37:50 -0500 Subject: [PATCH] refactor(agent): make the default coding posture prompt-only; add focus mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toolset collapse was subtractive against explicit user intent: the strippable toolsets (messaging, smart-home, music, computer-use, …) are off-by-default for everyone, so collapsing did nothing for the median user while silently disabling tools that opted-in users deliberately enabled — and image-gen is genuinely useful in frontend/game-dev coding. - auto (default) and on are now prompt-only: operating brief + workspace snapshot, configured toolsets untouched. - focus (new, explicit opt-in; aliases: strict, lean) keeps the previous behavior — collapse to the coding toolset + enabled MCP servers. - RuntimeMode carries config_mode; toolset_selection() returns None unless mode is focus. cli/tui call sites unchanged (None falls through to normal platform resolution). --- agent/coding_context.py | 57 +++++++++++++++++++++++------- hermes_cli/config.py | 16 +++++---- tests/agent/test_coding_context.py | 36 ++++++++++++++++--- 3 files changed, 85 insertions(+), 24 deletions(-) diff --git a/agent/coding_context.py b/agent/coding_context.py index ca0b21c1932..d69f313f364 100644 --- a/agent/coding_context.py +++ b/agent/coding_context.py @@ -13,11 +13,14 @@ is *data* — it declares the toolset to collapse to, the operating brief to inject, and hints for other domains (model routing, memory, subagents). Every domain reads the same resolved object instead of probing git/config itself: - * **Toolset** — ``RuntimeMode.toolset_selection()`` → the ``coding`` toolset - plus the user's enabled MCP servers (``cli.py`` / ``tui_gateway``). Messaging - / TTS / image-gen / smart-home / music / cron / computer-use fall away. * **System prompt** — ``RuntimeMode.system_blocks()`` → the operating brief + a live git/workspace snapshot (``agent/system_prompt.py``). + * **Toolset** — ``RuntimeMode.toolset_selection()`` → the ``coding`` toolset + plus the user's enabled MCP servers (``cli.py`` / ``tui_gateway``). Only + under the opt-in ``focus`` mode: the default posture is prompt-only and + never touches the user's configured toolsets (toolsets like messaging / + smart-home / music are off-by-default anyway, and someone who explicitly + enabled image-gen or Spotify shouldn't lose it for being in a git repo). * **Delegation** — subagents inherit the parent's toolset and run through the same prompt builder, so the coding posture propagates to children for free. * **Model / memory / compression** — declared on the profile @@ -33,9 +36,15 @@ drift mid-session, so the brief tells the model to re-check with ``git`` before acting on the snapshot. A ``/coding`` flip therefore only takes effect next session (deferred), the same contract as ``/skills install`` vs ``--now``. -Activation (config ``agent.coding_context``): ``auto`` (default) turns it on for -an interactive coding surface sitting in a code workspace (git repo or a -recognised project root); ``on`` forces it anywhere; ``off`` disables it. +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. + * ``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. + * ``off`` — disable entirely. """ from __future__ import annotations @@ -159,7 +168,7 @@ def get_profile(name: str) -> ContextProfile: def _coding_mode(config: Optional[dict[str, Any]]) -> str: - """Return the normalized ``agent.coding_context`` mode (auto/on/off).""" + """Return the normalized ``agent.coding_context`` mode (auto/focus/on/off).""" if config is None: try: from hermes_cli.config import load_config @@ -169,6 +178,8 @@ def _coding_mode(config: Optional[dict[str, Any]]) -> str: config = {} raw = ((config or {}).get("agent", {}) or {}).get("coding_context", "auto") mode = str(raw).strip().lower() + if mode in {"focus", "strict", "lean"}: + return "focus" if mode in {"on", "true", "yes", "1", "always"}: return "on" if mode in {"off", "false", "no", "0", "never"}: @@ -214,9 +225,9 @@ def _has_project_marker(cwd: Path) -> bool: def _detect_profile_name(mode: str, platform: str, cwd_str: str) -> str: """Resolve which profile applies. - ``auto``: coding when the surface is interactive AND the cwd is a code - workspace (a git repo or a recognised project root). ``on``: always coding. - ``off``: always general. + ``auto``/``focus``: coding when the surface is interactive AND the cwd is a + code workspace (a git repo or a recognised project root). ``on``: always + coding. ``off``: always general. Detection is intentionally not memoized: it's a handful of ``stat`` calls, and callers resolve the mode once per session anyway. Caching here would @@ -250,6 +261,9 @@ class RuntimeMode: profile: ContextProfile surface: str cwd: Path + # The normalized ``agent.coding_context`` mode this posture was resolved + # under (auto/focus/on/off). Toolset collapse is gated on ``focus``. + config_mode: str = "auto" @property def kind(self) -> str: @@ -262,10 +276,17 @@ class RuntimeMode: def toolset_selection(self, config: Optional[dict[str, Any]] = None) -> Optional[list[str]]: """Toolset list for this posture, or ``None`` to keep the platform default. + Non-``None`` only under the opt-in ``focus`` mode. The default posture + is prompt-only: most strippable toolsets are off-by-default anyway, and + a user who explicitly enabled one (image-gen for frontend/game assets, + messaging for build notifications, …) keeps it while coding. + Callers apply this only when the user hasn't pinned an explicit selection (``--toolsets``, ``HERMES_TUI_TOOLSETS``, …); they never override a pin. Returns the profile's toolset plus enabled MCP servers. """ + if self.config_mode != "focus": + return None if self.profile.toolset is None: return None return [self.profile.toolset, *_enabled_mcp_servers(config)] @@ -295,10 +316,16 @@ def resolve_runtime_mode( object is immutable and safe to cache for the session. """ resolved_cwd = _resolve_cwd(cwd) + mode = _coding_mode(config) name = _detect_profile_name( - _coding_mode(config), (platform or "").strip().lower(), str(resolved_cwd) + mode, (platform or "").strip().lower(), str(resolved_cwd) + ) + return RuntimeMode( + profile=get_profile(name), + surface=platform or "", + cwd=resolved_cwd, + config_mode=mode, ) - return RuntimeMode(profile=get_profile(name), surface=platform or "", cwd=resolved_cwd) # ── Back-compat surface (thin wrappers over RuntimeMode) ──────────────────── @@ -320,7 +347,11 @@ def coding_selection( cwd: Optional[str | Path] = None, config: Optional[dict[str, Any]] = None, ) -> Optional[list[str]]: - """Toolset selection for the coding posture, or ``None`` when it's off.""" + """Toolset selection for the coding posture. + + ``None`` unless the user opted into ``focus`` mode AND the posture is + active — the default coding posture never overrides configured toolsets. + """ return resolve_runtime_mode( platform=platform, cwd=cwd, config=config ).toolset_selection(config) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 4dfb1c97b3b..5cf564523d8 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -864,13 +864,17 @@ DEFAULT_CONFIG = { # env var overrides this (build-time/container mechanism). "environment_hint": "", # Coding posture — on interactive coding surfaces (CLI, TUI, desktop - # app, ACP) Hermes collapses to the coding toolset and adds an - # operating brief + a live git/workspace snapshot. See + # app, ACP) in a code workspace, Hermes adds a coding operating brief + # + a live git/workspace snapshot to the system prompt. See # agent/coding_context.py. - # "auto" (default) — on when the surface is interactive AND cwd is a - # code workspace; messaging platforms unaffected. - # "on" — force it everywhere (incl. non-git dirs). - # "off" — disable entirely (legacy full-toolset behavior). + # "auto" (default) — prompt-only posture when the surface is + # interactive AND cwd is a code workspace. + # Toolsets are never touched; messaging platforms + # unaffected. + # "focus" — auto + collapse the toolset to the lean coding + # set (+ enabled MCP servers). Explicit opt-in. + # "on" — force the prompt posture everywhere. + # "off" — disable entirely. "coding_context": "auto", # Staged inactivity warning: send a warning to the user at this # threshold before escalating to a full timeout. The warning fires diff --git a/tests/agent/test_coding_context.py b/tests/agent/test_coding_context.py index b6ee13930e1..992a089da58 100644 --- a/tests/agent/test_coding_context.py +++ b/tests/agent/test_coding_context.py @@ -52,13 +52,33 @@ class TestIsCodingContext: # ── toolset substitution ──────────────────────────────────────────────────── class TestCodingSelection: - def test_selects_coding_when_active(self, tmp_path): + def test_selects_coding_under_focus(self, tmp_path): _git_init(tmp_path) - cfg = {"agent": {"coding_context": "on"}} + cfg = {"agent": {"coding_context": "focus"}} out = cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg) assert out is not None assert out[0] == cc.CODING_TOOLSET + def test_auto_is_prompt_only(self, tmp_path): + # Default posture must never override the user's configured toolsets — + # off-by-default toolsets are already off, and explicit opt-ins + # (image-gen, spotify, …) survive entering a code workspace. + _git_init(tmp_path) + cfg = {"agent": {"coding_context": "auto"}} + assert cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg) is None + # …while the prompt posture is still active. + assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True + + def test_on_is_prompt_only(self, tmp_path): + cfg = {"agent": {"coding_context": "on"}} + assert cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg) is None + assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True + + def test_focus_requires_workspace(self, tmp_path): + # focus inherits auto's detection gate — bare dir stays general. + cfg = {"agent": {"coding_context": "focus"}} + assert cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg) is None + def test_none_when_inactive(self, tmp_path): cfg = {"agent": {"coding_context": "off"}} assert cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg) is None @@ -151,10 +171,16 @@ class TestRuntimeMode: assert any("coding agent" in b for b in blocks) assert any("Workspace" in b for b in blocks) - def test_toolset_selection_starts_with_coding(self, tmp_path): - mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={"agent": {"coding_context": "on"}}) - sel = mode.toolset_selection() + def test_toolset_selection_gated_on_focus(self, tmp_path): + _git_init(tmp_path) + focus = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={"agent": {"coding_context": "focus"}}) + sel = focus.toolset_selection() assert sel and sel[0] == cc.CODING_TOOLSET + # auto/on resolve the coding profile but stay prompt-only. + for raw in ("auto", "on"): + mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={"agent": {"coding_context": raw}}) + assert mode.is_coding is True + assert mode.toolset_selection() is None # ── profile registry ────────────────────────────────────────────────────────