mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-13 09:01:54 +00:00
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.
242 lines
9.3 KiB
Python
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)
|