Files
hermes-agent/agent/turn_stop_gates.py

208 lines
8.6 KiB
Python

"""Text-response stop gates for the conversation turn loop.
Extracted from ``run_conversation``. 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
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 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)."""
_pending_verification_response = pending_verification_response
_pending_verification_response_previewed = pending_verification_response_previewed
def _verdict(continue_turn: bool) -> StopGateVerdict:
return StopGateVerdict(
continue_turn=continue_turn,
final_response=None if continue_turn else final_response,
pending_verification_response=_pending_verification_response,
pending_verification_response_previewed=_pending_verification_response_previewed,
)
try:
from agent.verification_stop import (
build_verify_on_stop_nudge,
verify_on_stop_enabled,
)
if verify_on_stop_enabled():
_verify_nudge = 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),
)
else:
_verify_nudge = None
except Exception:
logger.debug("verification stop-loop check failed", exc_info=True)
_verify_nudge = None
if _verify_nudge:
agent._verification_stop_nudges = (
getattr(agent, "_verification_stop_nudges", 0) + 1
)
final_msg["finish_reason"] = "verification_required"
# 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("verify-on-stop interim flush failed", exc_info=True)
append_message(messages, {
"role": "user",
"content": _verify_nudge,
"_verification_stop_synthetic": True,
})
agent._session_messages = messages
# Internal nudge: stay silent on the terminal, debug-log only.
logger.debug("verification stop-loop nudge issued (attempt %d)",
agent._verification_stop_nudges)
# 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)
_pending_verification_response = final_response
_pending_verification_response_previewed = (
agent._interim_content_was_streamed(final_response or "")
)
return _verdict(True)
# pre_verify hook gate: after code edits a registered hook may keep the
# agent going one more turn; 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.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
_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"
# 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("pre_verify interim flush failed", exc_info=True)
append_message(messages, {
"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)
_pending_verification_response = final_response
_pending_verification_response_previewed = (
agent._interim_content_was_streamed(final_response or "")
)
return _verdict(True)
# ── Kanban worker terminal-tool stop guard ─────────────
# Workers must end with kanban_complete / kanban_block; a narrated stop
# is recorded as protocol_violation, so nudge once or twice first.
try:
from agent.kanban_stop import build_kanban_stop_nudge
_kanban_nudge = 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)
_kanban_nudge = None
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)
append_message(messages, {
"role": "user",
"content": _kanban_nudge,
"_kanban_stop_synthetic": True,
})
agent._session_messages = messages
logger.info(
"kanban stop-loop nudge issued (attempt %d) task=%s",
agent._kanban_stop_nudges,
os.environ.get("HERMES_KANBAN_TASK", ""),
)
agent._emit_status(
"⚠️ Kanban worker tried to exit without "
"kanban_complete/kanban_block — nudging to finish"
)
# Same finalizer contract as verify-on-stop: clear final_response so
# budget exhaustion doesn't treat the narrated stop as an answer.
_pending_verification_response = final_response
_pending_verification_response_previewed = (
agent._interim_content_was_streamed(final_response or "")
)
return _verdict(True)
return _verdict(False)