Files
hermes-agent/agent/turn_stop_gates.py
2026-09-24 22:41:10 +05:30

178 lines
7.9 KiB
Python

"""Text-response stop gates for the conversation turn loop.
When the model stops with a text answer, three gates may instead append the answer as an
interim row plus a synthetic user-role nudge and continue the turn: verify-on-stop (#65919),
the ``pre_verify`` plugin hook after code edits, and the kanban worker terminal-tool guard.
Each keeps the candidate answer as a budget-exhaustion fallback
(``pending_verification_response``) and clears ``final_response`` so the finalizer can tell
this gate from error exits (#61631). Nothing here imports ``agent.conversation_loop`` at
module level (cycle).
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from agent.message_metadata import append_message
logger = logging.getLogger("agent.conversation_loop")
@dataclass
class StopGateVerdict:
"""``continue_turn`` True → a nudge was appended; re-enter the turn loop with
``final_response=None`` and the pending-verification fields updated."""
continue_turn: bool
final_response: Any
pending_verification_response: Any
pending_verification_response_previewed: Any
def _verify_on_stop_nudge(agent) -> Optional[str]:
try:
from agent.verification_stop import (
build_verify_on_stop_nudge, verify_on_stop_enabled
)
if verify_on_stop_enabled():
return build_verify_on_stop_nudge(
session_id=getattr(agent, "session_id", None),
changed_paths=getattr(agent, "_turn_file_mutation_paths", set()),
attempts=getattr(agent, "_verification_stop_nudges", 0),
)
except Exception:
logger.debug("verification stop-loop check failed", exc_info=True)
return None
def _pre_verify_nudge(agent, final_response, attempt: int) -> Optional[str]:
"""After code edits a registered ``pre_verify`` hook may keep the agent going one
more turn; no default continuation cost."""
_edited = sorted(getattr(agent, "_turn_file_mutation_paths", set()) or [])
try:
from agent.verify_hooks import max_verify_nudges
from hermes_cli.lifecycle import has_hook
from hermes_cli.plugins import get_pre_verify_continue_message
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
return 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)
return None
def _kanban_stop_nudge(agent, messages) -> Optional[str]:
"""Workers must end with a terminal board tool (kanban_complete / kanban_block /
kanban_request_review / kanban_request_changes); a narrated stop is recorded
as protocol_violation, so nudge once or twice first."""
try:
from agent.kanban_stop import build_kanban_stop_nudge
return build_kanban_stop_nudge(
messages=messages, attempts=getattr(agent, "_kanban_stop_nudges", 0)
)
except Exception:
logger.debug("kanban stop-loop check failed", exc_info=True)
return None
def _append_interim_answer(agent, final_msg, messages, conversation_history, flush_fail_msg: str) -> None:
"""Real content: persist and emit as interim so the user sees the attempted answer;
only the nudge is flagged synthetic (#65919)."""
agent._emit_interim_assistant_message(final_msg)
append_message(messages, final_msg)
try:
agent._flush_messages_to_session_db(messages, conversation_history)
except Exception:
logger.debug(flush_fail_msg, exc_info=True)
def apply_stop_gates(
agent: Any, final_msg: Dict[str, Any], *, final_response: Any, messages: List[Dict[str, Any]],
conversation_history: Any, pending_verification_response: Any,
pending_verification_response_previewed: Any,
) -> StopGateVerdict:
"""Run verify-on-stop → pre_verify hook → kanban stop guard, in that order. Nudges
are user-role rows appended only after the assistant answer row, so role alternation
holds. Hook lookups are imported lazily from their origin modules (tests patch them
there)."""
def _continue(nudge: str, flag: str) -> StopGateVerdict:
"""Append the synthetic nudge row and hand the turn back to the loop."""
append_message(messages, {"role": "user", "content": nudge, flag: True})
agent._session_messages = messages
# Keep the answer only as a budget-exhaustion fallback; clear ``final_response`` so
# the finalizer can tell this gate from error exits. Mark previewed only if the
# candidate is reused (#61631).
return StopGateVerdict(
continue_turn=True, final_response=None,
pending_verification_response=final_response,
# Prefix semantics on purpose: the streamed prefix IS on screen here. The gateway
# interim verdict uses _interim_content_fully_streamed instead (#88954).
pending_verification_response_previewed=agent._interim_content_was_streamed(
final_response or ""
),
)
_verify_nudge = _verify_on_stop_nudge(agent)
if _verify_nudge:
agent._verification_stop_nudges = getattr(agent, "_verification_stop_nudges", 0) + 1
final_msg["finish_reason"] = "verification_required"
_append_interim_answer(
agent, final_msg, messages, conversation_history, "verify-on-stop interim flush failed"
)
verdict = _continue(_verify_nudge, "_verification_stop_synthetic")
# Internal nudge: stay silent on the terminal, debug-log only.
logger.debug("verification stop-loop nudge issued (attempt %d)", agent._verification_stop_nudges)
return verdict
_attempt = getattr(agent, "_pre_verify_nudges", 0)
_verify_nudge2 = _pre_verify_nudge(agent, final_response, _attempt)
if _verify_nudge2:
agent._pre_verify_nudges = _attempt + 1
final_msg["finish_reason"] = "verify_hook_continue"
_append_interim_answer(
agent, final_msg, messages, conversation_history, "pre_verify interim flush failed"
)
verdict = _continue(_verify_nudge2, "_pre_verify_synthetic")
logger.debug("pre_verify nudge issued (attempt %d)", agent._pre_verify_nudges)
return verdict
_kanban_nudge = _kanban_stop_nudge(agent, messages)
if _kanban_nudge:
agent._kanban_stop_nudges = getattr(agent, "_kanban_stop_nudges", 0) + 1
final_msg["finish_reason"] = "kanban_terminal_required"
final_msg["_kanban_stop_synthetic"] = True
append_message(messages, final_msg)
verdict = _continue(_kanban_nudge, "_kanban_stop_synthetic")
logger.info(
"kanban stop-loop nudge issued (attempt %d) task=%s",
agent._kanban_stop_nudges,
os.environ.get("HERMES_KANBAN_TASK", ""),
)
agent._emit_diagnostic_status(
"⚠️ Kanban worker tried to exit without a terminal board call "
"(kanban_complete/kanban_request_review/kanban_block) — nudging to finish"
)
return verdict
return StopGateVerdict(
continue_turn=False, final_response=final_response,
pending_verification_response=pending_verification_response,
pending_verification_response_previewed=pending_verification_response_previewed,
)