Files
hermes-agent/agent/turn_finalizer.py
kshitijk4poor 429f319d45 refactor(agent): one builder for the interrupted_during_api_call exit reason
The summary-interrupt path added a second copy of the string the loop's
interrupt path builds; turn_explainers matches its prefix, so both now
share interrupted_during_api_call_reason().
2026-09-27 02:02:41 +05:30

784 lines
41 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Post-loop turn finalization for ``run_conversation``.
Budget summary, trajectory save, persist, diagnostics, response transforms, result
assembly, steer drain, memory/skill review. Synchronous, single return. ``logger`` is
imported lazily from ``agent.conversation_loop`` (no cycle, same logger name)."""
from __future__ import annotations
import logging
import os
import time
from contextlib import suppress
from typing import Any, Callable, List, Optional, Tuple
from agent.codex_responses_adapter import _summarize_user_message_for_log
from agent.delegation_context import is_dispatcher_owned_worker_context
from agent.interrupt_control import interrupted_during_api_call_reason
from agent.turn_failure_copy import exit_reason_failure, stamp_failure
from agent.context_compressor import _DB_PERSISTED_MARKER
from agent.message_content import flatten_message_text
from agent.message_metadata import append_message, stamp_message_timestamp
from agent.message_sanitization import _sanitize_surrogates
from agent.served_model import result_model_fields
# Verification-continuation nudges (verify-on-stop / pre_verify) must be stripped from
# returned/live history to avoid role-alternation breaks; the assistant response is
# real content and is not flagged. (#65919)
_VERIFICATION_CONTINUATION_FLAGS = ("_verification_stop_synthetic", "_pre_verify_synthetic")
_SENTENCE_END = {".", "!", "?", "。", "!", "?", "`", ")"}
# ``result[key] = agent.session_<key>`` for the per-session usage/cost counters.
_SESSION_TOKEN_KEYS = (
"input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens",
"reasoning_tokens", "prompt_tokens", "completion_tokens", "total_tokens",
)
_SESSION_COST_KEYS = ("estimated_cost_usd", "cost_status", "cost_source")
def _assistant_row_missing_visible_text(msg: dict) -> bool:
"""True when an assistant row has no visible text (blank final or tool-only)."""
if not isinstance(msg, dict) or msg.get("role") != "assistant":
return False
return not flatten_message_text(msg.get("content")).strip()
def _record_kanban_budget_exhausted(
kanban_task: str, api_call_count: int, max_iterations: int, logger: logging.Logger
) -> None:
"""Record a terminal ``timed_out`` outcome for a kanban worker out of budget.
Routed via ``_record_task_failure`` (not ``kanban_block``) so it counts toward the
consecutive-failure circuit breaker. Idempotent via the ``_end_run`` CAS
(``WHERE ended_at IS NULL``), so safe from multiple exit paths.
This is a bounded fallback (#87096): the CAS invariant in ``_end_run`` (``WHERE ended_at IS NULL``)
guarantees idempotence — if another path already closed the run this is a no-op — so it is safe to call
from multiple exit paths.
"""
try:
from hermes_cli import kanban_db as _kb
from hermes_cli import kanban_db_connect as _kbc
from hermes_cli import kanban_db_dispatch as _kbd
_conn = _kbc.connect()
try:
_kbd._record_task_failure(
_conn,
kanban_task,
error=(
f"Iteration budget exhausted ({api_call_count}/{max_iterations}) — "
"task could not complete within the allowed iterations"
),
outcome="timed_out",
release_claim=True,
end_run=True,
event_payload_extra={"budget_used": api_call_count, "budget_max": max_iterations},
)
finally:
with suppress(Exception):
_conn.close()
except Exception:
logger.warning(
"Failed to record budget-exhausted failure for task %s", kanban_task, exc_info=True
)
def _drop_verification_continuation_scaffolding(messages) -> None:
"""Remove verification-continuation nudges in place; only the synthetic nudges carry
these flags, so the real attempted final answer persisted to state.db survives."""
messages[:] = [
m for m in messages
if not (isinstance(m, dict) and any(m.get(f) for f in _VERIFICATION_CONTINUATION_FLAGS))
]
def _clone_background_review_messages(messages):
"""Copy the review input without aliasing the live transcript."""
# Lazy: conversation_loop imports this module (cycle).
from agent.conversation_loop import _clone_message_for_send
return [_clone_message_for_send(message) for message in messages]
def _invoke_hook_safely(name: str, logger: logging.Logger, **kwargs) -> list:
"""Fire a lifecycle plugin hook; a failing hook is logged, never fatal."""
try:
from hermes_cli.lifecycle import invoke_hook
return invoke_hook(name, **kwargs)
except Exception as exc:
logger.warning("%s hook failed: %s", name, exc)
return []
def _guarded_cleanup(label: str, fn: Callable[[], Any], errors: List[str], logger) -> None:
"""Post-loop cleanup must never lose the response: each step is guarded
independently and errors surface via ``cleanup_errors`` (#8049)."""
try:
fn()
except Exception as err:
errors.append(f"{label}: {err}")
logger.error("finalize_turn: _%s failed: %s", label, err, exc_info=True)
def _resolve_budget_fallback(
agent, *, final_response, api_call_count, interrupted, failed, messages, _turn_exit_reason,
_pending_verification_response, _pending_verification_response_previewed, logger,
) -> Tuple[Any, Any, bool, Any]:
"""Iteration-budget exhaustion. Returns ``(final_response, _turn_exit_reason,
preserved_verification_fallback, interrupted)``."""
budget_exhausted = (
api_call_count >= agent.max_iterations or agent.iteration_budget.remaining <= 0
)
preserved_verification_fallback = False
if (
final_response is None and budget_exhausted and not interrupted and not failed
and str(_turn_exit_reason) in {"unknown", "budget_exhausted"}
):
_turn_exit_reason = f"max_iterations_reached({api_call_count}/{agent.max_iterations})"
if _pending_verification_response:
# A verification gate withheld a composed answer, then the budget ran out:
# preserve it rather than make another fallible call. The explicit pending
# value is the provenance guard; unrelated error exits never enter here.
# Previewed only if the reused candidate was actually streamed as interim.
final_response = _pending_verification_response
if _pending_verification_response_previewed:
agent._response_was_previewed = True
preserved_verification_fallback = True
else:
# _handle_max_iterations makes one extra toolless request for a summary.
agent._emit_diagnostic_status(
f"⚠️ Iteration budget exhausted ({api_call_count}/{agent.max_iterations}) "
"— asking model to summarise"
)
if not agent.quiet_mode:
agent._safe_print(
f"\n⚠️ Iteration budget exhausted ({api_call_count}/{agent.max_iterations}) "
"— requesting summary...", diagnostic=True,
)
_summary_start = time.time()
try:
final_response = agent._handle_max_iterations(messages, api_call_count)
except InterruptedError:
# The turn ends interrupted, so the pending interrupt message is returned
# for requeue instead of being cleared behind a fallback summary. A redirect
# also ends it: the budget is spent, so there is no loop to restart into.
from agent.conversation_loop import INTERRUPT_WAITING_FOR_MODEL_PREFIX
interrupted = True
_turn_exit_reason = interrupted_during_api_call_reason(agent)
final_response = f"{INTERRUPT_WAITING_FOR_MODEL_PREFIX}{time.time() - _summary_start:.1f}s elapsed)."
# A kanban worker must record a terminal outcome whether or not a fallback path
# was eligible, so the dispatcher learns the worker could not complete. Only the
# dispatcher-owned worker owns the task: an in-process delegate_task child or cron run
# inherits ``HERMES_KANBAN_TASK`` via os.environ but exhausting ITS budget must not
# close the parent's run and release its claim (#112817).
_kanban_task = (
os.environ.get("HERMES_KANBAN_TASK")
if budget_exhausted and is_dispatcher_owned_worker_context() else None
)
# If running as a kanban worker, signal the dispatcher that the worker could not complete (rather than
# treating it as a protocol violation). This applies whether the user-facing fallback came from the
# summary call or an explicitly pending continuation; both exhausted the task budget and must advance
# the failure circuit. We route through ``_record_task_failure(outcome="timed_out")`` rather than
# ``kanban_block`` so this counts toward the dispatcher's consecutive-failure circuit breaker (#29747
# gap 2).
# Bounded fallback (#87096): budget was exhausted but none of the normal fallback paths were eligible
# (interrupted / failed / anomalous exit_reason). If running as a kanban worker we must still record a
# terminal outcome so the task does not remain in an ambiguous lifecycle state. The worker's run is
# closed via ``_record_task_failure`` (compare-and-swap receipt path) which is a no-op if another path
# closed it — the CAS invariant in ``_end_run`` (``WHERE ended_at IS NULL``) guarantees idempotence.
if _kanban_task:
_record_kanban_budget_exhausted(_kanban_task, api_call_count, agent.max_iterations, logger)
return final_response, _turn_exit_reason, preserved_verification_fallback, interrupted
def _rollback_interrupted_preflight_display(agent, interrupted) -> None:
"""Roll back the preflight-seeded display count only when an interrupt wins before
any provider response; compaction state (incl. ``-1``) stays with the real-usage
path. Type-pinned guards keep MagicMock/SimpleNamespace doubles inert."""
_preflight_snapshot = getattr(agent, "_turn_preflight_display_snapshot", None)
if (
interrupted is True
and isinstance(_preflight_snapshot, int)
and not isinstance(_preflight_snapshot, bool)
and getattr(agent, "_turn_received_provider_response", False) is not True
and getattr(agent, "context_compressor", None) is not None
):
_rollback_fn = getattr(
agent.context_compressor, "rollback_interrupted_preflight_display_tokens", None
)
if callable(_rollback_fn):
_rollback_fn(_preflight_snapshot)
def _drop_transcript_scaffolding(agent, messages) -> None:
"""Strip private retry scaffolding first, or a later "continue" replays
assistant("(empty)") / recovery nudges into the same empty-response loop. Only
the synthetic verification nudges go; the assistant candidate persists (#65919)."""
agent._drop_trailing_empty_response_scaffolding(messages)
_drop_verification_continuation_scaffolding(messages)
def _recover_final_from_stream(agent, final_response, interrupted, failed) -> Tuple[Any, bool]:
"""An empty terminal completion is not authoritative when the stream already
delivered text; recover before persist so a blank tail isn't frozen (#95514).
Returns ``(final_response, recovered_from_stream)``. Called by the finalizer BEFORE
the fallible tail-shaping/persist steps so the recovered text is already bound when
one of them raises — a persist failure must not lose text the user already saw."""
if interrupted or failed:
return final_response, False
_streamed = getattr(agent, "_current_streamed_assistant_text", "") or ""
_streamed = _streamed.strip() if isinstance(_streamed, str) else ""
if not (flatten_message_text(final_response).strip() if final_response else "") and _streamed:
return _streamed, True
return final_response, False
def _close_transcript_tail(agent, messages, final_response, interrupted, _recovered_from_stream) -> None:
"""Shape the transcript tail before the durable snapshot (scaffolding already dropped
and ``final_response`` already stream-recovered by the caller)."""
# An interrupt can leave a tool result as the tail; close the sequence so strict
# providers don't see ``tool → user`` (placeholder: final_response is usually empty).
if interrupted:
from agent.message_sanitization import close_interrupted_tool_sequence
close_interrupted_tool_sequence(messages, final_response)
# Recovery ``break`` sites can return a final_response with no closing assistant
# row; enforce "delivered final_response ⇒ assistant row" here. Compare content,
# not role, so a matching verification candidate isn't dup'd.
if final_response and not interrupted:
# Some recovery/fallback paths return a real final_response without adding a closing assistant
# message to the transcript (e.g. the partial-stream and prior-turn-content recovery ``break`` sites
# in ``conversation_loop``). If persisted as-is, the durable session can end at a tool/user message
# even though the caller — and the gateway platform — already saw a completed assistant response.
# The next turn then replays a user-only backlog and the model re-answers every "unanswered"
# message. Close the durable turn at the source, at the single chokepoint every recovery ``break``
# flows through, so the invariant "delivered final_response ⇒ assistant row in transcript" holds
# regardless of which path produced it. (#43849 / #44100) Compare content (not just role) so a
# verification candidate that matches the final response is not duplicated at budget exhaustion.
# (#65919 §7)
_tail = messages[-1] if messages else None
if not isinstance(_tail, dict) or _tail.get("role") != "assistant":
append_message(messages, {"role": "assistant", "content": final_response})
elif (
_tail.get("content") != final_response
and _assistant_row_missing_visible_text(_tail)
and (_tail.get("tool_calls") or _recovered_from_stream)
):
# Pure tool-call turn or stream-recovered blank (#95514): fill the persisted
# blank row's content rather than append a second row.
_tail["content"] = final_response
stamp_message_timestamp(_tail)
_tail.pop(_DB_PERSISTED_MARKER, None)
agent._db_flush_scan_prefix = None
# Request is complete, so replace API-local voice/model/skill guidance with the
# clean user input before the durable snapshot (earlier flushes still needed them).
# Earlier turn-start flushes use the DB-only override because their messages are still needed for the
# API request; this finalizer runs after that request is complete (#48677 / #63766).
_apply_override = getattr(agent, "_apply_persist_user_message_override", None)
if callable(_apply_override):
_apply_override(messages)
def _micro_compact_after_turn(agent, messages, final_response, logger, task_id) -> None:
"""Post-turn micro-compaction: absorb the oldest uncompacted exchange into the
rolling summary before persist, amortizing compression across turns."""
try:
_compressor = getattr(agent, "context_compressor", None)
# Strict `is True` + callable gates: plugin context engines and MagicMock
# compressors pass duck checks and would wipe the transcript. Never run while
# compression.checkpoint_required is armed (no checkpoint hook here), nor for
# persistence-isolated agents (background review fork): that burns an aux-LLM
# call on a throwaway transcript and could compact the CANONICAL session rows.
if (
_compressor
and getattr(_compressor, '_micro_compact_enabled', False) is True
and callable(getattr(_compressor, '_micro_compact', None))
and final_response
and getattr(agent, "compression_checkpoint_required", False) is not True
and not getattr(agent, "_persist_disabled", False)
):
_before = len(messages)
_compacted = _compressor._micro_compact(messages)
# Defrag rewrites the newest MICRO marker in place and pops _db_persisted;
# the compressor flags us to invalidate the flush-scan cursor, else the
# rewritten row is identity-skipped (stale).
if getattr(_compressor, "_flush_scan_cursor_invalidated", False):
_compressor._flush_scan_cursor_invalidated = False
agent._db_flush_scan_prefix = None
if isinstance(_compacted, list) and _compacted:
_spliced = _compacted is not messages # no-op and defrag passes return the input
messages[:] = _compacted
if _spliced:
# The splice summarized tool results away: a repeat read must serve them
# again, not an "unchanged" stub pointing at a body that is gone (#32106).
from agent.conversation_compression import _reset_read_dedup_caches
_reset_read_dedup_caches(task_id, session_id=agent.session_id or "")
if _before != len(messages):
logger.info("Micro-compaction: %d -> %d messages", _before, len(messages))
except Exception as _mc_err:
logger.info("Micro-compaction failed: %s", _mc_err)
def _log_turn_exit(agent, messages, final_response, api_call_count, _turn_exit_reason, interrupted, logger) -> None:
"""Always INFO so agent.log captures WHY every turn ended; WARNING when the last
message is a tool result (the "just stops" scenario)."""
_last_msg_role = messages[-1].get("role") if messages else None
_last_tool_name = None
if _last_msg_role == "tool":
# Walk back to the assistant message with the tool call.
for _m in reversed(messages):
if _m.get("role") == "assistant" and _m.get("tool_calls"):
_tcs = _m["tool_calls"]
if _tcs and isinstance(_tcs[0], dict):
_last_tool_name = _tcs[-1].get("function", {}).get("name")
break
_turn_tool_count = sum(
1 for m in messages
if isinstance(m, dict) and m.get("role") == "assistant" and m.get("tool_calls")
)
# Fork turns (background review, side questions) carry ``_turn_origin``; tagging the
# exit line keeps a fork's ``interrupted_during_api_call`` from reading as a killed
# foreground stream — the fork shares the parent's session_id and often its model (#118693).
_turn_origin = getattr(agent, "_turn_origin", None)
_diag_msg = (
"Turn ended: reason=%s model=%s api_calls=%d/%d budget=%d/%d "
"tool_turns=%d last_msg_role=%s response_len=%d session=%s"
+ (" origin=%s" if _turn_origin else "")
)
_diag_args = (
_turn_exit_reason, agent.model, api_call_count, agent.max_iterations,
agent.iteration_budget.used if agent.iteration_budget else 0,
agent.iteration_budget.max_total if agent.iteration_budget else 0,
_turn_tool_count, _last_msg_role, len(final_response) if final_response else 0,
agent.session_id or "none",
*((_turn_origin,) if _turn_origin else ()),
)
if _last_msg_role == "tool" and not interrupted:
logger.warning(
"Turn ended with pending tool result (agent may appear stuck). "
+ _diag_msg + " last_tool=%s",
*_diag_args, _last_tool_name,
)
else:
logger.info(_diag_msg, *_diag_args)
def _append_file_mutation_footer(agent, final_response, logger):
"""Append the verifier advisory when ``write_file`` / ``patch`` calls failed and were
never superseded by a successful write to the same path (surfaces over-claiming)."""
try:
# File-mutation verifier footer. This catches the specific case — reported by Ben Eng
# (#15524-adjacent) — where a model issues a batch of parallel patches, half of them fail with
# "Could not find old_string", and the model summarises the turn claiming every file was edited. The
# user then has to manually run ``git status`` to catch the lie. With this footer the truth is
# surfaced on every turn, so over-claiming is structurally impossible past the model. Gate: only
# applied when a real text response exists for this turn and the user didn't interrupt.
# Empty/interrupted turns already have other surface text that shouldn't be augmented.
_failed = getattr(agent, "_turn_failed_file_mutations", None) or {}
if _failed and agent._file_mutation_verifier_enabled():
_failed = agent._file_mutations_still_failed(_failed)
footer = agent._format_file_mutation_failure_footer(_failed)
if footer:
final_response = final_response.rstrip() + "\n\n" + footer
except Exception as _ver_err:
logger.debug("file-mutation verifier footer failed: %s", _ver_err)
return final_response
def _explain_abnormal_exit(agent, final_response, _turn_exit_reason, preserved_verification_fallback, logger):
"""Turn-completion explainer: on abnormal exits, surface one explanation from
``_turn_exit_reason``. Only acts when no usable reply exists (empty, "(empty)",
or a short unpunctuated fragment); ``text_response(...)`` exits stay silent."""
try:
if not agent._turn_completion_explainer_enabled():
return final_response
_stripped = (final_response or "").strip()
_is_empty_terminal = _stripped in ("", "(empty)")
# A short fragment not from a text_response exit and lacking sentence-ending
# punctuation is treated as a truncated partial (#34452).
_is_partial_fragment = (
not _is_empty_terminal
and not preserved_verification_fallback
and not str(_turn_exit_reason).startswith("text_response")
and len(_stripped) <= 24
and _stripped[-1:] not in _SENTENCE_END
)
if _is_empty_terminal or _is_partial_fragment or str(_turn_exit_reason) == "partial_stream_recovery":
_explanation = agent._format_turn_completion_explanation(
_turn_exit_reason, getattr(agent, "_last_persistence_error_cause", None),
db_path=getattr(getattr(agent, "_session_db", None), "db_path", None),
model=str(getattr(agent, "model", "") or ""),
)
if _explanation:
# Replace the bare sentinel; keep a partial fragment and append why.
final_response = _explanation if _is_empty_terminal else _stripped + "\n\n" + _explanation
except Exception as _exp_err:
logger.debug("turn-completion explainer failed: %s", _exp_err)
return final_response
def _last_turn_reasoning(messages) -> Optional[Any]:
"""Reasoning from the CURRENT turn only: stop at this turn's user message (#17055),
but take the most recent non-empty reasoning since many providers emit it on the
tool-call step and leave the final step with reasoning=None."""
for msg in reversed(messages):
if msg.get("role") == "user":
return None # turn boundary — don't cross into prior turns
if msg.get("role") == "assistant" and msg.get("reasoning"):
return msg["reasoning"]
return None
def _apply_output_hooks(
agent, final_response, logger, *, platform, effective_task_id, turn_id, original_user_message,
messages,
) -> Tuple[Any, bool, Optional[Any]]:
"""Resolve the turn's ``transform_llm_output`` outcome, then fire ``post_llm_call`` once per
turn after the tool loop. Returns ``(final_response, transformed, pre_transform_response)``.
The transform itself normally already ran before the assistant row was first persisted
(``apply_llm_output_transform`` from ``finish_text_response`` / ``_persist_step``); this
call returns that recorded outcome, and only fires the hook here when no earlier seam saw a
response (e.g. text that only appeared through ``_explain_abnormal_exit``)."""
final_response, transformed, pre_transform = apply_llm_output_transform(
agent, final_response, turn_id=turn_id, platform=platform, logger=logger,
)
# Detached forks are internal work and must not publish turns under the parent's session ID.
if not getattr(agent, "_persist_disabled", False):
_invoke_hook_safely(
"post_llm_call", logger,
session_id=agent.session_id,
task_id=effective_task_id,
turn_id=turn_id,
user_message=original_user_message,
assistant_response=final_response,
conversation_history=list(messages),
model=agent.model,
platform=platform,
)
return final_response, transformed, pre_transform
def apply_llm_output_transform(
agent, final_response, *, turn_id, platform=None, logger=None,
) -> Tuple[Any, bool, Optional[Any]]:
"""Fire ``transform_llm_output`` once per turn and return
``(final_response, transformed, pre_transform_response)``.
Called BEFORE the final assistant row is first persisted — from ``finish_text_response``
ahead of its durable flush, and from ``finalize_turn._persist_step`` ahead of the
recovery-path tail close — so the text the user sees is the text stored in SQLite/JSON and
replayed next turn (#44239). SQLite treats a non-blank assistant row as settled (a re-flush
adopts the stored content rather than overwriting it), so transforming after that first
write can never reach the durable store. Idempotent per ``turn_id``: later callers in the
same turn get the recorded outcome instead of a second hook firing. Only the current
turn's not-yet-written text is touched — earlier turns and the system prompt are never
rewritten (prompt-cache invariant)."""
if logger is None:
from agent.conversation_loop import logger
recorded = getattr(agent, "_llm_output_transform", None)
if isinstance(recorded, tuple) and len(recorded) == 3 and recorded[0] == turn_id:
_, transformed, pre_transform = recorded
return final_response, transformed, pre_transform
if not final_response:
return final_response, False, None
if platform is None:
platform = getattr(agent, "platform", None) or ""
transformed, pre_transform = False, None
# First hook to return a string wins; None/empty leaves the text unchanged.
for _hook_result in _invoke_hook_safely(
"transform_llm_output", logger,
response_text=final_response,
session_id=agent.session_id or "",
model=agent.model,
platform=platform,
turn_id=turn_id, # per-turn identity for the hook callback gate
):
if isinstance(_hook_result, str) and _hook_result:
pre_transform, final_response, transformed = final_response, _hook_result, True
break
agent._llm_output_transform = (turn_id, transformed, pre_transform)
return final_response, transformed, pre_transform
def finalize_turn(
agent, *, final_response, api_call_count, interrupted, failed, messages, conversation_history,
effective_task_id, turn_id, user_message, original_user_message, _should_review_memory,
_turn_exit_reason, _pending_verification_response=None,
_pending_verification_response_previewed=False,
):
"""Run the post-loop finalization and return the turn ``result`` dict."""
from agent.conversation_loop import logger
final_response, _turn_exit_reason, preserved_verification_fallback, interrupted = _resolve_budget_fallback(
agent, final_response=final_response, api_call_count=api_call_count,
interrupted=interrupted, failed=failed, messages=messages,
_turn_exit_reason=_turn_exit_reason,
_pending_verification_response=_pending_verification_response,
_pending_verification_response_previewed=_pending_verification_response_previewed,
logger=logger,
)
# A non-interrupted turn that fell out of the loop after a tool result, with no
# follow-up assistant text, is the Desktop/TUI "silent stop" (#55316, #54756): the
# composer returns to ready (or keeps spinning) while the durable transcript ends
# at a raw ``tool`` row — the user never learns the turn stopped, and the next user
# message lands as ``tool → user``. Interrupted tails keep
# ``close_interrupted_tool_sequence``; this is the non-interrupt sibling. Mint the
# exit reason, fail the turn, and synthesize the visible close so the tail close in
# ``_persist_step`` persists an assistant row. A turn that already streamed text is
# left alone: ``_recover_final_from_stream`` owns that recovery (#95514).
if (
not final_response
and not interrupted
and messages
and isinstance(messages[-1], dict)
and messages[-1].get("role") == "tool"
and not (getattr(agent, "_current_streamed_assistant_text", "") or "").strip()
):
_turn_exit_reason = "pending_tool_result"
failed = True
final_response = ""
try:
if agent._turn_completion_explainer_enabled():
final_response = (
agent._format_turn_completion_explanation("pending_tool_result", None) or ""
)
except Exception:
final_response = ""
if not final_response:
# The turn-completion explainer opt-out must not reintroduce the silent stop.
final_response = (
"No reply: the turn stopped while a tool result was still pending. "
"Send `continue` to let the model summarize."
)
# Loop exits that are failures in their own right (outer-loop error cap, shutdown, context
# that could not be shrunk) carry the verdict the UI descriptor needs; a bare
# ``turn_exit_reason`` collapsed to code="unknown", retryable=True on every surface.
# Advisory verdicts (``fails_turn=False``) only add the code: ``failed``/``completed`` keep
# the loop's values so cron, kanban and transcript persistence behave as before.
_exit_failure = None if interrupted else exit_reason_failure(_turn_exit_reason)
if _exit_failure is not None and _exit_failure.fails_turn:
failed = True
# Sibling producers (``turn_recovery``, ``codex_runtime``) return ``completed=False`` for an
# interrupted turn; the gateway stream gate and the API run status rely on that contract.
completed = (
final_response is not None
and not failed
and not interrupted
and (api_call_count < agent.max_iterations or str(_turn_exit_reason).startswith("text_response("))
)
_rollback_interrupted_preflight_display(agent, interrupted)
_cleanup_errors: List[str] = []
# The model has answered (or the loop gave up): a title upgrade held back because it shares a
# self-hosted endpoint with the main request (#117296) may go out now.
from agent.turn_context import start_deferred_title_upgrade
_guarded_cleanup("start_deferred_title_upgrade", lambda: start_deferred_title_upgrade(agent), _cleanup_errors, logger)
# ``user_message`` may be a multimodal list of parts; the trajectory format wants a string.
_guarded_cleanup(
"save_trajectory",
lambda: agent._save_trajectory(messages, _summarize_user_message_for_log(user_message), completed),
_cleanup_errors, logger,
)
_guarded_cleanup(
"cleanup_task_resources", lambda: agent._cleanup_task_resources(effective_task_id),
_cleanup_errors, logger,
)
# Persist only after the transcript tail is shaped and scaffolding removed. Each
# sub-step runs in the same order as the original inline block, and the
# stream-recovered ``final_response`` is rebound the moment it is computed — BEFORE
# the fallible tail-shaping / override / micro-compaction / persist calls — so a
# raise in any of them can't drop text the user already saw (#95514, #8049).
def _persist_step():
nonlocal final_response
_drop_transcript_scaffolding(agent, messages)
final_response, _recovered_from_stream = _recover_final_from_stream(
agent, final_response, interrupted, failed
)
# Recovery paths (stream-recovered / prior-turn text) reach here with a response no
# earlier seam transformed; the normal text turn already did this before its flush and
# gets the recorded outcome back. Either way the tail close below writes the text the
# user will see, never the raw model text (#44239).
if final_response and not interrupted:
final_response, _, _ = apply_llm_output_transform(agent, final_response, turn_id=turn_id, logger=logger)
_close_transcript_tail(agent, messages, final_response, interrupted, _recovered_from_stream)
if not interrupted and not failed:
_micro_compact_after_turn(agent, messages, final_response, logger, effective_task_id)
agent._persist_session(messages, conversation_history)
_guarded_cleanup("persist_session", _persist_step, _cleanup_errors, logger)
# Keep the gateway's separate in-memory history snapshot current even on
# cleanup error, so a later prompt isn't sent with a pre-turn snapshot.
with suppress(Exception):
agent._session_messages = messages
_log_turn_exit(agent, messages, final_response, api_call_count, _turn_exit_reason, interrupted, logger)
# Response transforms apply only to real, uninterrupted responses.
if final_response and not interrupted:
final_response = _append_file_mutation_footer(agent, final_response, logger)
if not interrupted:
final_response = _explain_abnormal_exit(
agent, final_response, _turn_exit_reason, preserved_verification_fallback, logger,
)
_platform = getattr(agent, "platform", None) or ""
_response_transformed = False
_pre_transform_response = None
if final_response and not interrupted:
final_response, _response_transformed, _pre_transform_response = _apply_output_hooks(
agent, final_response, logger, platform=_platform, effective_task_id=effective_task_id,
turn_id=turn_id, original_user_message=original_user_message, messages=messages,
)
# Context engine observation hook: the turn finished with the finalized transcript.
# Fail-open. ``_last_turn_usage`` is the last response's canonical usage dict, or
# ``None`` on turns that never reached a provider response — by contract.
try:
from agent.conversation_loop import _notify_context_engine_turn_complete
_notify_context_engine_turn_complete(
agent, messages, usage=getattr(agent, "_last_turn_usage", None), logger=logger,
turn_id=turn_id, task_id=effective_task_id, api_call_count=api_call_count,
interrupted=interrupted, failed=failed, turn_exit_reason=_turn_exit_reason,
)
except Exception as exc:
logger.warning("on_turn_complete notification failed: %s", exc)
# Surrogate chokepoint: RAW SDK text with a lone UTF-16 surrogate crashes downstream
# consumers (stdout, Telegram ``utf16_len``, JSON); scrub once where it leaves the loop.
# Class-level surrogate chokepoint (#80366, #55143, #55309, #19819): ``final_response`` is often the RAW
# SDK content (``assistant_message.content``), not the sanitized copy stored in history by
# ``build_assistant_message``. Any lone UTF-16 surrogate (U+D800–U+DFFF) in it crashes downstream
# consumers — oneshot stdout writes, Telegram's ``utf16_len`` length check, Signal formatting, JSON
# envelope encodes — on every provider (Ollama, NVIDIA NIM, …). Scrub once here, where model text leaves
# the conversation loop, so every delivery surface receives valid Unicode.
if isinstance(final_response, str):
final_response = _sanitize_surrogates(final_response)
result = {
"final_response": final_response,
"last_reasoning": _last_turn_reasoning(messages),
"messages": messages,
"api_calls": api_call_count,
"completed": completed,
"turn_exit_reason": _turn_exit_reason,
"failed": failed,
"partial": False, # True only when stopped due to invalid tool calls
"interrupted": interrupted,
"response_transformed": _response_transformed,
"pre_transform_response": _pre_transform_response,
"response_previewed": getattr(agent, "_response_was_previewed", False),
"model": agent.model,
# requested_model / served_model: proxy-reported deployment or Hermes' own fallback route.
**result_model_fields(agent),
"provider": agent.provider,
"base_url": agent.base_url,
**{key: getattr(agent, f"session_{key}") for key in _SESSION_TOKEN_KEYS},
# Gateway SessionEntry persists an API reading, never the preflight display seed.
"last_prompt_tokens": (
getattr(agent.context_compressor, "last_real_prompt_tokens", agent.context_compressor.last_prompt_tokens)
if getattr(agent.context_compressor, "last_prompt_tokens", 0) > 0
else getattr(agent.context_compressor, "last_prompt_tokens", 0)
) or 0,
**{key: getattr(agent, f"session_{key}") for key in _SESSION_COST_KEYS},
# Requested service tier, for billing audits (`hermes -z --usage-file`).
"service_tier": (
(getattr(agent, "request_overrides", {}) or {}).get("extra_body") or {}
).get("service_tier"),
"session_id": agent.session_id,
}
if agent._tool_guardrail_halt_decision is not None:
result["guardrail"] = agent._tool_guardrail_halt_decision.to_metadata()
# Persistence failures already set failed=True; also stamp `error` so the gateway
# surfaces status="error" (desktop can toast) instead of a quiet complete frame, plus
# the machine-readable cause 'session_persistence_failed:<locked|compression|...>'.
if failed and str(_turn_exit_reason) == "session_persistence_failed":
from hermes_constants import profile_cli_selector
# Never rebind final_response here: the memory sync and the background-review gate
# below must still see an empty response on a persistence-failed turn.
result["error"] = final_response or (
"session storage could not be written — check the state database "
f"health (`hermes {profile_cli_selector()}doctor`), then send your message again"
)
_cause = getattr(agent, "_last_persistence_error_cause", None)
result["failure_reason"] = "session_persistence_failed:" + (_cause or "unknown")
elif _exit_failure is not None:
if failed:
result["error"] = final_response or str(_turn_exit_reason)
stamp_failure(result, _exit_failure.reason, _exit_failure.retryable)
# Cleanup failures are surfaced, but the response is returned either way (#8049).
if _cleanup_errors:
result["cleanup_errors"] = _cleanup_errors
# A /steer landing after the final assistant turn has no tool batch to drain into;
# hand it back so it becomes the next user turn instead of being lost.
_leftover_steer = agent._drain_pending_steer()
if _leftover_steer:
result["pending_steer"] = _leftover_steer
agent._response_was_previewed = False
if interrupted and agent._interrupt_message:
result["interrupt_message"] = agent._interrupt_message
agent.clear_interrupt()
agent._stream_callback = None # don't leak into future calls
# Skill trigger is checked NOW — based on how many tool iterations THIS turn used.
_should_review_skills = (
agent._skill_nudge_interval > 0
and agent._iters_since_skill >= agent._skill_nudge_interval
and "skill_manage" in agent.valid_tool_names
)
if _should_review_skills:
agent._iters_since_skill = 0
# External memory provider: sync the completed turn + queue next prefetch.
agent._sync_external_memory_for_turn(
original_user_message=original_user_message, final_response=final_response,
interrupted=interrupted, messages=messages,
)
# Background memory/skill review runs AFTER delivery so it never competes with the
# user's task. Suppressed by skip_background_review (e.g. cron): the fork costs
# ~30K tokens / event with no human-in-the-loop benefit. Best-effort; the review
# clones the snapshot structurally so its sanitizers can't reach the live transcript.
if (
final_response
and not interrupted
and not getattr(agent, "skip_background_review", False)
and (_should_review_memory or _should_review_skills)
):
with suppress(Exception):
agent._spawn_background_review(
messages_snapshot=list(messages), review_memory=_should_review_memory,
review_skills=_should_review_skills,
)
# Memory provider on_session_end()/shutdown_all() are NOT called here:
# run_conversation() runs once per message; CLI/gateway own session-end cleanup.
if not getattr(agent, "_persist_disabled", False):
_invoke_hook_safely(
"on_session_end", logger,
session_id=agent.session_id,
task_id=effective_task_id,
turn_id=turn_id,
completed=completed,
failed=failed,
interrupted=interrupted,
turn_exit_reason=_turn_exit_reason,
model=agent.model,
platform=_platform,
)
agent._turn_preflight_display_snapshot = None
agent._turn_received_provider_response = False
return result