From a10113658b6033c5902bbe873bfe2e1f05815339 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 00:59:29 -0500 Subject: [PATCH] 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. --- agent/conversation_loop.py | 49 ++++++++++++++++ agent/shell_hooks.py | 11 ++++ agent/turn_context.py | 1 + agent/verification_stop.py | 12 +++- agent/verify_hooks.py | 69 ++++++++++++++++++++++ cli-config.yaml.example | 11 ++++ hermes_cli/config.py | 8 +++ hermes_cli/hooks.py | 9 +++ hermes_cli/plugins.py | 62 ++++++++++++++++++++ tests/agent/test_shell_hooks.py | 18 ++++++ tests/agent/test_verification_stop.py | 18 ++++++ tests/agent/test_verify_hooks.py | 53 +++++++++++++++++ tests/hermes_cli/test_plugins.py | 68 ++++++++++++++++++++++ website/docs/user-guide/features/hooks.md | 70 +++++++++++++++++++++++ 14 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 agent/verify_hooks.py create mode 100644 tests/agent/test_verify_hooks.py diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 10825cfd683..b3cd4952f29 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -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})" diff --git a/agent/shell_hooks.py b/agent/shell_hooks.py index a48bab42bb8..3f155f20465 100644 --- a/agent/shell_hooks.py +++ b/agent/shell_hooks.py @@ -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} diff --git a/agent/turn_context.py b/agent/turn_context.py index 189771511b6..f53a89a9497 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -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. diff --git a/agent/verification_stop.py b/agent/verification_stop.py index 8824267f694..ea5cb7a6117 100644 --- a/agent/verification_stop.py +++ b/agent/verification_stop.py @@ -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}]" ) diff --git a/agent/verify_hooks.py b/agent/verify_hooks.py new file mode 100644 index 00000000000..e051080202c --- /dev/null +++ b/agent/verify_hooks.py @@ -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", +] diff --git a/cli-config.yaml.example b/cli-config.yaml.example index e517b5f1aee..d8f8e6d48a2 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -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 diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 1c2664e9f76..f422af82529 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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 diff --git a/hermes_cli/hooks.py b/hermes_cli/hooks.py index 9bbec9997fe..d3f86bd00e8 100644 --- a/hermes_cli/hooks.py +++ b/hermes_cli/hooks.py @@ -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"}, diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index d343b077a7a..5d9b9949c67 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -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": ""} + # 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": ""} + + 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. diff --git a/tests/agent/test_shell_hooks.py b/tests/agent/test_shell_hooks.py index ce060f2f3c7..5efd2f93bbe 100644 --- a/tests/agent/test_shell_hooks.py +++ b/tests/agent/test_shell_hooks.py @@ -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"}') diff --git a/tests/agent/test_verification_stop.py b/tests/agent/test_verification_stop.py index 2f47e84bc0f..a695469908d 100644 --- a/tests/agent/test_verification_stop.py +++ b/tests/agent/test_verification_stop.py @@ -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): diff --git a/tests/agent/test_verify_hooks.py b/tests/agent/test_verify_hooks.py new file mode 100644 index 00000000000..8922a931085 --- /dev/null +++ b/tests/agent/test_verify_hooks.py @@ -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 diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index e84dda7a1f2..e200b8b95bf 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -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.""" diff --git a/website/docs/user-guide/features/hooks.md b/website/docs/user-guide/features/hooks.md index d354e4f8f9c..4a2fc068606 100644 --- a/website/docs/user-guide/features/hooks.md +++ b/website/docs/user-guide/features/hooks.md @@ -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: ```