feat(agent): coding-context posture across CLI/TUI/desktop

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.
This commit is contained in:
Brooklyn Nicholson 2026-06-10 00:07:26 -05:00
parent bf7abc2f73
commit 9b200c3b68
8 changed files with 485 additions and 3 deletions

242
agent/coding_context.py Normal file
View file

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

View file

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

18
cli.py
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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