diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 37246d8d84..e002521acc 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -34,6 +34,7 @@ from agent.turn_retry_state import TurnRetryState from agent.turn_usage import record_response_usage from agent.turn_overflow import recover_from_overflow from agent.turn_empty_response import recover_empty_response +from agent.turn_stop_gates import apply_stop_gates from agent.turn_tool_validation import validate_tool_calls from agent.turn_truncation import continue_codex_incomplete, recover_from_truncation from agent.turn_preflight import run_preflight_compression @@ -4169,154 +4170,18 @@ def run_conversation( ): messages.pop() - 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 "") - ) - final_response = None - continue - - # 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 "") - ) - final_response = None - continue - - # ── 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 "") - ) + _sg = apply_stop_gates( + agent, + final_msg, + final_response=final_response, + messages=messages, + conversation_history=conversation_history, + pending_verification_response=_pending_verification_response, + pending_verification_response_previewed=_pending_verification_response_previewed, + ) + _pending_verification_response = _sg.pending_verification_response + _pending_verification_response_previewed = _sg.pending_verification_response_previewed + if _sg.continue_turn: final_response = None continue diff --git a/agent/turn_stop_gates.py b/agent/turn_stop_gates.py new file mode 100644 index 0000000000..7260566d75 --- /dev/null +++ b/agent/turn_stop_gates.py @@ -0,0 +1,207 @@ +"""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)