mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-13 09:01:54 +00:00
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:
parent
bf7abc2f73
commit
9b200c3b68
8 changed files with 485 additions and 3 deletions
242
agent/coding_context.py
Normal file
242
agent/coding_context.py
Normal 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)
|
||||
|
|
@ -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
18
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
120
tests/agent/test_coding_context.py
Normal file
120
tests/agent/test_coding_context.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
23
toolsets.py
23
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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue