From 9b200c3b685636d677b63b0ec74e9a0abe38ab43 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 10 Jun 2026 00:07:26 -0500 Subject: [PATCH] feat(agent): coding-context posture across CLI/TUI/desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Hermes runs on an interactive coding surface (CLI, TUI, desktop app, ACP) inside a git repo, it now shifts into a coding posture: - Tool restriction: the toolset collapses to a new `coding` set (files, terminal, search, web docs, skills, todo, delegate, vision, browser) plus the user's enabled MCP servers. Messaging / TTS / image-gen / smart-home / music / cron / computer-use fall away. - Operating brief: a Cursor-style system block (gather context before editing, focused diffs, verify, never fabricate, git is the user's). - Live workspace snapshot: git root, branch + upstream (ahead/behind), worktree, dirty/staged counts, recent commits — built once per session (cache-safe; never re-probed per turn). Activation via `agent.coding_context` (auto|on|off, default auto). `auto` fires only on interactive surfaces in a git repo; messaging platforms are never affected. A `--toolsets` flag or `HERMES_TUI_TOOLSETS` pin always wins. --- agent/coding_context.py | 242 +++++++++++++++++++++++++++++ agent/system_prompt.py | 22 +++ cli.py | 18 ++- hermes_cli/config.py | 9 ++ tests/agent/test_coding_context.py | 120 ++++++++++++++ tests/agent/test_system_prompt.py | 40 +++++ toolsets.py | 23 +++ tui_gateway/server.py | 14 ++ 8 files changed, 485 insertions(+), 3 deletions(-) create mode 100644 agent/coding_context.py create mode 100644 tests/agent/test_coding_context.py diff --git a/agent/coding_context.py b/agent/coding_context.py new file mode 100644 index 00000000000..9d2d7a9c8b5 --- /dev/null +++ b/agent/coding_context.py @@ -0,0 +1,242 @@ +"""Coding-context awareness — base Hermes, every interactive surface. + +When the user runs Hermes inside a code workspace (CLI, TUI, desktop app, or +an editor over ACP), Hermes shifts into a coding posture: + + 1. **Tool restriction** — the toolset collapses to the coding-relevant set + (file, terminal, search, web docs, skills, todo, delegate, vision, + browser). Messaging / TTS / image-gen / smart-home / music / cron / + computer-use fall away — noise the model never needs while pairing on + code. + 2. **Operating brief** — a Cursor-style system block: gather context before + editing, make focused diffs, verify, never fabricate. + 3. **Live workspace snapshot** — git root, branch + upstream (ahead/behind), + worktree, dirty/staged counts, and recent commits. + +The snapshot is built ONCE per session at prompt-build time and baked into the +stable system prompt — never re-probed per turn (that would shatter the prompt +cache). Branch and dirty state drift mid-session, so the brief tells the model +to re-check with ``git`` before acting on them. + +Activation (config ``agent.coding_context``): ``auto`` (default) turns it on +for interactive coding surfaces sitting in a git repo; ``on`` forces it +anywhere; ``off`` disables it. +""" + +from __future__ import annotations + +import logging +import os +import subprocess +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger("hermes.coding_context") + +CODING_TOOLSET = "coding" + +# Surfaces where a coding posture makes sense under ``auto``. Messaging +# platforms (telegram, discord, slack, …) are intentionally absent — a chat +# bot in a group is not pair-programming. +INTERACTIVE_CODING_PLATFORMS = {"cli", "tui", "acp", "desktop", ""} + +_GIT_TIMEOUT = 2.5 + + +# Cursor-style operating brief. Tool names referenced here (read_file, +# search_files, patch, terminal, todo) are in the coding toolset and in +# _HERMES_CORE_TOOLS, so they're present on every surface this fires on. +CODING_AGENT_GUIDANCE = ( + "You are a coding agent pairing with the user inside their codebase. " + "Operate like a careful senior engineer:\n" + "- Understand before you change: read the relevant files (`read_file`) " + "and locate code with `search_files` rather than guessing. Never invent " + "files, symbols, or APIs — if you haven't seen it, go look.\n" + "- Make focused edits with `patch`/`write_file`; match the project's " + "existing style and conventions (AGENTS.md / .cursorrules already in " + "context win over your defaults). Touch only what the task needs.\n" + "- Use `terminal` for git, builds, tests, and inspection; verify your " + "work (run the relevant tests/linter/build) before claiming it's done.\n" + "- Track multi-step work with `todo`. Reference code as `path:line` " + "rather than pasting whole files.\n" + "- Git is the user's, not yours: don't commit, push, or alter history " + "unless asked. The Workspace block below is a snapshot from session " + "start — re-run `git status`/`git branch` before relying on it.\n" + "- Be concise. Lead with the change or answer, not a preamble." +) + + +def _coding_mode(config: Optional[dict[str, Any]]) -> str: + """Return the normalized ``agent.coding_context`` mode (auto/on/off).""" + if config is None: + try: + from hermes_cli.config import load_config + + config = load_config() + except Exception: + config = {} + raw = ((config or {}).get("agent", {}) or {}).get("coding_context", "auto") + mode = str(raw).strip().lower() + if mode in {"on", "true", "yes", "1", "always"}: + return "on" + if mode in {"off", "false", "no", "0", "never"}: + return "off" + return "auto" + + +def _resolve_cwd(cwd: Optional[str | Path]) -> Path: + if cwd: + return Path(cwd).expanduser() + try: + from agent.runtime_cwd import resolve_agent_cwd + + return resolve_agent_cwd() + except Exception: + return Path(os.getcwd()) + + +def _git_root(cwd: Path) -> Optional[Path]: + current = cwd.resolve() + for parent in [current, *current.parents]: + if (parent / ".git").exists(): + return parent + return None + + +def is_coding_context( + *, + platform: Optional[str] = None, + cwd: Optional[str | Path] = None, + config: Optional[dict[str, Any]] = None, +) -> bool: + """Whether Hermes should operate in its coding posture right now. + + ``auto`` (default): true for an interactive coding surface sitting in a + git repo. ``on``: always true. ``off``: always false. + """ + mode = _coding_mode(config) + if mode == "off": + return False + if mode == "on": + return True + if platform is not None and platform.strip().lower() not in INTERACTIVE_CODING_PLATFORMS: + return False + return _git_root(_resolve_cwd(cwd)) is not None + + +def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]: + """Names of MCP servers the user has enabled — kept in the coding posture. + + MCP servers (figma, browser, tophat, …) are explicitly configured and part + of the coding workflow, not noise to strip. + """ + try: + from hermes_cli.config import read_raw_config + from hermes_cli.tools_config import _parse_enabled_flag + + servers = read_raw_config().get("mcp_servers") or {} + return [ + str(name) + for name, cfg in servers.items() + if isinstance(cfg, dict) + and _parse_enabled_flag(cfg.get("enabled", True), default=True) + ] + except Exception: + return [] + + +def coding_selection( + *, + platform: Optional[str] = None, + cwd: Optional[str | Path] = None, + config: Optional[dict[str, Any]] = None, +) -> Optional[list[str]]: + """The toolset selection for the coding posture, or ``None`` when it's off. + + Callers apply this only when the user hasn't pinned an explicit selection + (a ``--toolsets`` flag, ``HERMES_TUI_TOOLSETS``, …); they never override + a pin. Returns the coding toolset plus the user's enabled MCP servers. + """ + if not is_coding_context(platform=platform, cwd=cwd, config=config): + return None + return [CODING_TOOLSET, *_enabled_mcp_servers(config)] + + +def _git(cwd: Path, *args: str) -> str: + try: + out = subprocess.run( + ["git", "-C", str(cwd), *args], + capture_output=True, + text=True, + timeout=_GIT_TIMEOUT, + ) + except (OSError, subprocess.SubprocessError): + return "" + return out.stdout.strip() if out.returncode == 0 else "" + + +def _parse_status(porcelain: str) -> tuple[dict[str, str], dict[str, int]]: + """Parse ``git status --porcelain=2 --branch`` into branch + counts.""" + branch: dict[str, str] = {} + counts = {"staged": 0, "modified": 0, "untracked": 0, "conflicts": 0} + for line in porcelain.splitlines(): + if line.startswith("# branch.head"): + branch["head"] = line.split(maxsplit=2)[-1] + elif line.startswith("# branch.upstream"): + branch["upstream"] = line.split(maxsplit=2)[-1] + elif line.startswith("# branch.ab"): + parts = line.split() + branch["ahead"], branch["behind"] = parts[2].lstrip("+"), parts[3].lstrip("-") + elif line.startswith(("1 ", "2 ")): + xy = line.split(maxsplit=2)[1] + if xy[0] != ".": + counts["staged"] += 1 + if xy[1] != ".": + counts["modified"] += 1 + elif line.startswith("u "): + counts["conflicts"] += 1 + elif line.startswith("? "): + counts["untracked"] += 1 + return branch, counts + + +def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str: + """Live git/workspace snapshot for the system prompt (empty if not a repo).""" + root = _git_root(_resolve_cwd(cwd)) + if root is None: + return "" + + lines = ["Workspace (snapshot at session start — re-check with `git` before acting on it):"] + lines.append(f"- Root: {root}") + + branch, counts = _parse_status(_git(root, "status", "--porcelain=2", "--branch")) + head = branch.get("head", "") + if head and head != "(detached)": + line = f"- Branch: {head}" + if branch.get("upstream"): + line += f" \u2192 {branch['upstream']}" + ahead, behind = branch.get("ahead", "0"), branch.get("behind", "0") + if ahead != "0" or behind != "0": + line += f" (ahead {ahead}, behind {behind})" + lines.append(line) + elif head == "(detached)": + lines.append("- Branch: (detached HEAD)") + + # Linked worktree: the per-worktree git dir differs from the shared common dir. + git_dir, common_dir = _git(root, "rev-parse", "--git-dir"), _git(root, "rev-parse", "--git-common-dir") + if git_dir and common_dir and Path(git_dir).resolve() != Path(common_dir).resolve(): + main_tree = Path(common_dir).resolve().parent + lines.append(f"- Worktree: linked (primary tree at {main_tree})") + + dirty = [f"{n} {label}" for label, n in ( + ("staged", counts["staged"]), ("modified", counts["modified"]), + ("untracked", counts["untracked"]), ("conflicts", counts["conflicts"]), + ) if n] + lines.append(f"- Status: {', '.join(dirty) if dirty else 'clean'}") + + recent = _git(root, "log", "-3", "--pretty=%h %s") + if recent: + lines.append("- Recent commits:") + lines.extend(f" {c}" for c in recent.splitlines()) + + return "\n".join(lines) diff --git a/agent/system_prompt.py b/agent/system_prompt.py index 4038716df48..7709a76e9f4 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -221,6 +221,28 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) if _env_hints: stable_parts.append(_env_hints) + # Coding posture (base Hermes, any interactive coding surface in a code + # workspace — see agent/coding_context.py). The operating brief + the live + # git/workspace snapshot are built once here and cached for the session; + # the snapshot is never re-probed per turn (that would break the prompt + # cache), so the brief tells the model to re-check git before relying on it. + if agent.valid_tool_names: + try: + from agent.coding_context import ( + CODING_AGENT_GUIDANCE, + build_coding_workspace_block, + is_coding_context, + ) + + if is_coding_context(platform=agent.platform, cwd=resolve_context_cwd()): + stable_parts.append(CODING_AGENT_GUIDANCE) + _workspace = build_coding_workspace_block(resolve_context_cwd()) + if _workspace: + stable_parts.append(_workspace) + except Exception: + # Coding-context probing must never block prompt build. + pass + # Local Python toolchain probe — names python/pip/uv/PEP-668 state when # something is non-default so the model can pick the right install # strategy without discovering by failure. Emits a single line; emits diff --git a/cli.py b/cli.py index 3b555a288fa..cf3187c793a 100644 --- a/cli.py +++ b/cli.py @@ -13264,9 +13264,21 @@ def main( else: toolsets_list.append(str(t)) else: - # Use the shared resolver so MCP servers are included at runtime - from hermes_cli.tools_config import _get_platform_tools - toolsets_list = sorted(_get_platform_tools(CLI_CONFIG, "cli")) + # Coding posture (base Hermes): with no explicit --toolsets, collapse + # to the coding toolset (+ enabled MCP servers) when sitting in a code + # workspace. See agent/coding_context.py. + _coding = None + try: + from agent.coding_context import coding_selection + _coding = coding_selection(platform="cli", config=CLI_CONFIG) + except Exception: + _coding = None + if _coding is not None: + toolsets_list = _coding + else: + # Use the shared resolver so MCP servers are included at runtime + from hermes_cli.tools_config import _get_platform_tools + toolsets_list = sorted(_get_platform_tools(CLI_CONFIG, "cli")) parsed_skills = _parse_skills_argument(skills) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 750e7c91fdf..194660aeb75 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -863,6 +863,15 @@ DEFAULT_CONFIG = { # identity slot (SOUL.md). Empty by default. The HERMES_ENVIRONMENT_HINT # 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 a + # Cursor-style operating brief + a live git/workspace snapshot. See + # agent/coding_context.py. + # "auto" (default) — on when the surface is interactive AND cwd is a + # git repo; messaging platforms are never affected. + # "on" — force it everywhere (incl. non-git dirs). + # "off" — disable entirely (legacy full-toolset behavior). + "coding_context": "auto", # Staged inactivity warning: send a warning to the user at this # threshold before escalating to a full timeout. The warning fires # once per run and does not interrupt the agent. 0 = disable warning. diff --git a/tests/agent/test_coding_context.py b/tests/agent/test_coding_context.py new file mode 100644 index 00000000000..0667424c8d8 --- /dev/null +++ b/tests/agent/test_coding_context.py @@ -0,0 +1,120 @@ +"""Tests for agent.coding_context — resolver, toolset substitution, git probe.""" + +import subprocess + +import pytest + +from agent import coding_context as cc + + +def _git_init(path): + env = { + "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t", + } + for args in ( + ["init", "-q", "-b", "main"], + ["commit", "-q", "--allow-empty", "-m", "init commit"], + ): + subprocess.run(["git", "-C", str(path), *args], check=True, env={**env, "HOME": str(path)}) + + +# ── resolver ────────────────────────────────────────────────────────────── + +class TestIsCodingContext: + def test_off_never_activates(self, tmp_path): + _git_init(tmp_path) + cfg = {"agent": {"coding_context": "off"}} + assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is False + + def test_on_forces_even_without_git(self, tmp_path): + cfg = {"agent": {"coding_context": "on"}} + assert cc.is_coding_context(platform="telegram", cwd=tmp_path, config=cfg) is True + + def test_auto_requires_git_repo(self, tmp_path): + cfg = {"agent": {"coding_context": "auto"}} + assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is False + _git_init(tmp_path) + assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True + + def test_auto_skips_messaging_surfaces(self, tmp_path): + _git_init(tmp_path) + cfg = {"agent": {"coding_context": "auto"}} + assert cc.is_coding_context(platform="discord", cwd=tmp_path, config=cfg) is False + assert cc.is_coding_context(platform="tui", cwd=tmp_path, config=cfg) is True + + def test_default_mode_is_auto(self, tmp_path): + # Unknown/missing value normalizes to auto. + _git_init(tmp_path) + assert cc.is_coding_context(platform="cli", cwd=tmp_path, config={}) is True + + +# ── toolset substitution ──────────────────────────────────────────────────── + +class TestCodingSelection: + def test_selects_coding_when_active(self, tmp_path): + _git_init(tmp_path) + cfg = {"agent": {"coding_context": "on"}} + out = cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg) + assert out[0] == cc.CODING_TOOLSET + + 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 + + def test_coding_toolset_is_registered(self): + from toolsets import resolve_toolset + + tools = resolve_toolset(cc.CODING_TOOLSET) + # Coding essentials present… + for t in ("read_file", "write_file", "patch", "search_files", "terminal", "todo"): + assert t in tools + # …and the noise is gone. + for t in ("send_message", "text_to_speech", "image_generate", "computer_use"): + assert t not in tools + + +# ── git/workspace probe ───────────────────────────────────────────────────── + +class TestWorkspaceBlock: + def test_empty_outside_repo(self, tmp_path): + assert cc.build_coding_workspace_block(tmp_path) == "" + + def test_reports_branch_and_clean_status(self, tmp_path): + _git_init(tmp_path) + block = cc.build_coding_workspace_block(tmp_path) + assert "Workspace" in block + assert f"Root: {tmp_path.resolve()}" in block or "Root:" in block + assert "Branch: main" in block + assert "Status: clean" in block + assert "init commit" in block + + def test_reports_dirty_counts(self, tmp_path): + _git_init(tmp_path) + (tmp_path / "untracked.txt").write_text("hi") + block = cc.build_coding_workspace_block(tmp_path) + assert "untracked" in block + assert "clean" not in block.split("Status:")[1].splitlines()[0] + + +# ── prompt assembly integration ───────────────────────────────────────────── + +class TestStatusParsing: + def test_parse_status_counts_and_branch(self): + porcelain = ( + "# branch.head feature\n" + "# branch.upstream origin/feature\n" + "# branch.ab +2 -1\n" + "1 M. N... 100644 100644 100644 aaa bbb staged.py\n" + "1 .M N... 100644 100644 100644 ccc ddd modified.py\n" + "? new.py\n" + "u UU N... 1 2 3 abc def conflict.py\n" + ) + branch, counts = cc._parse_status(porcelain) + assert branch["head"] == "feature" + assert branch["upstream"] == "origin/feature" + assert branch["ahead"] == "2" and branch["behind"] == "1" + assert counts["staged"] == 1 + assert counts["modified"] == 1 + assert counts["untracked"] == 1 + assert counts["conflicts"] == 1 diff --git a/tests/agent/test_system_prompt.py b/tests/agent/test_system_prompt.py index 75bf28b54d8..2b8310aed2e 100644 --- a/tests/agent/test_system_prompt.py +++ b/tests/agent/test_system_prompt.py @@ -55,3 +55,43 @@ class TestContextFileCwd: def test_configured_dir_when_terminal_cwd_set(self, monkeypatch, tmp_path): monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) assert _captured_context_cwd(_make_agent()) == tmp_path + + +def _stable_prompt(agent): + with ( + patch("run_agent.load_soul_md", return_value=""), + patch("run_agent.build_nous_subscription_prompt", return_value=""), + patch("run_agent.build_environment_hints", return_value=""), + patch("run_agent.build_context_files_prompt", return_value=""), + ): + return build_system_prompt_parts(agent)["stable"] + + +class TestCodingContextBlock: + def test_injected_when_active(self, monkeypatch, tmp_path): + import subprocess + + subprocess.run(["git", "-C", str(tmp_path), "init", "-q"], check=True) + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + agent = _make_agent(valid_tool_names=["read_file"], platform="cli") + stable = _stable_prompt(agent) + assert "coding agent" in stable + assert "Workspace" in stable + + def test_absent_when_off(self, monkeypatch, tmp_path): + import subprocess + + subprocess.run(["git", "-C", str(tmp_path), "init", "-q"], check=True) + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + agent = _make_agent(valid_tool_names=["read_file"], platform="cli") + with patch("agent.coding_context.is_coding_context", return_value=False): + stable = _stable_prompt(agent) + assert "coding agent" not in stable + + def test_absent_without_tools(self, monkeypatch, tmp_path): + import subprocess + + subprocess.run(["git", "-C", str(tmp_path), "init", "-q"], check=True) + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + agent = _make_agent(valid_tool_names=[], platform="cli") + assert "coding agent" not in _stable_prompt(agent) diff --git a/toolsets.py b/toolsets.py index 901b072f46c..41c035b23f2 100644 --- a/toolsets.py +++ b/toolsets.py @@ -339,6 +339,29 @@ TOOLSETS = { "tools": [], "includes": ["web", "vision", "image_gen"] }, + + # Coding posture (base Hermes — CLI/TUI/desktop/ACP). Auto-selected in a + # code workspace; see agent/coding_context.py. Keeps everything you reach + # for while pairing on code and drops the rest (messaging, tts, image_gen, + # spotify, home-assistant, cron, computer-use). + "coding": { + "description": "Coding-focused toolset: files, terminal, search, web docs, skills, todo, delegate, vision, browser", + "tools": [ + "web_search", "web_extract", + "terminal", "process", "read_terminal", + "read_file", "write_file", "patch", "search_files", + "vision_analyze", + "skills_list", "skill_view", "skill_manage", + "browser_navigate", "browser_snapshot", "browser_click", + "browser_type", "browser_scroll", "browser_back", + "browser_press", "browser_get_images", + "browser_vision", "browser_console", "browser_cdp", "browser_dialog", + "todo", "memory", + "session_search", "clarify", + "execute_code", "delegate_task", + ], + "includes": [] + }, # ========================================================================== # Full Hermes toolsets (CLI + messaging platforms) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 12bfd502fdb..040ffe34594 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1526,6 +1526,20 @@ def _load_enabled_toolsets() -> list[str] | None: cfg = None fallback_notice = None + # Coding posture (base Hermes): with no explicit pin, collapse to the + # coding toolset (+ enabled MCP servers) when sitting in a code workspace. + # The desktop app and `hermes --tui` both land here. See + # agent/coding_context.py. + if not explicit: + try: + from agent.coding_context import coding_selection + + selection = coding_selection(platform="tui") + if selection is not None: + return selection + except Exception: + pass + try: from toolsets import validate_toolset except Exception: