feat(agent): add pre_verify hook and verify-on-stop coding guidance

Add a `pre_verify` user/plugin/shell hook fired once per turn when the agent
edited code and is about to finish, after the existing verify-on-stop guard. A
hook can keep the agent going one more turn (run a check, defer it, tidy the
diff) by returning {"action":"continue","message":...} (the Claude-Code Stop
shape {"decision":"block","reason":...} is accepted too). Hooks receive coding,
attempt, final_response, and sorted changed_paths so they can self-scope and
self-throttle; the path is bounded by agent.max_verify_nudges and preserves
message-role alternation.

Hermes still ships its default coding guidance (agent.verify_guidance, on by
default), but it now rides the evidence-based verify-on-stop missing-evidence
nudge instead of a separate default pre_verify continuation, so it costs no
extra model turn of its own. Guidance reuses the shared utils.is_truthy_value
parser rather than a local copy.
This commit is contained in:
Brooklyn Nicholson 2026-06-30 00:59:29 -05:00
parent 14c4a849b7
commit a10113658b
14 changed files with 458 additions and 1 deletions

View file

@ -4810,6 +4810,55 @@ def run_conversation(
agent._verification_stop_nudges)
continue
# User verification-loop gate: when the agent edited code this
# turn, let a registered `pre_verify` hook (plugin/shell) keep it
# going one more turn. The shipped guidance is folded into the
# evidence-based verify-on-stop nudge above, so this path has no
# default continuation cost.
_verify_nudge2 = None
_edited = sorted(getattr(agent, "_turn_file_mutation_paths", set()) or [])
_attempt = getattr(agent, "_pre_verify_nudges", 0)
try:
from agent.verify_hooks import max_verify_nudges
from hermes_cli.plugins import get_pre_verify_continue_message, has_hook
if _edited and has_hook("pre_verify") and _attempt < max_verify_nudges():
# Posture is fixed for the session — resolve once + cache.
coding = getattr(agent, "_resolved_is_coding", None)
if coding is None:
from agent.coding_context import is_coding_context
coding = bool(is_coding_context(platform=getattr(agent, "platform", "") or ""))
agent._resolved_is_coding = coding
_verify_nudge2 = get_pre_verify_continue_message(
session_id=getattr(agent, "session_id", None) or "",
platform=getattr(agent, "platform", "") or "",
model=getattr(agent, "model", "") or "",
coding=coding,
attempt=_attempt,
final_response=final_response,
changed_paths=_edited,
)
except Exception:
logger.debug("pre_verify hook check failed", exc_info=True)
_verify_nudge2 = None
if _verify_nudge2:
agent._pre_verify_nudges = _attempt + 1
final_msg["finish_reason"] = "verify_hook_continue"
# Same alternation contract as verify-on-stop: keep the
# attempted answer in history, follow it with a synthetic
# user nudge, and don't surface the premature answer.
messages.append(final_msg)
messages.append({
"role": "user",
"content": _verify_nudge2,
"_pre_verify_synthetic": True,
})
agent._session_messages = messages
logger.debug("pre_verify nudge issued (attempt %d)",
agent._pre_verify_nudges)
continue
messages.append(final_msg)
_turn_exit_reason = f"text_response(finish_reason={finish_reason})"

View file

@ -588,6 +588,17 @@ def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]:
return {"action": "block", "message": _block_message(data.get("reason"), data.get("message"))}
return None
if event == "pre_verify":
# "continue" (Hermes) / "block" (Claude-Code Stop: block the stop) both
# mean keep going; the message/reason is the follow-up for the model. A
# continue with no message is a no-op — let the turn finish.
action = str(data.get("action") or data.get("decision") or "").strip().lower()
if action in {"continue", "block"}:
message = data.get("message") or data.get("reason")
if isinstance(message, str) and message.strip():
return {"action": "continue", "message": message.strip()}
return None
context = data.get("context")
if isinstance(context, str) and context.strip():
return {"context": context}

View file

