hermes-agent/agent/coding_context.py
Brooklyn Nicholson 9b200c3b68 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.
2026-06-10 00:07:26 -05:00

242 lines
9.3 KiB
Python

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