mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
Make verification closure the default coding behavior after landed file edits while keeping bounded retries and config/env switches for users who need to disable it.
153 lines
5 KiB
Python
153 lines
5 KiB
Python
"""Turn-end verification guard for coding edits.
|
|
|
|
This module is intentionally policy-only. It never runs checks itself; it turns
|
|
the passive verification ledger into a bounded follow-up when the model tries to
|
|
finish immediately after editing code without fresh evidence.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any, Iterable
|
|
|
|
|
|
_MAX_CHANGED_PATHS_IN_NUDGE = 8
|
|
|
|
|
|
def verify_on_stop_enabled(config: dict[str, Any] | None = None) -> bool:
|
|
"""Return whether edit -> verify-before-finish behavior is enabled."""
|
|
env = os.environ.get("HERMES_VERIFY_ON_STOP")
|
|
if env is not None:
|
|
return env.strip().lower() not in {"0", "false", "no", "off"}
|
|
if config is None:
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
|
|
config = load_config()
|
|
except Exception:
|
|
config = {}
|
|
agent_cfg = (config or {}).get("agent") if isinstance(config, dict) else None
|
|
if isinstance(agent_cfg, dict) and "verify_on_stop" in agent_cfg:
|
|
return bool(agent_cfg.get("verify_on_stop"))
|
|
return True
|
|
|
|
|
|
def _candidate_cwds(paths: Iterable[str]) -> list[Path]:
|
|
candidates: list[Path] = []
|
|
seen: set[str] = set()
|
|
for raw in paths:
|
|
if not raw:
|
|
continue
|
|
try:
|
|
path = Path(raw).expanduser()
|
|
candidate = path if path.is_dir() else path.parent
|
|
resolved = str(candidate.resolve())
|
|
except Exception:
|
|
continue
|
|
if resolved not in seen:
|
|
seen.add(resolved)
|
|
candidates.append(Path(resolved))
|
|
return candidates
|
|
|
|
|
|
def _verification_snapshot(
|
|
*,
|
|
session_id: str | None,
|
|
changed_paths: list[str],
|
|
) -> tuple[dict[str, Any], dict[str, Any]] | None:
|
|
"""Return ``(status, facts)`` for the first edited workspace needing proof."""
|
|
try:
|
|
from agent.coding_context import project_facts_for
|
|
from agent.verification_evidence import verification_status
|
|
except Exception:
|
|
return None
|
|
|
|
first_snapshot: tuple[dict[str, Any], dict[str, Any]] | None = None
|
|
for cwd in _candidate_cwds(changed_paths):
|
|
facts = project_facts_for(cwd)
|
|
if not facts:
|
|
continue
|
|
status = verification_status(session_id=session_id, cwd=cwd)
|
|
snapshot = (status, facts)
|
|
if first_snapshot is None:
|
|
first_snapshot = snapshot
|
|
if str(status.get("status") or "unverified") != "passed":
|
|
return snapshot
|
|
return first_snapshot
|
|
|
|
|
|
def _format_changed_paths(paths: list[str]) -> str:
|
|
shown = paths[:_MAX_CHANGED_PATHS_IN_NUDGE]
|
|
lines = [f"- `{path}`" for path in shown]
|
|
remaining = len(paths) - len(shown)
|
|
if remaining > 0:
|
|
lines.append(f"- ... and {remaining} more")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _status_detail(status: dict[str, Any]) -> str:
|
|
state = str(status.get("status") or "unverified")
|
|
evidence = status.get("evidence") if isinstance(status.get("evidence"), dict) else None
|
|
if not evidence:
|
|
return state
|
|
|
|
command = evidence.get("canonical_command") or evidence.get("command")
|
|
summary = str(evidence.get("output_summary") or "").strip()
|
|
parts = [state]
|
|
if command:
|
|
parts.append(f"last command `{command}`")
|
|
if summary:
|
|
max_summary = 1200
|
|
if len(summary) > max_summary:
|
|
summary = summary[:max_summary].rstrip() + "\n... [truncated]"
|
|
parts.append(f"last output:\n{summary}")
|
|
return "\n".join(parts)
|
|
|
|
|
|
def build_verify_on_stop_nudge(
|
|
*,
|
|
session_id: str | None,
|
|
changed_paths: Iterable[str],
|
|
attempts: int = 0,
|
|
max_attempts: int = 2,
|
|
) -> str | None:
|
|
"""Return a synthetic follow-up when edited code lacks fresh verification."""
|
|
paths = sorted({str(p) for p in changed_paths if p})
|
|
if not paths or attempts >= max_attempts:
|
|
return None
|
|
|
|
snapshot = _verification_snapshot(session_id=session_id, changed_paths=paths)
|
|
if snapshot is None:
|
|
return None
|
|
status, facts = snapshot
|
|
|
|
verify_commands = [
|
|
str(cmd).strip()
|
|
for cmd in (facts.get("verifyCommands") or [])
|
|
if str(cmd).strip()
|
|
]
|
|
if not verify_commands:
|
|
return None
|
|
|
|
state = str(status.get("status") or "unverified")
|
|
if state == "passed":
|
|
return None
|
|
|
|
command_hint = ", ".join(f"`{cmd}`" for cmd in verify_commands[:3])
|
|
if len(verify_commands) > 3:
|
|
command_hint += ", ..."
|
|
|
|
return (
|
|
"[System: You edited code in this turn, but the workspace does not have "
|
|
"fresh passing verification evidence yet.\n\n"
|
|
f"Verification status: {_status_detail(status)}\n\n"
|
|
f"Changed paths:\n{_format_changed_paths(paths)}\n\n"
|
|
f"Run the relevant verification command now ({command_hint}), read any "
|
|
"failure, repair the code, and summarize what passed. If verification "
|
|
"is not possible, explain the concrete blocker instead of claiming the "
|
|
"work is fully verified.]"
|
|
)
|
|
|
|
|
|
__all__ = ["build_verify_on_stop_nudge", "verify_on_stop_enabled"]
|