Files
hermes-agent/agent/turn_finalizer.py

691 lines
31 KiB
Python
Raw 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``.
Lifted verbatim: 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
from agent.codex_responses_adapter import _summarize_user_message_for_log
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
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 _is_pure_tool_call_tail(msg: dict) -> bool:
"""Assistant row with ``tool_calls`` but no visible text of its own."""
if not isinstance(msg, dict) or not msg.get("tool_calls"):
return False
return _assistant_row_missing_visible_text(msg)
def _fill_assistant_tail_content(agent, tail: dict, final_response) -> None:
"""Write delivered text onto an already-persisted blank assistant row."""
tail["content"] = final_response
stamp_message_timestamp(tail)
tail.pop(_DB_PERSISTED_MARKER, None)
agent._db_flush_scan_prefix = None
# 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",
)
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.
Idempotent via the ``_end_run`` CAS (``WHERE ended_at IS NULL``): a no-op if
another path already closed the run, so safe from multiple exit paths (#87096)."""
try:
from hermes_cli import kanban_db as _kb
_conn = _kb.connect()
try:
_kb._record_task_failure(
_conn,
kanban_task,
error=(
f"Iteration budget exhausted "
f"({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:
try:
_conn.close()
except Exception:
pass
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 nudge messages from *messages* 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."""
# Import lazily: conversation_loop imports this module during turn
# finalization, so a module-level import would create a cycle.
from agent.conversation_loop import _clone_message_for_send
return [_clone_message_for_send(message) for message in messages]
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
budget_exhausted = (
api_call_count >= agent.max_iterations
or agent.iteration_budget.remaining <= 0
)
budget_fallback_eligible = (
budget_exhausted
and not interrupted
and not failed
and str(_turn_exit_reason) in {"unknown", "budget_exhausted"}
)
continuation_budget_exhausted = (
final_response is None
and bool(_pending_verification_response)
and budget_fallback_eligible
)
iteration_limit_fallback = False
preserved_verification_fallback = False
if continuation_budget_exhausted:
# 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.
final_response = _pending_verification_response
# Previewed only if the reused candidate was actually streamed as interim.
if _pending_verification_response_previewed:
agent._response_was_previewed = True
_turn_exit_reason = f"max_iterations_reached({api_call_count}/{agent.max_iterations})"
iteration_limit_fallback = True
preserved_verification_fallback = True
elif final_response is None and budget_fallback_eligible:
# Budget exhausted: _handle_max_iterations makes one extra toolless request
# for a summary.
_turn_exit_reason = f"max_iterations_reached({api_call_count}/{agent.max_iterations})"
agent._emit_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..."
)
final_response = agent._handle_max_iterations(messages, api_call_count)
iteration_limit_fallback = True
if iteration_limit_fallback:
# Kanban worker: signal the dispatcher the worker could not complete. Route
# via ``_record_task_failure(outcome="timed_out")`` (not ``kanban_block``) so
# it counts toward the consecutive-failure circuit breaker (#29747).
_kanban_task = os.environ.get("HERMES_KANBAN_TASK")
if _kanban_task:
_record_kanban_budget_exhausted(
_kanban_task, api_call_count, agent.max_iterations, logger,
)
elif budget_exhausted:
# Bounded fallback: budget exhausted with no eligible fallback path. A kanban
# worker must still record a terminal outcome; the ``_end_run`` CAS makes it
# idempotent if another path already closed the run (#87096).
_kanban_task = os.environ.get("HERMES_KANBAN_TASK")
if _kanban_task:
_record_kanban_budget_exhausted(
_kanban_task, api_call_count, agent.max_iterations, logger,
)
# Determine if conversation completed successfully
normal_text_response = str(_turn_exit_reason).startswith("text_response(")
completed = (
final_response is not None
and not failed
and (
api_call_count < agent.max_iterations
or normal_text_response
)
)
# 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)
# Post-loop cleanup must never lose the response: trajectory save, teardown,
# and session persist are guarded independently and errors surface via
# ``cleanup_errors`` rather than killing the turn (#8049).
_cleanup_errors = []
# Save trajectory if enabled. ``user_message`` may be a multimodal
# list of parts; the trajectory format wants a plain string.
try:
agent._save_trajectory(messages, _summarize_user_message_for_log(user_message), completed)
except Exception as _save_err:
_cleanup_errors.append(f"save_trajectory: {_save_err}")
logger.error("finalize_turn: _save_trajectory failed: %s", _save_err, exc_info=True)
# Clean up VM and browser for this task after conversation completes
try:
agent._cleanup_task_resources(effective_task_id)
except Exception as _cleanup_err:
_cleanup_errors.append(f"cleanup_task_resources: {_cleanup_err}")
logger.error("finalize_turn: _cleanup_task_resources failed: %s", _cleanup_err, exc_info=True)
# Persist only after private retry scaffolding is removed, or a later "continue"
# replays assistant("(empty)") / recovery nudges into the same empty-response loop.
try:
agent._drop_trailing_empty_response_scaffolding(messages)
# Strip only the synthetic verification nudges before the tail-assistant
# check; the assistant candidate persists in state.db. (#65919)
_drop_verification_continuation_scaffolding(messages)
# An empty terminal completion is not authoritative when the stream already
# delivered text; recover before persist so a blank tail isn't frozen (#95514).
_recovered_from_stream = False
if not interrupted and not failed:
_streamed = getattr(agent, "_current_streamed_assistant_text", "") or ""
if isinstance(_streamed, str):
_streamed = _streamed.strip()
else:
_streamed = ""
_final_visible = flatten_message_text(final_response).strip() if final_response else ""
if not _final_visible and _streamed:
final_response = _streamed
_recovered_from_stream = True
# An interrupt can leave a tool result as the tail (no scaffolding flag rewinds
# it); close the sequence so strict providers don't see ``tool → user``. An
# explicit placeholder is used since final_response is usually empty (#48879).
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:
try:
_tail = messages[-1] if messages else None
except Exception:
_tail = None
_tail_role = _tail.get("role") if isinstance(_tail, dict) else None
if _tail_role != "assistant":
# Tail is not an assistant row — append the final response
# so the durable turn closes with the answer (#43849/#44100).
append_message(
messages,
{"role": "assistant", "content": final_response},
)
elif (
isinstance(_tail, dict)
and _tail.get("content") != final_response
and (
_is_pure_tool_call_tail(_tail)
or (
_recovered_from_stream
and _assistant_row_missing_visible_text(_tail)
)
)
):
# Tail is an assistant row (pure tool-call turn or stream-recovered
# blank, #95514): fill its content rather than append a second row.
_fill_assistant_tail_content(agent, _tail, final_response)
# Request is complete, so replace API-local voice/model/skill guidance with
# the clean user input before the durable snapshot; earlier flushes used the
# DB-only override as their messages were still needed (#48677 / #63766).
_apply_override = getattr(agent, "_apply_persist_user_message_override", None)
if callable(_apply_override):
_apply_override(messages)
# ── Post-turn micro-compaction ────────────────────────────
# Absorb the oldest uncompacted exchange into the rolling summary before
# persist, amortizing compression across turns instead of one big pause.
if not interrupted and not failed:
try:
_compressor = getattr(agent, "context_compressor", None)
# Strict `is True` + isinstance gates: plugin context engines and
# MagicMock compressors pass duck checks and would wipe the transcript.
if (
_compressor
and getattr(_compressor, '_micro_compact_enabled', False) is True
and callable(getattr(_compressor, '_micro_compact', None))
and final_response
# Micro-compaction has no checkpoint hook, so it must never run
# while compression.checkpoint_required is armed.
and getattr(
agent, "compression_checkpoint_required", False
) is not True
# Persistence-isolated agents (background review fork) must not
# micro-compact: it burns an aux-LLM call on a throwaway transcript
# and could archive_and_compact the CANONICAL session rows.
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:
messages[:] = _compacted
_after = len(messages)
if _before != _after:
logger.info(
"Micro-compaction: %d -> %d messages",
_before, _after,
)
except Exception as _mc_err:
logger.info("Micro-compaction failed: %s", _mc_err)
agent._persist_session(messages, conversation_history)
except Exception as _persist_err:
_cleanup_errors.append(f"persist_session: {_persist_err}")
logger.error("finalize_turn: _persist_session failed: %s", _persist_err, exc_info=True)
# 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.
try:
agent._session_messages = messages
except Exception:
pass
# ── Turn-exit diagnostic log ─────────────────────────────────────
# 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 find 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")
)
_resp_len = len(final_response) if final_response else 0
_budget_used = agent.iteration_budget.used if agent.iteration_budget else 0
_budget_max = agent.iteration_budget.max_total if agent.iteration_budget else 0
_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"
)
_diag_args = (
_turn_exit_reason, agent.model, api_call_count, agent.max_iterations,
_budget_used, _budget_max,
_turn_tool_count, _last_msg_role, _resp_len,
agent.session_id or "none",
)
if _last_msg_role == "tool" and not interrupted:
# Agent was mid-work — this is the "just stops" case.
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)
# File-mutation verifier footer: if ``write_file`` / ``patch`` calls failed and
# were never superseded by a successful write to the same path, append an
# advisory so over-claiming is surfaced. Only on real, uninterrupted responses.
if final_response and not interrupted:
try:
_failed = getattr(agent, "_turn_failed_file_mutations", None) or {}
if _failed and agent._file_mutation_verifier_enabled():
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)
# 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.
if not interrupted:
try:
if agent._turn_completion_explainer_enabled():
_stripped = (final_response or "").strip()
_is_empty_terminal = _stripped == "" or _stripped == "(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 {".", "!", "?", "。", "!", "?", "`", ")"}
)
_is_partial_stream_recovery = (
str(_turn_exit_reason) == "partial_stream_recovery"
)
if (
_is_empty_terminal
or _is_partial_fragment
or _is_partial_stream_recovery
):
_explanation = agent._format_turn_completion_explanation(
_turn_exit_reason,
getattr(agent, "_last_persistence_error_cause", None),
)
if _explanation:
if _is_empty_terminal:
# Replace the bare "(empty)"/blank sentinel with
# the actionable explanation.
final_response = _explanation
else:
# Keep the partial fragment and append why it stopped.
final_response = (
_stripped + "\n\n" + _explanation
)
except Exception as _exp_err:
logger.debug("turn-completion explainer failed: %s", _exp_err)
_response_transformed = False
_pre_transform_response = None
# Plugin hook: transform_llm_output — fired once per turn after the tool loop.
# First hook to return a string wins; None/empty leaves the text unchanged.
if final_response and not interrupted:
try:
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_transform_results = _invoke_hook(
"transform_llm_output",
response_text=final_response,
session_id=agent.session_id or "",
model=agent.model,
platform=getattr(agent, "platform", None) or "",
)
for _hook_result in _transform_results:
if isinstance(_hook_result, str) and _hook_result:
_pre_transform_response = final_response
final_response = _hook_result
_response_transformed = True
break # First non-empty string wins
except Exception as exc:
logger.warning("transform_llm_output hook failed: %s", exc)
# Plugin hook: post_llm_call — fired once per turn after the tool loop (e.g. sync
# conversation data to an external memory system).
if final_response and not interrupted:
try:
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_invoke_hook(
"post_llm_call",
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=getattr(agent, "platform", None) or "",
)
except Exception as exc:
logger.warning("post_llm_call hook failed: %s", exc)
# Context engine observation hook (complements per-request select_context()):
# notify the engine the turn finished with the finalized transcript. Fail-open.
try:
from agent.conversation_loop import _notify_context_engine_turn_complete
# ``_last_turn_usage`` holds the last API response's canonical usage dict, or
# ``None`` on turns that never reached a provider response — by contract.
_turn_usage = getattr(agent, "_last_turn_usage", None)
_notify_context_engine_turn_complete(
agent,
messages,
usage=_turn_usage,
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)
# 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.
last_reasoning = None
for msg in reversed(messages):
if msg.get("role") == "user":
break # turn boundary — don't cross into prior turns
if msg.get("role") == "assistant" and msg.get("reasoning"):
last_reasoning = msg["reasoning"]
break
# Surrogate chokepoint: ``final_response`` may be RAW SDK content, and a lone UTF-16
# surrogate crashes downstream consumers (stdout, Telegram ``utf16_len``, JSON).
# Scrub once where model text leaves the loop (#80366).
if isinstance(final_response, str):
final_response = _sanitize_surrogates(final_response)
# Build result with interrupt info if applicable
result = {
"final_response": final_response,
"last_reasoning": last_reasoning,
"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,
"provider": agent.provider,
"base_url": agent.base_url,
"input_tokens": agent.session_input_tokens,
"output_tokens": agent.session_output_tokens,
"cache_read_tokens": agent.session_cache_read_tokens,
"cache_write_tokens": agent.session_cache_write_tokens,
"reasoning_tokens": agent.session_reasoning_tokens,
"prompt_tokens": agent.session_prompt_tokens,
"completion_tokens": agent.session_completion_tokens,
"total_tokens": agent.session_total_tokens,
"last_prompt_tokens": getattr(agent.context_compressor, "last_prompt_tokens", 0) or 0,
"estimated_cost_usd": agent.session_estimated_cost_usd,
"cost_status": agent.session_cost_status,
"cost_source": agent.session_cost_source,
# Requested service tier (from request_overrides.extra_body), for
# billing audits by callers like `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" (and desktop can toast) instead of a quiet complete frame.
if failed and str(_turn_exit_reason) == "session_persistence_failed":
result["error"] = final_response or (
"session storage could not be written — check the state database "
"health (`hermes doctor`), then send your message again"
)
# Machine-readable cause for the gateway/desktop, exactly
# 'session_persistence_failed:<locked|compression|turn_lease|corrupt|...>'.
# Never clobber a failure_reason another path already stamped.
if "failure_reason" not in result:
_cause = getattr(agent, "_last_persistence_error_cause", None)
result["failure_reason"] = (
"session_persistence_failed:" + (_cause or "unknown")
)
# Surface post-loop cleanup failures so the caller can tell a clean turn from one
# whose teardown raised; 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
# Include interrupt message if one triggered the interrupt
if interrupted and agent._interrupt_message:
result["interrupt_message"] = agent._interrupt_message
# Clear interrupt state after handling
agent.clear_interrupt()
# Clear stream callback so it doesn't leak into future calls
agent._stream_callback = None
# Check skill trigger NOW — based on how many tool iterations THIS turn used.
_should_review_skills = False
if (agent._skill_nudge_interval > 0
and agent._iters_since_skill >= agent._skill_nudge_interval
and "skill_manage" in agent.valid_tool_names):
_should_review_skills = True
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.
if (
final_response
and not interrupted
and not getattr(agent, "skip_background_review", False)
and (_should_review_memory or _should_review_skills)
):
try:
# _spawn_background_review clones the snapshot structurally so
# the fork's in-place sanitizers can't reach the live transcript.
agent._spawn_background_review(
messages_snapshot=list(messages),
review_memory=_should_review_memory,
review_skills=_should_review_skills,
)
except Exception:
pass # Background review is best-effort
# Memory provider on_session_end()/shutdown_all() are NOT called here:
# run_conversation() runs once per message; CLI/gateway own session-end cleanup.
# Plugin hook: on_session_end — fired at the end of every run_conversation call.
try:
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_invoke_hook(
"on_session_end",
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=getattr(agent, "platform", None) or "",
)
except Exception as exc:
logger.warning("on_session_end hook failed: %s", exc)
agent._turn_preflight_display_snapshot = None
agent._turn_received_provider_response = False
return result