@ -443,6 +443,7 @@ def build_turn_context(
agent._turn_failed_file_mutations = {}
agent._turn_file_mutation_paths = set()
agent._verification_stop_nudges = 0
agent._pre_verify_nudges = 0
# Record the execution thread so interrupt()/clear_interrupt() can scope
# the tool-level interrupt signal to THIS agent's thread only.

View file

@ -273,6 +273,15 @@ def build_verify_on_stop_nudge(
if state == "passed":
return None
# Optional shipped coding guidance, only paid when this evidence gate fires.
try:
from agent.verify_hooks import coding_verify_guidance
guidance = coding_verify_guidance()
except Exception:
guidance = None
addendum = f"\n\n{guidance}" if guidance else ""
if verify_commands:
command_instruction = (
"Run the relevant verification command now ("
@ -297,7 +306,8 @@ def build_verify_on_stop_nudge(
f"Verification status: {_status_detail(status)}\n\n"
f"Changed paths:\n{_format_changed_paths(paths)}\n\n"
f"{command_instruction} If verification is not possible, explain the "
"concrete blocker instead of claiming the work is fully verified.]"
"concrete blocker instead of claiming the work is fully verified."
f"{addendum}]"
)

69
agent/verify_hooks.py Normal file
View file

@ -0,0 +1,69 @@
"""Verification-loop helpers for the ``pre_verify`` round-end gate.
When the agent has edited code and is about to verify/finish, the loop fires the
``pre_verify`` hook (user directives resolved by
:func:`hermes_cli.plugins.get_pre_verify_continue_message`). A directive keeps
the agent going one more turn run a check, defer it, tidy the diff instead of
stopping immediately.
The shipped coding guidance lives on the evidence-based verification-stop nudge
(``agent/verification_stop.py``), not as a second default stop gate. That keeps
the default token cost tied to the existing "missing verification evidence"
decision while preserving ``pre_verify`` for user/plugin policy.
"""
from __future__ import annotations
from typing import Any, Optional
from utils import is_truthy_value
DEFAULT_MAX_VERIFY_NUDGES = 3
# Shipped guidance appended to the verification-stop nudge when code lacks fresh
# verification evidence. Wording mirrors the user-facing "clean your work"
# workflow, but does not create its own extra model turn.
CODING_VERIFY_GUIDANCE = (
"[Coding] Before you run tests/linters or call this done: if this is "
"creative UI/visual work, hold off on tests and linters until the user says "
"they like the result or you're about to commit. And before every commit, "
"clean your work: keep it KISS/DRY, match the surrounding code style, and be "
"elitist, shorthand, clever, concise, efficient, and elegant."
)
def max_verify_nudges(config: Optional[dict[str, Any]] = None) -> int:
"""Bound on consecutive ``pre_verify`` continue directives per turn (>= 0)."""
agent_cfg = _agent_cfg(config)
raw = agent_cfg.get("max_verify_nudges")
try:
return max(0, int(raw))
except (TypeError, ValueError):
return DEFAULT_MAX_VERIFY_NUDGES
def coding_verify_guidance(config: Optional[dict[str, Any]] = None) -> Optional[str]:
"""Return the optional guidance appended to verification-stop nudges."""
if not is_truthy_value(_agent_cfg(config).get("verify_guidance", True), default=True):
return None
return CODING_VERIFY_GUIDANCE
def _agent_cfg(config: Optional[dict[str, Any]]) -> dict[str, Any]:
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
return agent_cfg if isinstance(agent_cfg, dict) else {}
__all__ = [
"CODING_VERIFY_GUIDANCE",
"DEFAULT_MAX_VERIFY_NUDGES",
"coding_verify_guidance",
"max_verify_nudges",
]

View file

@ -646,6 +646,17 @@ agent:
# force it on or off; the HERMES_VERIFY_ON_STOP env var (1/0) takes precedence.
# verify_on_stop: auto
# When verify-on-stop finds edited code without fresh verification evidence,
# append guidance for creative UI work (avoid broad tsc/lint/test before visual
# approval) and clean-diff expectations. Set false to keep that nudge terse.
# verify_guidance: true
# A `pre_verify` hook (plugin or shell, see Event Hooks docs) can keep the
# agent going one more turn to verify/clean before finishing. This caps how
# many times one turn may be nudged to continue, so a hook can't trap the loop.
# Default 3.
# max_verify_nudges: 3
# Enable verbose logging
verbose: false

View file

@ -999,6 +999,14 @@ DEFAULT_CONFIG = {
# "on" — force the prompt posture everywhere.
# "off" — disable entirely.
"coding_context": "auto",
# When verify-on-stop finds edited code without fresh verification
# evidence, append guidance for creative UI work (avoid broad
# tsc/lint/test before visual approval) and clean-diff expectations.
# Set false to keep the evidence nudge terse.
"verify_guidance": True,
# Upper bound on consecutive `pre_verify` "continue" nudges in a single
# turn, so a user/plugin hook can never trap the loop.
"max_verify_nudges": 3,
# Verification closure: after the agent edits files in a code workspace,
# do not accept a final answer until fresh verification evidence exists
# or the agent explains why it cannot run checks. The loop is bounded

View file

@ -139,6 +139,15 @@ _DEFAULT_PAYLOADS = {
"model": "gpt-4",
"platform": "cli",
},
"pre_verify": {
"session_id": "test-session",
"platform": "cli",
"model": "gpt-4",
"coding": True,
"attempt": 0,
"final_response": "All done — the change is applied.",
"changed_paths": ["src/app.tsx"],
},
"on_session_start": {"session_id": "test-session"},
"on_session_end": {"session_id": "test-session"},
"on_session_finalize": {"session_id": "test-session"},

View file

@ -136,6 +136,17 @@ VALID_HOOKS: Set[str] = {
"transform_llm_output",
"pre_llm_call",
"post_llm_call",
# Verification-loop gate. Fired once per turn when the agent has edited code
# and is about to verify/finish (after the verify-on-stop guard). A callback
# may keep the agent going — run a check, defer it, tidy the diff — instead
# of stopping by returning:
# {"action": "continue", "message": "<follow-up instruction>"}
# The Claude-Code Stop shape {"decision": "block", "reason": "..."} (block
# the stop == keep going) is accepted too. Anything else lets the turn
# finish. Hermes' shipped guidance lives in the evidence-based
# verification-stop nudge; this hook is for user/plugin policy and is
# bounded by agent.max_verify_nudges.
"pre_verify",
"pre_api_request",
"post_api_request",
"api_request_error",
@ -2029,6 +2040,57 @@ def get_pre_tool_call_block_message(
return None
def get_pre_verify_continue_message(
*,
session_id: str = "",
platform: str = "",
model: str = "",
coding: bool = False,
attempt: int = 0,
final_response: str = "",
changed_paths: Optional[List[str]] = None,
) -> Optional[str]:
"""Check user ``pre_verify`` hooks for a directive to keep the agent going.
Fired once per turn when the agent edited code and is about to verify/finish.
A hook keeps the turn going (run a check, defer it, tidy the diff) by
returning::
{"action": "continue", "message": "<follow-up for the model>"}
The Claude-Code Stop shape ``{"decision": "block", "reason": "..."}`` (block
the stop == keep going) is accepted too. The first directive carrying a
non-empty message wins; any other return lets the turn finish. Mirrors
:func:`get_pre_tool_call_block_message` the call site stays a one-liner.
``coding`` / ``attempt`` let a hook scope itself (``if not coding`` ) and
self-throttle (``if attempt`` ), the same way a ``pre_tool_call`` hook
scopes on ``tool_name``.
"""
hook_results = invoke_hook(
"pre_verify",
session_id=session_id,
platform=platform,
model=model,
coding=coding,
attempt=attempt,
final_response=final_response,
changed_paths=list(changed_paths or []),
)
for result in hook_results:
if not isinstance(result, dict):
continue
action = str(result.get("action") or result.get("decision") or "").strip().lower()
if action not in ("continue", "block"):
continue
message = result.get("message") or result.get("reason")
if isinstance(message, str) and message.strip():
return message.strip()
return None
def _ensure_plugins_discovered(force: bool = False) -> PluginManager:
"""Return the global manager after ensuring plugin discovery has run.

View file

@ -97,6 +97,24 @@ class TestParseResponse:
)
assert r is None
def test_pre_verify_continue_canonical(self):
r = shell_hooks._parse_response(
"pre_verify", '{"action": "continue", "message": "run checks"}',
)
assert r == {"action": "continue", "message": "run checks"}
def test_pre_verify_block_is_continue_claude_style(self):
# Claude-Code Stop hooks: block the stop == keep going; reason → message.
r = shell_hooks._parse_response(
"pre_verify", '{"decision": "block", "reason": "run the formatter"}',
)
assert r == {"action": "continue", "message": "run the formatter"}
def test_pre_verify_without_message_is_noop(self):
# A continue with nothing to tell the model lets the turn finish.
assert shell_hooks._parse_response("pre_verify", '{"action": "continue"}') is None
assert shell_hooks._parse_response("pre_verify", '{"decision": "allow"}') is None
def test_block_action_without_message_uses_default(self):
"""Block is honored even when message/reason is absent."""
r = shell_hooks._parse_response("pre_tool_call", '{"action": "block"}')

View file

@ -215,6 +215,7 @@ def test_nudge_after_unverified_edit_with_known_command(tmp_path, monkeypatch):
assert "fresh passing verification evidence" in nudge
assert "`pnpm run test`" in nudge
assert changed in nudge
assert "creative UI/visual work" in nudge
def test_nudge_includes_failed_output_summary(tmp_path, monkeypatch):
@ -249,6 +250,23 @@ def test_no_suite_nudge_requests_temp_script(tmp_path, monkeypatch):
assert tempfile.gettempdir() in nudge
assert "ad-hoc verification" in nudge
assert "suite green" in nudge
assert "creative UI/visual work" in nudge
def test_verify_guidance_can_be_disabled(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
_node_project(tmp_path)
changed = str(tmp_path / "src" / "app.ts")
from agent import verify_hooks
monkeypatch.setattr(verify_hooks, "coding_verify_guidance", lambda: None)
nudge = build_verify_on_stop_nudge(session_id="s1", changed_paths=[changed])
assert nudge is not None
assert "fresh passing verification evidence" in nudge
assert "creative UI/visual work" not in nudge
def test_ad_hoc_pass_satisfies_no_suite_stop_loop(tmp_path, monkeypatch):

View file

@ -0,0 +1,53 @@
"""Unit tests for the verification-loop policy (agent/verify_hooks.py).
The `pre_verify` user-hook aggregation lives in `hermes_cli.plugins`
(`get_pre_verify_continue_message`) and is tested in
`tests/hermes_cli/test_plugins.py`, alongside `get_pre_tool_call_block_message`.
"""
from __future__ import annotations
from agent import verify_hooks
class TestMaxVerifyNudges:
def test_default_when_unset(self):
assert (
verify_hooks.max_verify_nudges({})
== verify_hooks.DEFAULT_MAX_VERIFY_NUDGES
)
assert (
verify_hooks.max_verify_nudges({"agent": {}})
== verify_hooks.DEFAULT_MAX_VERIFY_NUDGES
)
def test_reads_and_coerces(self):
assert verify_hooks.max_verify_nudges({"agent": {"max_verify_nudges": 5}}) == 5
assert verify_hooks.max_verify_nudges({"agent": {"max_verify_nudges": "2"}}) == 2
assert verify_hooks.max_verify_nudges({"agent": {"max_verify_nudges": -1}}) == 0
def test_bad_value_falls_back(self):
assert (
verify_hooks.max_verify_nudges({"agent": {"max_verify_nudges": "x"}})
== verify_hooks.DEFAULT_MAX_VERIFY_NUDGES
)
class TestCodingVerifyGuidance:
def test_enabled_by_default(self):
assert (
verify_hooks.coding_verify_guidance({})
== verify_hooks.CODING_VERIFY_GUIDANCE
)
assert (
verify_hooks.coding_verify_guidance({"agent": {}})
== verify_hooks.CODING_VERIFY_GUIDANCE
)
def test_reads_truthy_config(self):
cfg = {"agent": {"verify_guidance": "yes"}}
assert verify_hooks.coding_verify_guidance(cfg) == verify_hooks.CODING_VERIFY_GUIDANCE
def test_opt_out_via_config(self):
off = {"agent": {"verify_guidance": False}}
assert verify_hooks.coding_verify_guidance(off) is None

View file

@ -18,6 +18,7 @@ from hermes_cli.plugins import (
get_plugin_command_handler,
get_plugin_commands,
get_pre_tool_call_block_message,
get_pre_verify_continue_message,
has_middleware,
resolve_plugin_command_result,
)
@ -858,6 +859,73 @@ class TestPreToolCallBlocking:
assert get_pre_tool_call_block_message("terminal", {}) == "first blocker"
class TestGetPreVerifyContinueMessage:
"""`pre_verify` directive aggregation — mirrors the pre_tool_call block path."""
def test_continue_canonical(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [{"action": "continue", "message": "run checks"}],
)
assert get_pre_verify_continue_message(session_id="s") == "run checks"
def test_claude_block_means_continue(self, monkeypatch):
# Claude-Code Stop: "block" the stop == keep going; reason → message.
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [{"decision": "block", "reason": "run the formatter"}],
)
assert get_pre_verify_continue_message() == "run the formatter"
def test_first_actionable_directive_wins(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [
"noise", # not a dict
{"action": "continue"}, # no message → skipped
{"action": "continue", "message": "second"},
{"action": "continue", "message": "third"},
],
)
assert get_pre_verify_continue_message() == "second"
def test_message_is_trimmed(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [{"action": "continue", "message": " tidy up "}],
)
assert get_pre_verify_continue_message() == "tidy up"
def test_invalid_returns_ignored(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [
{"action": "allow"}, # wrong action
{"context": "noise"}, # not a directive
{"action": "continue", "message": " "}, # blank message
{"action": "continue", "message": 42}, # message not str
],
)
assert get_pre_verify_continue_message() is None
def test_none_when_no_hooks(self, monkeypatch):
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda hook_name, **kwargs: [])
assert get_pre_verify_continue_message() is None
def test_forwards_scope_signals_to_hooks(self, monkeypatch):
seen = {}
def capture(hook_name, **kwargs):
seen.update(kwargs)
return []
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", capture)
get_pre_verify_continue_message(coding=True, attempt=2, changed_paths=["a.py"])
assert seen["coding"] is True
assert seen["attempt"] == 2
assert seen["changed_paths"] == ["a.py"]
class TestThreadToolWhitelist:
"""Tests for the thread-local tool whitelist used by background review forks."""

View file

@ -382,6 +382,7 @@ def register(ctx):
| [`post_tool_call`](#post_tool_call) | After any tool returns | ignored |
| [`pre_llm_call`](#pre_llm_call) | Once per turn, before the tool-calling loop | `{"context": str}` to prepend context to the user message |
| [`post_llm_call`](#post_llm_call) | Once per turn, after the tool-calling loop | ignored |
| [`pre_verify`](#pre_verify) | Once per turn when the agent edited code, before it verifies/finishes | `{"action": "continue", "message": str}` to keep going |
| [`on_session_start`](#on_session_start) | New session created (first turn only) | ignored |
| [`on_session_end`](#on_session_end) | Session ends | ignored |
| [`on_session_finalize`](#on_session_finalize) | CLI/gateway tears down an active session (flush, save, stats) | ignored |
@ -652,6 +653,71 @@ def register(ctx):
---
### `pre_verify`
Fires **once per turn when the agent edited code**, just before it finishes (after the built-in verify-on-stop guard). This is a user/plugin policy gate: a callback can keep the agent going — run a check, defer it, tidy the diff — instead of letting it stop.
Hermes' shipped verification guidance is not a default `pre_verify` hook. It is appended to the evidence-based verify-on-stop nudge when edited code lacks fresh verification evidence, so it does not create a second default continuation path. Set `agent.verify_guidance: false` to keep that built-in evidence nudge terse.
**Callback signature:**
```python
def my_callback(session_id: str, platform: str, model: str, coding: bool,
attempt: int, final_response: str, changed_paths: list, **kwargs):
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `session_id` | `str` | Unique identifier for the current session |
| `platform` | `str` | Where the session is running (`"cli"`, `"telegram"`, …) |
| `model` | `str` | The model identifier |
| `coding` | `bool` | Whether the turn is in the coding posture (in a code workspace) — scope your hook on this |
| `attempt` | `int` | How many times this turn has already been nudged (0 on the first) — self-throttle on this |
| `final_response` | `str` | The answer the agent is about to deliver |
| `changed_paths` | `list` | Files the agent edited this turn (sorted, always non-empty here) |
Scope a hook to the coding context by checking `coding` and make it one-shot with `attempt` (shell hooks read both from `.extra`), the same way a `pre_tool_call` hook scopes on `tool_name` — so you can register several `pre_verify` hooks, each firing only where it should.
**Fires:** In `agent/conversation_loop.py`, at the point the agent would accept a final answer, immediately after the verify-on-stop check — but only when the agent edited code this turn and at least one `pre_verify` hook is registered.
**Return value — keep the agent going:**
```python
return {"action": "continue", "message": "Run the formatter on your changes, then finish."}
```
The `message` is appended as a synthetic user turn and the loop runs again. The Claude-Code Stop shape (`{"decision": "block", "reason": "..."}`, where blocking the stop means *keep going*) is accepted too. A directive with no message — or any other return — lets the turn finish.
**Bounded:** consecutive continue directives in one turn are capped by `agent.max_verify_nudges` (default 3), so a hook that always says continue can never trap the loop. The attempted answer is kept in history but not surfaced to the user while the agent is being nudged.
**Make it idempotent:** the hook re-fires after each nudge, so gate on `attempt` (`if attempt: return None`) — otherwise it just nudges until the bound is hit.
**Use cases:** defer tests/lints during creative iteration, require green checks for certain paths, block "done" until a changelog entry exists, run a project-specific verification checklist.
**Example — defer checks on creative UI work, scoped + one-shot:**
```python
UI = (".tsx", ".jsx", ".css", ".scss")
def defer_ui_checks(coding, attempt, changed_paths, **kwargs):
if attempt or not coding:
return None # one-shot, coding only
if not all(p.endswith(UI) for p in changed_paths):
return None # only pure-UI edits
return {
"action": "continue",
"message": "This is UI work — don't run tests/lints yet; ask the user to "
"eyeball it first, and clean the diff before any commit.",
}
def register(ctx):
ctx.register_hook("pre_verify", defer_ui_checks)
```
For standing guidance that should shape the built-in missing-evidence nudge, use `agent.verify_guidance`. For broader coding posture rules that don't need to *gate* verification, prefer `agent.coding_instructions` in `config.yaml` — it rides the coding brief and costs no extra turn.
---
### `on_session_start`
Fires **once** when a brand-new session is created. Does **not** fire on session continuation (when the user sends a second message in an existing session).
@ -1284,6 +1350,10 @@ Each time the event fires, Hermes spawns a subprocess for every matching hook (m
// Inject context for pre_llm_call:
{"context": "Today is Friday, 2026-04-17"}
// Keep the agent going at the verify gate (pre_verify); both shapes accepted:
{"action": "continue", "message": "Run the formatter, then finish."}
{"decision": "block", "reason": "Run the formatter, then finish."}
// Silent no-op — any empty / non-matching output is fine:
```