refactor(agent): make the default coding posture prompt-only; add focus mode

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).
This commit is contained in:
Brooklyn Nicholson 2026-06-10 02:37:50 -05:00
parent 83f792ed9d
commit 9d8763dd26
3 changed files with 85 additions and 24 deletions

View file

@ -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)

View file

@ -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

View file

@ -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 ────────────────────────────────────────────────────────